From 7396056a2eabf5d3165c5b36d699bfa81afe40fd Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 01:19:00 -0700 Subject: [PATCH 1/9] Declare workbench-digest cadence as a native schedule trigger (CL-4455) The hashed definition now carries Interchange ScheduleTrigger { type: "schedule", cron: "0 9 * * *" } instead of a mail trigger whose `to` was not the fire address. Launch remains sidecar routeMail to the live deployment; that is transport, not a start condition. --- packages/seeding/src/seed.ts | 3 +- packages/seeding/test/seed.test.ts | 8 ++--- scripts/e2e/workbench-digest.test.ts | 1 - workflows/workbench-digest/README.md | 20 +++++------ workflows/workbench-digest/package.json | 2 +- workflows/workbench-digest/src/index.ts | 34 +++++++------------ .../workbench-digest/test/definition.test.ts | 13 +++---- 7 files changed, 32 insertions(+), 49 deletions(-) diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index bc76c435e6..cc9efe5fad 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -269,10 +269,9 @@ export const DEFAULT_WORKFLOWS: readonly DefaultWorkflow[] = [ assetName: "workbench-digest", displayName: catalogDisplayName("workbench-digest"), automatable: catalogAutomatable("workbench-digest"), - buildJson: (tenantDomain, model) => + buildJson: (_tenantDomain, model) => serializeWorkbenchDigestWorkflow( buildWorkbenchDigestWorkflow({ - triggerAddress: `workbench-digest@${tenantDomain}`, inferencePreferences: [ { provider: model.provider, model: model.model }, ], diff --git a/packages/seeding/test/seed.test.ts b/packages/seeding/test/seed.test.ts index 3bb9544c2c..cf7f9bce77 100644 --- a/packages/seeding/test/seed.test.ts +++ b/packages/seeding/test/seed.test.ts @@ -1113,13 +1113,13 @@ describe("seedTenant", () => { if (!push0) throw new Error("expected one workflow push"); const definition = JSON.parse(push0.workflowJson) as { id: string; - triggers: { type: string; to: string }[]; + triggers: { type: string; cron?: string }[]; stepOrder: string[]; }; expect(definition.id).toBe("wf_workbench_digest"); - expect(definition.triggers[0]?.to).toBe( - `workbench-digest@${TENANT_DOMAIN}`, - ); + expect(definition.triggers).toEqual([ + { type: "schedule", cron: "0 9 * * *" }, + ]); expect(definition.stepOrder).toEqual(["workbench-digest"]); // Defaults deploy against the tenant's real model (not noop). diff --git a/scripts/e2e/workbench-digest.test.ts b/scripts/e2e/workbench-digest.test.ts index 6338f90b53..ecf2dc56eb 100644 --- a/scripts/e2e/workbench-digest.test.ts +++ b/scripts/e2e/workbench-digest.test.ts @@ -187,7 +187,6 @@ describe.skipIf(databaseUrl === undefined)("workbench-digest workflow", () => { expectStatus("mint git token", minted, 201); const definition = buildWorkbenchDigestWorkflow({ - triggerAddress: `workbench-digest@${slug}.localhost`, inferencePreferences: [{ provider: "anthropic", model: "noop" }], turnTimeoutMs: DIGEST_TURN_TIMEOUT_MS, }); diff --git a/workflows/workbench-digest/README.md b/workflows/workbench-digest/README.md index 38f4fdaadf..866d9cec00 100644 --- a/workflows/workbench-digest/README.md +++ b/workflows/workbench-digest/README.md @@ -1,20 +1,19 @@ # @corbits/workbench-digest-workflow -A single mail-triggered step meant to be deployed against a workbench's -own timeline address. It relays whatever deterministic summary line its -trigger mail already carries — a message count, a timestamp, any line a -scheduler computed ahead of time — straight back into the workbench, -mirroring how a workbench host's reply becomes a workbench mail post (see -`packages/chat/src/workbench-workflow.ts` and `platform-adapter.ts`). +A single schedule-triggered step meant to be deployed against a +workbench's own timeline address. It fires daily at 09:00 UTC +(`0 9 * * *`) and relays a deterministic summary line straight back +into the workbench, mirroring how a workbench host's reply becomes a +workbench mail post (see `packages/chat/src/workbench-workflow.ts` and +`platform-adapter.ts`). ## What it does One step, one agent, a system prompt that instructs relaying the trigger's exact text with no additions, no commentary, no formatting of -its own. The deterministic content — the actual digest line — is -computed by whatever sends the trigger mail; this definition never -computes it itself, so its output is exactly as deterministic as its -input. +its own. The schedule is fixed on the definition; this package never +computes the digest line itself, so its output is exactly as +deterministic as its input. ## Cost profile @@ -43,7 +42,6 @@ import { } from "@corbits/workbench-digest-workflow"; const definition = buildWorkbenchDigestWorkflow({ - triggerAddress: "workbench-digest@tenant.example", inferencePreferences: [{ provider: "anthropic", model: "noop" }], turnTimeoutMs: 60_000, }); diff --git a/workflows/workbench-digest/package.json b/workflows/workbench-digest/package.json index 1c5fffc6a4..2138703156 100644 --- a/workflows/workbench-digest/package.json +++ b/workflows/workbench-digest/package.json @@ -1,7 +1,7 @@ { "name": "@corbits/workbench-digest-workflow", "private": true, - "description": "Minimal mail-triggered workflow that posts a deterministic summary line, exercising the workbench-mail path at zero inference cost", + "description": "Minimal schedule-triggered workflow that posts a deterministic summary line, exercising the workbench-mail path at zero inference cost", "version": "0.0.1", "license": "LGPL-2.1-or-later", "type": "module", diff --git a/workflows/workbench-digest/src/index.ts b/workflows/workbench-digest/src/index.ts index 54acecc63f..0bae134d5c 100644 --- a/workflows/workbench-digest/src/index.ts +++ b/workflows/workbench-digest/src/index.ts @@ -1,14 +1,13 @@ -// The workbench-digest workflow: a single-step, mail-triggered definition -// meant to be deployed against a workbench's own timeline address so its -// reply posts a deterministic summary line — the trigger's own message -// count and timestamp — back into the workbench, the same way a workbench +// The workbench-digest workflow: a single-step definition meant to be +// deployed against a workbench's own timeline address so its reply +// posts a deterministic summary line — the trigger's own message count +// and timestamp — back into the workbench, the same way a workbench // host's reply becomes a workbench mail post (see // `packages/chat/src/workbench-workflow.ts` and `platform-adapter.ts`'s -// `connectorReplyContent` handling). The digest line itself is -// computed by the caller that sends the trigger mail (a scheduler, in -// the common case) and carried in the trigger body; this definition's -// only job is to relay that already-deterministic line back out -// verbatim, so nothing about the reply's content is left to the model. +// `connectorReplyContent` handling). Fired on a 09:00 UTC schedule +// (`WORKBENCH_DIGEST_SCHEDULE_CRON`). This definition's only job is to +// relay an already-deterministic digest line back out verbatim, so +// nothing about the reply's content is left to the model. // // Zero inference cost: like `@corbits/heartbeat-workflow`, this // definition is deployed with its `inferencePreferences` pinned to the @@ -30,6 +29,7 @@ import type { WorkflowDefinition } from "@intx/workflow"; export const WORKBENCH_DIGEST_WORKFLOW_ID = "wf_workbench_digest"; export const WORKBENCH_DIGEST_STEP_ID = "workbench-digest"; +export const WORKBENCH_DIGEST_SCHEDULE_CRON = "0 9 * * *"; export const WORKBENCH_DIGEST_SYSTEM_PROMPT = "You post a single deterministic summary line into a workbench. The " + @@ -38,14 +38,11 @@ export const WORKBENCH_DIGEST_SYSTEM_PROMPT = "commentary, no formatting of your own."; /** - * Everything the definition needs that is per-deployment data. The - * trigger address names a specific deployment's inbox — for this - * workflow, ordinarily a workbench's own mail address — so a definition - * built here is per-deployment by construction. + * Everything the definition needs that is per-deployment data. + * Inference preferences and the per-turn timeout are resolved at + * deploy time; the schedule trigger is fixed on the definition. */ export interface WorkbenchDigestWorkflowInput { - /** The deployment's mail address; each inbound mail is one run. */ - readonly triggerAddress: string; /** Provider/model preferences, in order; resolved at deploy time. */ readonly inferencePreferences: readonly InferencePreference[]; /** Per-turn timeout in milliseconds, enforced on the single step. */ @@ -64,11 +61,6 @@ export interface WorkbenchDigestWorkflowInput { export function buildWorkbenchDigestWorkflow( input: WorkbenchDigestWorkflowInput, ): WorkflowDefinition { - if (input.triggerAddress === "") { - throw new Error( - "buildWorkbenchDigestWorkflow requires a non-empty triggerAddress", - ); - } if (!Number.isInteger(input.turnTimeoutMs) || input.turnTimeoutMs <= 0) { throw new Error( "buildWorkbenchDigestWorkflow requires turnTimeoutMs to be a positive integer", @@ -76,7 +68,7 @@ export function buildWorkbenchDigestWorkflow( } return defineWorkflow({ id: WORKBENCH_DIGEST_WORKFLOW_ID, - trigger: { type: "mail", to: input.triggerAddress }, + trigger: { type: "schedule", cron: WORKBENCH_DIGEST_SCHEDULE_CRON }, steps: { "workbench-digest": step({ agent: defineAgent({ diff --git a/workflows/workbench-digest/test/definition.test.ts b/workflows/workbench-digest/test/definition.test.ts index b731c564be..717d490933 100644 --- a/workflows/workbench-digest/test/definition.test.ts +++ b/workflows/workbench-digest/test/definition.test.ts @@ -7,6 +7,7 @@ import { expect, test } from "bun:test"; import type { StepPrimitive, WorkflowDefinition } from "@intx/workflow"; import { + WORKBENCH_DIGEST_SCHEDULE_CRON, WORKBENCH_DIGEST_STEP_ID, WORKBENCH_DIGEST_SYSTEM_PROMPT, WORKBENCH_DIGEST_WORKFLOW_ID, @@ -15,7 +16,6 @@ import { } from "../src/index"; const INPUT = { - triggerAddress: "ch_dep000000000000@example.test", inferencePreferences: [{ provider: "anthropic", model: "claude-test" }], turnTimeoutMs: 60000, } as const; @@ -41,12 +41,13 @@ test("the step carries an explicit per-turn timeout", () => { expect(digestStep(definition).timeout).toBe(INPUT.turnTimeoutMs); }); -test("the workflow is triggered by mail to the given deployment address", () => { +test("the workflow is triggered on the daily 09:00 UTC schedule", () => { const definition = buildWorkbenchDigestWorkflow(INPUT); expect(definition.id).toBe(WORKBENCH_DIGEST_WORKFLOW_ID); expect(definition.triggers).toEqual([ - { type: "mail", to: INPUT.triggerAddress }, + { type: "schedule", cron: "0 9 * * *" }, ]); + expect(WORKBENCH_DIGEST_SCHEDULE_CRON).toBe("0 9 * * *"); }); test("the agent instructs relaying the exact summary line, carries the preferences, and inlines no tools", () => { @@ -91,12 +92,6 @@ test("serialization fails loud on a function-valued field, naming its path", () ); }); -test("an empty trigger address is rejected", () => { - expect(() => - buildWorkbenchDigestWorkflow({ ...INPUT, triggerAddress: "" }), - ).toThrow(/triggerAddress/); -}); - test("a non-positive or fractional turn timeout is rejected", () => { expect(() => buildWorkbenchDigestWorkflow({ ...INPUT, turnTimeoutMs: 0 }), From 057a258ed07d4f792b088931a4d4cc9188369ff3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 12:12:02 -0700 Subject: [PATCH 2/9] Tick native schedule triggers from the hub poller (CL-4455) The hashed cron lives on the workflow definition. The hub claims each UTC minute on workflow_definition.schedule_claimed_minute and launches through the sidecar. After launch, a workbench-delivering workflow joins the tenant's first existing workbench; no workbench still launches. Digest is seeded stopped so a fresh tenant does not fire until a member restores it. Seed pages every definitions cursor to find that row. --- VENDORED.md | 2 +- apps/hub/src/cron-due.ts | 9 +- apps/hub/src/index.ts | 45 ++- apps/hub/src/workflow-scheduler.ts | 268 +++++++++++++ apps/hub/test/workflow-scheduler.test.ts | 296 ++++++++++++++ docs/workflow-model.md | 14 +- packages/routines/src/cron.ts | 379 +----------------- packages/seeding/src/default-routines.ts | 32 +- packages/seeding/src/seed.ts | 105 +++++ .../seeding/test/default-routines.test.ts | 135 +++++-- packages/seeding/test/seed.test.ts | 219 ++++++++++ packages/workflows/src/index.ts | 14 + packages/workflows/src/schedule/cron.test.ts | 270 +++++++++++++ packages/workflows/src/schedule/cron.ts | 362 +++++++++++++++++ .../src/schedule/from-projection.test.ts | 46 +++ .../workflows/src/schedule/from-projection.ts | 27 ++ ...low_definition_schedule_claimed_minute.sql | 4 + vendor/intx/db/migrations/meta/_journal.json | 7 + vendor/intx/db/src/migrate.test.ts | 6 +- .../db/src/schema/workflow-definitions.ts | 4 + 20 files changed, 1796 insertions(+), 448 deletions(-) create mode 100644 apps/hub/src/workflow-scheduler.ts create mode 100644 apps/hub/test/workflow-scheduler.test.ts create mode 100644 packages/workflows/src/schedule/cron.test.ts create mode 100644 packages/workflows/src/schedule/cron.ts create mode 100644 packages/workflows/src/schedule/from-projection.test.ts create mode 100644 packages/workflows/src/schedule/from-projection.ts create mode 100644 vendor/intx/db/migrations/0089_workflow_definition_schedule_claimed_minute.sql diff --git a/VENDORED.md b/VENDORED.md index 609db403d9..914c1ff6d2 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/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/index.ts b/apps/hub/src/index.ts index ec2fba6e76..50f8cb9a2e 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -377,6 +377,12 @@ 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, +} from "./workflow-scheduler"; import { createToolGrantsForPins } from "./tool-grants"; import { createMcpCredentialBindingsFor } from "./mcp-credential-bindings"; import { reconcilePinnedToolPackagesAfterConnect } from "./connection-live-reconcile"; @@ -3132,15 +3138,14 @@ export async function createHub(config: HubConfig) { validateRoutineInput: routineInputValid, }), ); - // 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. + // Recurring auto-fire: two pollers. `routine-scheduler.ts` ticks enabled + // `@corbits/routines` rows so a user-created daily/cron routine keeps firing. + // `workflow-scheduler.ts` ticks authored, deployed definitions whose frozen + // projection carries a native ScheduleTrigger (workbench-digest). This hub + // has no general job-runner today, so each loop is scoped to exactly one + // job rather than standing up a bespoke cron daemon as a hidden dependency. + // Every hub replica can safely run both: each routine fire is claimed on + // `nextFireAt`, each native fire on `workflow_definition.schedule_claimed_minute`. const routineScheduler = createRoutineScheduler({ store: routineStore, launcher: routineLauncher, @@ -3149,6 +3154,27 @@ export async function createHub(config: HubConfig) { ? { pollIntervalMs: config.routineSchedulerPollIntervalMs } : {}), }); + const workflowScheduler = createWorkflowScheduler({ + listScheduledDefinitions: listScheduledDefinitionsFromDb(db), + claimScheduleMinute: claimScheduleMinuteFromDb(db), + launch: launchScheduledDefinitionFromDb({ + db, + sidecarRouter, + 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), + }), + ...(config.routineSchedulerPollIntervalMs !== undefined + ? { pollIntervalMs: config.routineSchedulerPollIntervalMs } + : {}), + }); // The inventory Myra is offered when drafting a new agent definition // (`plannerInventorySources` below, `@corbits/agent-directory`'s own @@ -3622,6 +3648,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/workflow-scheduler.ts b/apps/hub/src/workflow-scheduler.ts new file mode 100644 index 0000000000..250442b9a9 --- /dev/null +++ b/apps/hub/src/workflow-scheduler.ts @@ -0,0 +1,268 @@ +// A minimal periodic loop that ticks native ScheduleTrigger cadences on +// authored, deployed workflow definitions. `@corbits/routines` is not in +// this path: 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, + }, + }); + } + }; +} diff --git a/apps/hub/test/workflow-scheduler.test.ts b/apps/hub/test/workflow-scheduler.test.ts new file mode 100644 index 0000000000..419ac5b14b --- /dev/null +++ b/apps/hub/test/workflow-scheduler.test.ts @@ -0,0 +1,296 @@ +// Scheduler poller: cron match → CAS → launch. Skip-missed, no backoff. +import { afterEach, describe, expect, jest, test } from "bun:test"; +import { + createWorkflowScheduler, + DEFAULT_WORKFLOW_SCHEDULER_POLL_INTERVAL_MS, + joinScheduledDefinitionToWorkbench, + 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); + }); +}); diff --git a/docs/workflow-model.md b/docs/workflow-model.md index 386c7074c2..5ef051587d 100644 --- a/docs/workflow-model.md +++ b/docs/workflow-model.md @@ -145,9 +145,11 @@ dead weight. ## What is not native, and stays in Workbench Checked against upstream origin/main `d187e327`: Interchange has no -routine or scheduler (its `ScheduleTrigger` type has zero consumers), no -per-principal "launchable definitions" query, and no agent-facing tool that -writes assets. Those three compositions are Workbench's, built over native -rows and `@intx/authz`. Before adding anything else, check upstream first; -if upstream has it past our pin, re-vendor at that commit rather than -reimplementing. +routine table and no definition-level scheduler — its `ScheduleTrigger` +type is projected, and Workbench's hub poller (`workflow-scheduler.ts`) +is the consumer (per-minute CAS on +`workflow_definition.schedule_claimed_minute`, skip-missed). Per-principal +"launchable definitions" and the agent-facing tool that writes assets +remain Workbench compositions, built over native rows and `@intx/authz`. +Before adding anything else, check upstream first; if upstream has it +past our pin, re-vendor at that commit rather than reimplementing. diff --git a/packages/routines/src/cron.ts b/packages/routines/src/cron.ts index 49a2229466..1c3dd9f42e 100644 --- a/packages/routines/src/cron.ts +++ b/packages/routines/src/cron.ts @@ -1,362 +1,17 @@ -// The single 5-field cron grammar `@corbits/routines` speaks: one parser -// shared by validation (does this expression make sense at save time?) -// and execution (does this expression match this minute?). Before this -// module existed, `trigger.ts` and the hub's scheduler each hand-rolled -// their own field parser — format-only, no range checking, incompatible -// clause orderings — so an expression could validate as saveable and -// then never fire, or fire on one parser's reading and not the other's. -// One parser closes that gap: whatever validates here is exactly what -// matches here. -// -// Semantics match Vixie/POSIX cron on the cases that matter for routines: -// - `*/N` steps from the field minimum (so `*/2` on day-of-month is -// 1,3,5… not 2,4,6…). -// - When both day-of-month and day-of-week are restricted, they OR -// (`0 0 13 * 5` = the 13th or any Friday). -// - Day-of-week accepts both 0 and 7 as Sunday. -// - Matching can be evaluated in an IANA timezone; `nextFireAt` is still -// stored as a UTC instant. -export type CronField = - "minute" | "hour" | "dayOfMonth" | "month" | "dayOfWeek"; - -/** Field order in a 5-field cron expression, paired with its valid range. */ -export const CRON_FIELD_RANGES: Readonly< - Record -> = { - minute: [0, 59], - hour: [0, 23], - dayOfMonth: [1, 31], - month: [1, 12], - // 0 and 7 are both Sunday (POSIX); validation accepts either, matching - // normalises 7 → 0 so a Date's getUTCDay() of 0 still matches `7`. - dayOfWeek: [0, 7], -}; - -const CRON_FIELD_ORDER: readonly CronField[] = [ - "minute", - "hour", - "dayOfMonth", - "month", - "dayOfWeek", -]; - -type CronClause = { - readonly base: "*" | number; - readonly rangeEnd?: number; - readonly step?: number; -}; - -// Standard cron clause order is base, then an optional range, then an -// optional step: `5`, `5-10`, `*/2`, `5-10/2`. Only this order is -// accepted — the reversed `5/2-10` idiom neither cron nor either of -// this repo's previous hand-rolled parsers meaningfully supported. -const CLAUSE_PATTERN = /^(\*|[0-9]+)(?:-([0-9]+))?(?:\/([0-9]+))?$/; - -function parseCronClause(raw: string): CronClause | undefined { - const match = CLAUSE_PATTERN.exec(raw); - if (match === null) return undefined; - const [, base, rangeEnd, step] = match; - const clauseBase: CronClause = { base: base === "*" ? "*" : Number(base) }; - const withRangeEnd = - rangeEnd !== undefined - ? { ...clauseBase, rangeEnd: Number(rangeEnd) } - : clauseBase; - return step !== undefined - ? { ...withRangeEnd, step: Number(step) } - : withRangeEnd; -} - -/** - * True when `clause` is meaningful for a field whose valid values span - * `[min, max]`: every literal value in range, and — the case the old - * format-only validators missed — a reversed range (`10-5`) rejected - * rather than accepted as an expression that is syntactically fine and - * unconditionally never true. - */ -function clauseInRange( - clause: CronClause, - [min, max]: readonly [number, number], -): boolean { - if (clause.step !== undefined && clause.step <= 0) return false; - if (clause.base === "*") return true; - if (clause.base < min || clause.base > max) return false; - if (clause.rangeEnd === undefined) return true; - if (clause.rangeEnd < min || clause.rangeEnd > max) return false; - return clause.rangeEnd >= clause.base; -} - -/** - * Does `clause` match `value` for a field whose minimum is `min`? - * Star-with-step (asterisk-slash-N) steps from `min`, not from zero — so on a - * 1-based day-of-month, that pattern yields 1,3,5… rather than 2,4,6…. - */ -function clauseMatches( - clause: CronClause, - value: number, - min: number, -): boolean { - if (clause.base === "*") { - return clause.step === undefined ? true : (value - min) % clause.step === 0; - } - if (clause.rangeEnd === undefined && clause.step === undefined) { - return value === clause.base; - } - const upper = clause.rangeEnd ?? clause.base; - if (value < clause.base || value > upper) return false; - if (clause.step === undefined) return true; - return (value - clause.base) % clause.step === 0; -} - -function everyClause( - field: string, - test: (clause: CronClause) => boolean, -): boolean { - const clauses = field.split(",").map(parseCronClause); - if (clauses.length === 0) return false; - return clauses.every((clause) => clause !== undefined && test(clause)); -} - -function someClause( - field: string, - test: (clause: CronClause) => boolean, -): boolean { - return field.split(",").some((raw) => { - const clause = parseCronClause(raw); - return clause !== undefined && test(clause); - }); -} - -/** - * Loud, eager validation for a raw 5-field cron expression - * (minute hour day-of-month month day-of-week): every field's syntax - * AND every field's values must be sane for that position — a minute - * of 60, a month of 13, or a reversed range all fail here, never - * silently accepted only to fail at fire-time. - * - * This is syntactic + range only. Whether the expression can ever - * actually fire (e.g. `0 0 31 2 *` — Feb 31) is `cronExpressionCanFire`. - */ -export function isValidCronExpression(expression: string): boolean { - const fields = expression.trim().split(/\s+/); - if (fields.length !== 5) return false; - return fields.every((field, index) => { - const cronField = CRON_FIELD_ORDER[index]; - if (cronField === undefined) return false; - return everyClause(field, (clause) => - clauseInRange(clause, CRON_FIELD_RANGES[cronField]), - ); - }); -} - -function fieldMatches(field: string, value: number, min: number): boolean { - return someClause(field, (clause) => clauseMatches(clause, value, min)); -} - -/** - * Day-of-week match with 0/7 both meaning Sunday. A clause of `7` matches - * a Date whose day is 0, and a clause of `0` matches the same. - */ -function dayOfWeekMatches(field: string, dayOfWeek: number): boolean { - const [min] = CRON_FIELD_RANGES.dayOfWeek; - if (fieldMatches(field, dayOfWeek, min)) return true; - // Date APIs report Sunday as 0; expressions may say 7. - if (dayOfWeek === 0 && fieldMatches(field, 7, min)) return true; - return false; -} - -/** - * True when the day-of-month field is restricted (not a bare `*`, and not - * only `*` with a step that still covers every day). Vixie OR-semantics - * for DOM/DOW only apply when *both* fields are restricted. - */ -function isDayFieldRestricted(field: string): boolean { - const trimmed = field.trim(); - if (trimmed === "*") return false; - // `*/1` is every day — unrestricted in effect — but any other form - // (including `*/2`, `1-5`, `1,15`) is a restriction. - if (trimmed === "*/1") return false; - return true; -} - -export type ZonedParts = { - readonly year: number; - readonly month: number; - readonly day: number; - readonly hour: number; - readonly minute: number; - readonly dayOfWeek: number; -}; - -/** - * Wall-clock parts of `at` in `timeZone` (IANA). Falls back to UTC when - * `timeZone` is omitted or `"UTC"`. Throws if `timeZone` is not a valid - * IANA name — call sites that accept user input must validate first via - * `isValidTimeZone`. - */ -export function zonedParts(at: Date, timeZone: string = "UTC"): ZonedParts { - if (timeZone === "UTC") { - return { - year: at.getUTCFullYear(), - month: at.getUTCMonth() + 1, - day: at.getUTCDate(), - hour: at.getUTCHours(), - minute: at.getUTCMinutes(), - dayOfWeek: at.getUTCDay(), - }; - } - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - weekday: "short", - hourCycle: "h23", - }).formatToParts(at); - - let year = 0; - let month = 0; - let day = 0; - let hour = 0; - let minute = 0; - let weekday = ""; - for (const part of parts) { - if (part.type === "year") year = Number(part.value); - else if (part.type === "month") month = Number(part.value); - else if (part.type === "day") day = Number(part.value); - else if (part.type === "hour") hour = Number(part.value); - else if (part.type === "minute") minute = Number(part.value); - else if (part.type === "weekday") weekday = part.value; - } - // en-US short weekday → 0=Sun … 6=Sat - const dowMap: Record = { - Sun: 0, - Mon: 1, - Tue: 2, - Wed: 3, - Thu: 4, - Fri: 5, - Sat: 6, - }; - const dayOfWeek = dowMap[weekday]; - if (dayOfWeek === undefined) { - throw new Error( - `zonedParts: could not resolve weekday "${weekday}" in ${timeZone}`, - ); - } - return { year, month, day, hour, minute, dayOfWeek }; -} - -/** True when `timeZone` is a recognised IANA name (or `"UTC"`). */ -export function isValidTimeZone(timeZone: string): boolean { - if (timeZone === "UTC") return true; - try { - new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date()); - return true; - } catch { - return false; - } -} - -/** - * True when `expression`'s fields match the wall-clock minute of `at` - * in `timeZone` (default UTC). DOM and DOW OR when both are restricted - * (Vixie/POSIX); otherwise AND. - */ -export function cronMatchesMinute( - expression: string, - at: Date, - timeZone: string = "UTC", -): boolean { - const fields = expression.trim().split(/\s+/); - const [minute, hour, dayOfMonth, month, dayOfWeek] = fields; - if ( - minute === undefined || - hour === undefined || - dayOfMonth === undefined || - month === undefined || - dayOfWeek === undefined - ) { - throw new Error(`"${expression}" is not a 5-field cron expression`); - } - - const parts = zonedParts(at, timeZone); - const timeAndMonth = - fieldMatches(minute, parts.minute, CRON_FIELD_RANGES.minute[0]) && - fieldMatches(hour, parts.hour, CRON_FIELD_RANGES.hour[0]) && - fieldMatches(month, parts.month, CRON_FIELD_RANGES.month[0]); - if (!timeAndMonth) return false; - - const domOk = fieldMatches( - dayOfMonth, - parts.day, - CRON_FIELD_RANGES.dayOfMonth[0], - ); - const dowOk = dayOfWeekMatches(dayOfWeek, parts.dayOfWeek); - - if (isDayFieldRestricted(dayOfMonth) && isDayFieldRestricted(dayOfWeek)) { - // Vixie: either day-of-month or day-of-week may match. - return domOk || dowOk; - } - return domOk && dowOk; -} - -/** The UTC minute `at` falls in, as a stable, comparable integer key. */ -export function minuteKey(at: Date): number { - return Math.floor(at.getTime() / 60_000); -} - -/** - * Bounds how far ahead `nextCronFireAfter` will search before giving up. - * One leap year of minutes is enough for any expression that fires at - * least annually; impossible expressions (Feb 31, etc.) fail here — and - * at save time via `cronExpressionCanFire` — never inside a claim - * transaction that would otherwise spin for millions of iterations. - */ -export const MAX_LOOKAHEAD_MINUTES = 366 * 24 * 60; - -/** - * The next minute at or after `after` (exclusive) that `expression` - * matches in `timeZone` — the closed-form "when does this actually fire - * next" calculation, used both to persist a routine's `nextFireAt` and - * to render a UI's next-run estimate against the exact semantics that - * fire it. `nextFireAt` is always a UTC instant even when matching is - * zoned. - */ -export function nextCronFireAfter( - expression: string, - after: Date, - timeZone: string = "UTC", -): Date { - const start = minuteKey(after) + 1; - for (let minute = start; minute - start <= MAX_LOOKAHEAD_MINUTES; minute++) { - const candidate = new Date(minute * 60_000); - if (cronMatchesMinute(expression, candidate, timeZone)) return candidate; - } - throw new Error( - `"${expression}" has no fire time within the lookahead window` + - (timeZone === "UTC" ? "" : ` in ${timeZone}`), - ); -} - -/** - * True when `expression` has at least one fire inside the lookahead - * window from `from` (default: Unix epoch). Used at save time so an - * impossible expression (`0 0 31 2 *`) is rejected before it can ever - * reach the scheduler's claim path. - */ -export function cronExpressionCanFire( - expression: string, - timeZone: string = "UTC", - from: Date = new Date(0), -): boolean { - if (!isValidCronExpression(expression)) return false; - if (!isValidTimeZone(timeZone)) return false; - try { - nextCronFireAfter(expression, from, timeZone); - return true; - } catch { - return false; - } -} +// Cron matching lives on `@corbits/workflows` — it is definition-domain +// grammar (the hashed `ScheduleTrigger` cadence), not a routine concern. +// Re-exported here so existing `@corbits/routines` / `@corbits/routines/cron` +// importers keep compiling until that package is deleted. +export { + CRON_FIELD_RANGES, + cronExpressionCanFire, + cronMatchesMinute, + isValidCronExpression, + isValidTimeZone, + MAX_LOOKAHEAD_MINUTES, + minuteKey, + nextCronFireAfter, + zonedParts, + type CronField, + type ZonedParts, +} from "@corbits/workflows"; diff --git a/packages/seeding/src/default-routines.ts b/packages/seeding/src/default-routines.ts index df0f31c184..bc6ba96aa7 100644 --- a/packages/seeding/src/default-routines.ts +++ b/packages/seeding/src/default-routines.ts @@ -1,18 +1,19 @@ // Preset Routine rows every real tenant starts with, planted after // `seedTenant`'s own workflow-deploy loop (see seed.ts's `seedTenant`, // which calls `ensureDefaultRoutines` last). Deploying a workflow only -// makes it launchable; nothing shows up in the Routines picker until a -// `routine` row actually references its deployed definition — CL-6201 -// is exactly that gap: every default workflow deployed, zero routine -// rows ever created. +// makes it launchable; a run-now-only utility still needs a `routine` +// row before it shows up in the Routines picker (CL-6201). // -// Every preset is created DISABLED (`enabled: false`): a scheduled -// preset must never start firing just because a bench was minted. A -// disabled routine's "Run now" still works (the run-now route never -// checks `enabled` — see `packages/routines/src/routes.ts`), so a -// preset is inspectable and runnable the moment it's seeded, exactly -// what CL-6201 asks of the previously-stranded last-30-days-research -// definition. +// workbench-digest is not a preset here: its cadence is the native +// `ScheduleTrigger` on the frozen definition (CL-4455), so a wrapper +// row would be a second schedule. A previously-planted pristine +// "Daily digest" row is retired by `pruneDroppedPresetRoutines`. +// +// Every remaining preset is created DISABLED (`enabled: false`): a +// scheduled preset must never start firing just because a bench was +// minted. A disabled routine's "Run now" still works (the run-now +// route never checks `enabled` — see `packages/routines/src/routes.ts`), +// so a preset is inspectable and runnable the moment it's seeded. // // Idempotent by a stable `presetKey` (each preset's own `assetName`), // enforced server-side by `@corbits/routines`' `createRoutineIfAbsent` @@ -74,15 +75,6 @@ export type DefaultRoutinePreset = { }; export const DEFAULT_ROUTINE_PRESETS: readonly DefaultRoutinePreset[] = [ - { - name: "Daily digest", - assetName: "workbench-digest", - trigger: { kind: "daily", hour: 9, minute: 0 }, - input: { - summary: - "Daily digest — nothing computed yet; edit this routine's input.", - }, - }, { name: "Last 30 days research", assetName: "last-30-days-research", diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index cc9efe5fad..cb6bf34751 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -20,6 +20,7 @@ import { ModelResponse, ProviderResponse, WorkflowRunHealth, + WorkflowDefinitionResponse, paginatedSchema, Capability, } from "@intx/types"; @@ -188,6 +189,12 @@ export type DefaultWorkflow = { * model. */ modelSource?: (hubUrl: string) => ModelSource; + /** + * When true, PUT the authored definition to `stopped` after deploy so + * a native ScheduleTrigger does not fire every tenant at the next + * matching minute. Absent means leave the schema default (`deployed`). + */ + startStopped?: true; }; function catalogDisplayName(assetName: string): string { @@ -278,6 +285,7 @@ export const DEFAULT_WORKFLOWS: readonly DefaultWorkflow[] = [ turnTimeoutMs: WORKBENCH_DIGEST_TURN_TIMEOUT_MS, }), ), + startStopped: true, }, { assetName: "last-30-days-research", @@ -347,6 +355,7 @@ export const SEED_GRANTS: readonly { resource: string; action: string }[] = [ // (POST/PATCH .../routines) — none of the grants above cover those // routes, which gate on their own resource/action pairs. { resource: "workflow-definition:*", action: "read" }, + { resource: "workflow-definition:*", action: "update" }, { resource: "workflow-run:*", action: "create" }, { resource: "workflow-run:*", action: "write" }, // CL-6346 moved the room routes (post a message, read-state, typing, @@ -733,6 +742,94 @@ async function ensureDeployment( return deployment.id; } +/** + * After deploy, PUT a pristine authored definition to `stopped` so a native + * ScheduleTrigger does not fire every tenant. A member-restored row + * (`updatedAt !== createdAt`) or an already-stopped row is left alone — + * re-seed must never re-archive an enablement the member already chose. + * Uses the existing agent-directory status route (grant: + * `workflow-definition:` `update`). + */ +async function stopPristineScheduledDefinition( + api: ApiCall, + cookies: string[], + args: { tenantId: string; assetName: string }, + log: (line: string) => void, +): Promise { + const definitions = await listAllWorkflowDefinitions( + api, + cookies, + args.tenantId, + ); + const row = definitions.find((definition) => definition.name === args.assetName); + if (row === undefined) { + throw new HubApiError( + `seeded workflow ${args.assetName} has no authored definition to stop`, + "re-run: workbench seed after the deploy has projected a definition row", + ); + } + if (row.status === "stopped") { + log(`definition ${args.assetName} already stopped (skipped)`); + return; + } + if (row.createdAt !== row.updatedAt) { + log( + `definition ${args.assetName} was touched; leaving status ${row.status}`, + ); + return; + } + const updated = await api( + "PUT", + `/api/tenants/${args.tenantId}/agent-definitions/${row.id}/status`, + { status: "stopped" }, + cookies, + ); + if (updated.status !== 200) { + throw new HubApiError( + `the hub rejected stopping definition ${args.assetName} with status ${updated.status}: ${JSON.stringify(updated.data)}`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + log( + `stopped definition ${args.assetName} so its native schedule does not fire until restored`, + ); +} + +async function listAllWorkflowDefinitions( + api: ApiCall, + cookies: string[], + tenantId: string, +): Promise<(typeof WorkflowDefinitionResponse.infer)[]> { + const items: (typeof WorkflowDefinitionResponse.infer)[] = []; + let cursor: string | undefined; + for (;;) { + const query = + cursor === undefined + ? "limit=200" + : `limit=200&cursor=${encodeURIComponent(cursor)}`; + const listed = await api( + "GET", + `/api/tenants/${tenantId}/workflows/definitions?${query}`, + undefined, + cookies, + ); + const page = parseAs( + paginatedSchema(WorkflowDefinitionResponse), + listed.data, + "definitions response", + ); + items.push(...page.data); + if (page.nextCursor === null) return items; + if (items.length > 10_000) { + throw new HubApiError( + `definitions list for tenant ${tenantId} did not terminate while paging`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + cursor = page.nextCursor; + } +} + async function confirmDeploymentAnswers( api: ApiCall, cookies: string[], @@ -926,6 +1023,14 @@ export async function seedTenant(args: SeedTenantArgs): Promise { log, ); + if (workflow.startStopped === true) { + await stopPristineScheduledDefinition( + api, + cookies, + { tenantId: tenant.tenantId, assetName: workflow.assetName }, + log, + ); + } if (confirmDeployments) { await confirmDeploymentAnswers( api, diff --git a/packages/seeding/test/default-routines.test.ts b/packages/seeding/test/default-routines.test.ts index 4f5ed94f8c..f4583d4697 100644 --- a/packages/seeding/test/default-routines.test.ts +++ b/packages/seeding/test/default-routines.test.ts @@ -78,16 +78,20 @@ function deployedAssetsAndDeployments() { } describe("DEFAULT_ROUTINE_PRESETS", () => { - test("declares the daily digest and the un-stranded last-30-days-research presets", () => { + test("declares only the un-stranded last-30-days-research preset", () => { expect(DEFAULT_ROUTINE_PRESETS.map((p) => p.name)).toEqual([ - "Daily digest", "Last 30 days research", ]); }); - test("targets the workbench-digest and last-30-days-research assets", () => { + test("does not plant a scheduled digest wrapper — native ScheduleTrigger is the cadence", () => { + expect( + DEFAULT_ROUTINE_PRESETS.some((p) => p.assetName === "workbench-digest"), + ).toBe(false); + }); + + test("targets the last-30-days-research asset", () => { expect(DEFAULT_ROUTINE_PRESETS.map((p) => p.assetName)).toEqual([ - "workbench-digest", "last-30-days-research", ]); }); @@ -98,13 +102,6 @@ describe("DEFAULT_ROUTINE_PRESETS", () => { ); expect(research?.trigger).toBeNull(); }); - - test("daily digest fires on a fixed daily cadence", () => { - const digest = DEFAULT_ROUTINE_PRESETS.find( - (p) => p.assetName === "workbench-digest", - ); - expect(digest?.trigger).toEqual({ kind: "daily", hour: 9, minute: 0 }); - }); }); describe("ensureDefaultRoutines", () => { @@ -158,17 +155,12 @@ describe("ensureDefaultRoutines", () => { await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); - expect(createCalls).toHaveLength(2); - expect(createCalls[0]?.name).toBe("Daily digest"); + expect(createCalls).toHaveLength(1); + expect(createCalls[0]?.name).toBe("Last 30 days research"); expect(createCalls[0]?.body).not.toHaveProperty("deliveryWorkbenchId"); expect(createCalls[0]?.body).not.toHaveProperty("enabled"); - expect(createCalls[1]?.name).toBe("Last 30 days research"); - expect(createCalls[1]?.body).toMatchObject({ - deliveryWorkbenchId: "ch_provisioned", - }); const output = lines.join("\n"); - expect(output).toContain('seeded routine "Daily digest" (disabled)'); expect(output).toContain( 'seeded routine "Last 30 days research" (disabled)', ); @@ -193,16 +185,13 @@ describe("ensureDefaultRoutines", () => { await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); const output = lines.join("\n"); - expect(output).toContain( - 'routine "Daily digest" skipped: no deployed definition named "workbench-digest"', - ); expect(output).toContain( 'routine "Last 30 days research" skipped: no deployed definition named "last-30-days-research"', ); }); // The hub never auto-provisions a delivery workbench (that pollution — - // a workbench literally named "Daily digest" — is exactly what this + // a workbench literally named after the preset — is exactly what this // fix removes): a delivery-required preset seeded with no workbench // named gets this 400, and seeding must skip it honestly rather than // failing the whole seed or fabricating a destination. @@ -243,7 +232,7 @@ describe("ensureDefaultRoutines", () => { const output = lines.join("\n"); expect(output).toContain( - 'routine "Daily digest" skipped: its workflow needs a delivery workbench and this preset names none — create it by hand and pick one', + 'routine "Last 30 days research" skipped: its workflow needs a delivery workbench and this preset names none — create it by hand and pick one', ); }); @@ -268,13 +257,6 @@ describe("ensureDefaultRoutines", () => { status: 200, data: { items: [ - routineRow({ - id: "rtn_1", - name: "My renamed digest", - deliveryWorkbenchId: "ch_existing", - enabled: false, - presetKey: "workbench-digest", - }), routineRow({ id: "rtn_2", name: "Last 30 days research", @@ -294,7 +276,6 @@ describe("ensureDefaultRoutines", () => { await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); const output = lines.join("\n"); - expect(output).toContain('routine "Daily digest" already exists (skipped)'); expect(output).toContain( 'routine "Last 30 days research" already exists (skipped)', ); @@ -346,13 +327,17 @@ describe("ensureDefaultRoutines", () => { path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return assetsResponse([assetRow("ast_digest", "workbench-digest")]); + return assetsResponse([ + assetRow("ast_research", "last-30-days-research"), + ]); } if ( method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - return deploymentsResponse([deploymentRow("dep_digest", "ast_digest")]); + return deploymentsResponse([ + deploymentRow("dep_research", "ast_research"), + ]); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] } }; @@ -363,9 +348,9 @@ describe("ensureDefaultRoutines", () => { status: 201, data: routineRow({ id: "rtn_1", - name: "Daily digest", + name: "Last 30 days research", enabled: false, - presetKey: "workbench-digest", + presetKey: "last-30-days-research", }), }; } @@ -375,7 +360,7 @@ describe("ensureDefaultRoutines", () => { await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); expect(createCalls[0]?.body).toMatchObject({ - presetKey: "workbench-digest", + presetKey: "last-30-days-research", }); }); @@ -392,13 +377,17 @@ describe("ensureDefaultRoutines", () => { path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return assetsResponse([assetRow("ast_digest", "workbench-digest")]); + return assetsResponse([ + assetRow("ast_research", "last-30-days-research"), + ]); } if ( method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - return deploymentsResponse([deploymentRow("dep_digest", "ast_digest")]); + return deploymentsResponse([ + deploymentRow("dep_research", "ast_research"), + ]); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { // The app-level pre-check itself raced and saw nothing yet — @@ -410,10 +399,10 @@ describe("ensureDefaultRoutines", () => { status: 200, data: routineRow({ id: "rtn_winner", - name: "Daily digest", + name: "Last 30 days research", deliveryWorkbenchId: "ch_winner", enabled: false, - presetKey: "workbench-digest", + presetKey: "last-30-days-research", }), }; } @@ -423,7 +412,7 @@ describe("ensureDefaultRoutines", () => { await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); expect(lines.join("\n")).toContain( - 'routine "Daily digest" already exists (skipped)', + 'routine "Last 30 days research" already exists (skipped)', ); }); @@ -532,6 +521,62 @@ describe("ensureDefaultRoutines", () => { ); }); + test("a pristine Daily digest wrapper is retired once digest is no longer a routine preset", async () => { + const { lines, log } = collector(); + const deleteCalls: string[] = []; + const handler: FakeHandler = (method, path) => { + if ( + method === "GET" && + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + return assetsResponse(deployedAssetsAndDeployments().assets); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) { + return deploymentsResponse(deployedAssetsAndDeployments().deployments); + } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { + return { + status: 200, + data: { + items: [ + routineRow({ + id: "rtn_digest", + name: "Daily digest", + enabled: false, + presetKey: "workbench-digest", + }), + routineRow({ + id: "rtn_research", + name: "Last 30 days research", + enabled: false, + presetKey: "last-30-days-research", + }), + ], + }, + }; + } + if ( + method === "DELETE" && + path.startsWith(`/api/tenants/${TENANT_ID}/routines/`) + ) { + deleteCalls.push(path.split("/").pop() ?? ""); + return { status: 204, data: undefined }; + } + return undefined; + }; + + await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); + + expect(deleteCalls).toEqual(["rtn_digest"]); + expect(lines.join("\n")).toContain( + 'routine "Daily digest" retired (its preset no longer ships)', + ); + }); + test("a non-201 create response is a loud failure naming the preset", async () => { const { log } = collector(); const handler: FakeHandler = (method, path) => { @@ -540,13 +585,17 @@ describe("ensureDefaultRoutines", () => { path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return assetsResponse([assetRow("ast_digest", "workbench-digest")]); + return assetsResponse([ + assetRow("ast_research", "last-30-days-research"), + ]); } if ( method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - return deploymentsResponse([deploymentRow("dep_digest", "ast_digest")]); + return deploymentsResponse([ + deploymentRow("dep_research", "ast_research"), + ]); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] } }; diff --git a/packages/seeding/test/seed.test.ts b/packages/seeding/test/seed.test.ts index cf7f9bce77..0a93ef28b2 100644 --- a/packages/seeding/test/seed.test.ts +++ b/packages/seeding/test/seed.test.ts @@ -108,6 +108,37 @@ function baseRoutes(method: string, path: string) { return { status: 200, data: [] }; if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) return { status: 200, data: { items: [] } }; + // CL-4455: a startStopped workflow (workbench-digest) lists definitions + // after deploy and PUTs a pristine row to `stopped`. Tests that do not + // care about that handshake get a pristine digest row and a 200 stop. + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/workflows/definitions`) + ) { + return { + status: 200, + data: { + data: [ + { + id: "wfd_digest", + tenantId: TENANT_ID, + name: "workbench-digest", + currentVersion: "1", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, + }, + }; + } + if ( + method === "PUT" && + path === `/api/tenants/${TENANT_ID}/agent-definitions/wfd_digest/status` + ) { + return { status: 200, data: { id: "wfd_digest", status: "stopped" } }; + } return undefined; } @@ -1029,6 +1060,7 @@ describe("seedTenant", () => { expect(workbenchDigest.displayName).toBe("Workbench digest"); expect(workbenchDigest.automatable).toBe(true); expect(workbenchDigest.modelSource).toBeUndefined(); + expect(workbenchDigest.startStopped).toBe(true); }); test("echo and assistant are not automatable", () => { @@ -1131,6 +1163,190 @@ describe("seedTenant", () => { expect(output).toContain( "confirmed workflow workbench-digest: run run_1 started", ); + expect(output).toContain( + "stopped definition workbench-digest so its native schedule does not fire until restored", + ); + }); + + test("startStopped skips a member-restored digest instead of re-archiving it", async () => { + const { lines, log } = collector(); + let puts = 0; + let runsCalls = 0; + const handler: FakeHandler = (method, path) => { + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/workflows/definitions`) + ) { + return { + status: 200, + data: { + data: [ + { + id: "wfd_digest", + tenantId: TENANT_ID, + name: "workbench-digest", + currentVersion: "1", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ], + nextCursor: null, + }, + }; + } + if (method === "PUT" && path.includes("/agent-definitions/")) { + puts += 1; + throw new Error("must not re-stop a touched definition"); + } + const base = baseRoutes(method, path); + if (base) return base; + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) + return { status: 201, data: assetRow("ast_4", "workbench-digest") }; + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) + return { status: 200, data: [] }; + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) + return { + status: 201, + data: deploymentRow("dep_4", "ast_4", "deployed"), + }; + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/dep_4/runs` + ) { + runsCalls += 1; + return { + status: 200, + data: { runIds: runsCalls === 1 ? [] : ["run_1"] }, + }; + } + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/workflows/dep_4/mail` + ) + return { + status: 202, + data: { + runId: "dep_4", + address: `ins_dep_4@${TENANT_DOMAIN}`, + messageId: "", + }, + }; + return undefined; + }; + + await seedTenant( + args({ + api: fakeAPI(handler), + log, + workflows: DEFAULT_WORKFLOWS.filter( + (w) => w.assetName === "workbench-digest", + ), + }), + ); + + expect(puts).toBe(0); + expect(lines.join("\n")).toContain( + "definition workbench-digest was touched; leaving status deployed", + ); + }); + + test("startStopped pages past the first definitions page to find digest", async () => { + const { lines, log } = collector(); + let puts = 0; + let definitionPages = 0; + const handler: FakeHandler = (method, path) => { + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/workflows/definitions`) + ) { + definitionPages += 1; + if (!path.includes("cursor=")) { + return { + status: 200, + data: { + data: [ + { + id: "wfd_other", + tenantId: TENANT_ID, + name: "echo", + currentVersion: "1", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: "page2", + }, + }; + } + return { + status: 200, + data: { + data: [ + { + id: "wfd_digest", + tenantId: TENANT_ID, + name: "workbench-digest", + currentVersion: "1", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, + }, + }; + } + if ( + method === "PUT" && + path === `/api/tenants/${TENANT_ID}/agent-definitions/wfd_digest/status` + ) { + puts += 1; + return { status: 200, data: { id: "wfd_digest", status: "stopped" } }; + } + const base = baseRoutes(method, path); + if (base) return base; + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) + return { status: 201, data: assetRow("ast_4", "workbench-digest") }; + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) + return { status: 200, data: [] }; + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) + return { + status: 201, + data: deploymentRow("dep_4", "ast_4", "deployed"), + }; + return undefined; + }; + + await seedTenant( + args({ + api: fakeAPI(handler), + log, + workflows: DEFAULT_WORKFLOWS.filter( + (w) => w.assetName === "workbench-digest", + ), + confirmDeployments: false, + }), + ); + + expect(definitionPages).toBe(2); + expect(puts).toBe(1); + expect(lines.join("\n")).toContain( + "stopped definition workbench-digest so its native schedule does not fire until restored", + ); }); test("confirmDeployments: false deploys every default workflow without triggering or confirming any of them", async () => { @@ -1207,6 +1423,9 @@ describe("seedTenant", () => { `seed complete: ${DEFAULT_WORKFLOWS.length} workflow(s) deployed`, ); expect(output).not.toContain("deployed and confirmed"); + expect(output).toContain( + "stopped definition workbench-digest so its native schedule does not fire until restored", + ); }); }); diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts index d460738803..3b1b556748 100644 --- a/packages/workflows/src/index.ts +++ b/packages/workflows/src/index.ts @@ -30,3 +30,17 @@ export { type WorkflowCatalogEntry, type TriggerFieldsValidation, } from "./catalog"; +export { + CRON_FIELD_RANGES, + cronExpressionCanFire, + cronMatchesMinute, + isValidCronExpression, + isValidTimeZone, + MAX_LOOKAHEAD_MINUTES, + minuteKey, + nextCronFireAfter, + zonedParts, + type CronField, + type ZonedParts, +} from "./schedule/cron"; +export { scheduleCronFromProjection } from "./schedule/from-projection"; diff --git a/packages/workflows/src/schedule/cron.test.ts b/packages/workflows/src/schedule/cron.test.ts new file mode 100644 index 0000000000..a9ced44067 --- /dev/null +++ b/packages/workflows/src/schedule/cron.test.ts @@ -0,0 +1,270 @@ +// Table-driven cron correctness: step-from-min, DOM/DOW OR, Sunday=7, +// bounded lookahead, and timezone / DST round-trips. +import { describe, expect, test } from "bun:test"; + +import { + cronExpressionCanFire, + cronMatchesMinute, + isValidCronExpression, + isValidTimeZone, + nextCronFireAfter, + zonedParts, +} from "./cron"; + +describe("isValidCronExpression", () => { + test("accepts standard 5-field expressions", () => { + expect(isValidCronExpression("*/5 * * * *")).toBe(true); + expect(isValidCronExpression("0 9 * * 1")).toBe(true); + expect(isValidCronExpression("15,45 * * * *")).toBe(true); + expect(isValidCronExpression("0 0 13 * 5")).toBe(true); + }); + + test("accepts 7 as Sunday on day-of-week", () => { + expect(isValidCronExpression("* * * * 7")).toBe(true); + expect(isValidCronExpression("0 0 * * 0,7")).toBe(true); + }); + + test("rejects wrong field counts and garbage", () => { + expect(isValidCronExpression("* * * *")).toBe(false); + expect(isValidCronExpression("* * * * * *")).toBe(false); + expect(isValidCronExpression("not a cron string at all")).toBe(false); + expect(isValidCronExpression("")).toBe(false); + }); + + test("rejects every field out of range", () => { + expect(isValidCronExpression("99 99 99 99 99")).toBe(false); + expect(isValidCronExpression("0 0 32 * *")).toBe(false); + expect(isValidCronExpression("0 0 * 13 *")).toBe(false); + expect(isValidCronExpression("60 * * * *")).toBe(false); + expect(isValidCronExpression("* * * * 8")).toBe(false); + }); + + test("rejects a reversed range", () => { + expect(isValidCronExpression("10-5 * * * *")).toBe(false); + expect(isValidCronExpression("10-5/2 * * * *")).toBe(false); + }); + + test("accepts the standard range-then-step idiom", () => { + expect(isValidCronExpression("5-10/2 * * * *")).toBe(true); + }); +}); + +describe("step fields offset from field minimum", () => { + // `*/2` on day-of-month (min=1) → 1,3,5… not 2,4,6… + const cases: { + name: string; + expression: string; + at: string; + matches: boolean; + }[] = [ + { + name: "DOM */2 matches day 1", + expression: "0 0 */2 * *", + at: "2026-01-01T00:00:00Z", + matches: true, + }, + { + name: "DOM */2 does not match day 2", + expression: "0 0 */2 * *", + at: "2026-01-02T00:00:00Z", + matches: false, + }, + { + name: "DOM */2 matches day 3", + expression: "0 0 */2 * *", + at: "2026-01-03T00:00:00Z", + matches: true, + }, + { + name: "minute */2 still matches 0 (min=0)", + expression: "*/2 * * * *", + at: "2026-01-01T00:00:00Z", + matches: true, + }, + { + name: "minute */2 matches 2", + expression: "*/2 * * * *", + at: "2026-01-01T00:02:00Z", + matches: true, + }, + { + name: "minute */2 does not match 1", + expression: "*/2 * * * *", + at: "2026-01-01T00:01:00Z", + matches: false, + }, + { + name: "month */2 matches January (min=1)", + expression: "0 0 1 */2 *", + at: "2026-01-01T00:00:00Z", + matches: true, + }, + { + name: "month */2 does not match February", + expression: "0 0 1 */2 *", + at: "2026-02-01T00:00:00Z", + matches: false, + }, + ]; + + for (const c of cases) { + test(c.name, () => { + expect(cronMatchesMinute(c.expression, new Date(c.at))).toBe(c.matches); + }); + } +}); + +describe("DOM / DOW OR when both restricted (POSIX/Vixie)", () => { + // `0 0 13 * 5` = midnight on the 13th OR any Friday. + const expression = "0 0 13 * 5"; + + test("matches the 13th even when it is not Friday", () => { + // 2026-01-13 is a Tuesday. + expect( + cronMatchesMinute(expression, new Date("2026-01-13T00:00:00Z")), + ).toBe(true); + }); + + test("matches a Friday that is not the 13th", () => { + // 2026-01-16 is a Friday. + expect( + cronMatchesMinute(expression, new Date("2026-01-16T00:00:00Z")), + ).toBe(true); + }); + + test("does not match a day that is neither the 13th nor Friday", () => { + // 2026-01-14 is a Wednesday. + expect( + cronMatchesMinute(expression, new Date("2026-01-14T00:00:00Z")), + ).toBe(false); + }); + + test("when only DOM is restricted, DOW is ignored (AND with *)", () => { + // Only the 15th, any weekday. + expect( + cronMatchesMinute("0 0 15 * *", new Date("2026-01-15T00:00:00Z")), + ).toBe(true); + expect( + cronMatchesMinute("0 0 15 * *", new Date("2026-01-16T00:00:00Z")), + ).toBe(false); + }); + + test("when only DOW is restricted, DOM is ignored", () => { + // Every Monday at midnight. + expect( + cronMatchesMinute("0 0 * * 1", new Date("2026-01-12T00:00:00Z")), + ).toBe(true); + expect( + cronMatchesMinute("0 0 * * 1", new Date("2026-01-13T00:00:00Z")), + ).toBe(false); + }); +}); + +describe("day-of-week 0 and 7 are both Sunday", () => { + test("expression with 7 matches a Sunday Date (day 0)", () => { + // 2026-01-04 is a Sunday. + expect( + cronMatchesMinute("0 0 * * 7", new Date("2026-01-04T00:00:00Z")), + ).toBe(true); + }); + + test("expression with 0 matches the same Sunday", () => { + expect( + cronMatchesMinute("0 0 * * 0", new Date("2026-01-04T00:00:00Z")), + ).toBe(true); + }); + + test("neither matches a Monday", () => { + expect( + cronMatchesMinute("0 0 * * 7", new Date("2026-01-05T00:00:00Z")), + ).toBe(false); + }); +}); + +describe("nextCronFireAfter + canFire bounds", () => { + test("finds the next matching minute", () => { + const next = nextCronFireAfter( + "0-5 * * * *", + new Date("2026-01-01T00:00:00Z"), + ); + expect(next.toISOString()).toBe("2026-01-01T00:01:00.000Z"); + }); + + test("impossible Feb 31 fails canFire and nextCronFireAfter", () => { + expect(cronExpressionCanFire("0 0 31 2 *")).toBe(false); + expect(() => + nextCronFireAfter("0 0 31 2 *", new Date("2026-01-01T00:00:00Z")), + ).toThrow(/no fire time within the lookahead window/); + }); + + test("a once-a-year expression still finds its fire", () => { + // Jan 1 at 00:00 — after Dec 31, next is next year. + const next = nextCronFireAfter( + "0 0 1 1 *", + new Date("2026-06-01T00:00:00Z"), + ); + expect(next.toISOString()).toBe("2027-01-01T00:00:00.000Z"); + }); +}); + +describe("timezone matching and DST", () => { + test("isValidTimeZone accepts IANA names and rejects garbage", () => { + expect(isValidTimeZone("UTC")).toBe(true); + expect(isValidTimeZone("America/Los_Angeles")).toBe(true); + expect(isValidTimeZone("Europe/London")).toBe(true); + expect(isValidTimeZone("Not/A_Zone")).toBe(false); + expect(isValidTimeZone("")).toBe(false); + }); + + test("daily 09:00 America/Los_Angeles matches the correct UTC instant (PST)", () => { + // 2026-01-15 is winter — PST = UTC-8, so 09:00 local = 17:00 UTC. + const at = new Date("2026-01-15T17:00:00Z"); + expect(zonedParts(at, "America/Los_Angeles")).toMatchObject({ + hour: 9, + minute: 0, + day: 15, + month: 1, + }); + expect(cronMatchesMinute("0 9 * * *", at, "America/Los_Angeles")).toBe( + true, + ); + expect(cronMatchesMinute("0 9 * * *", at, "UTC")).toBe(false); + }); + + test("next fire for 09:00 America/Los_Angeles across a DST spring-forward", () => { + // US Pacific spring forward 2026: 2026-03-08 02:00 → 03:00 local. + // Before the transition (March 7 12:00 UTC = March 7 04:00 PST): + // next 09:00 local is March 7 09:00 PST = March 7 17:00 UTC. + const before = new Date("2026-03-07T12:00:00Z"); + const nextBefore = nextCronFireAfter( + "0 9 * * *", + before, + "America/Los_Angeles", + ); + expect(nextBefore.toISOString()).toBe("2026-03-07T17:00:00.000Z"); + expect(zonedParts(nextBefore, "America/Los_Angeles").hour).toBe(9); + + // After spring-forward, 09:00 PDT = UTC-7 → 16:00 UTC. + const afterTransition = new Date("2026-03-09T12:00:00Z"); + const nextAfter = nextCronFireAfter( + "0 9 * * *", + afterTransition, + "America/Los_Angeles", + ); + expect(nextAfter.toISOString()).toBe("2026-03-09T16:00:00.000Z"); + expect(zonedParts(nextAfter, "America/Los_Angeles").hour).toBe(9); + }); + + test("next fire for 09:00 America/Los_Angeles across a DST fall-back", () => { + // US Pacific fall back 2026: 2026-11-01 02:00 → 01:00 local. + // After the transition, 09:00 PST = UTC-8 → 17:00 UTC. + const afterFallback = new Date("2026-11-02T12:00:00Z"); + const next = nextCronFireAfter( + "0 9 * * *", + afterFallback, + "America/Los_Angeles", + ); + expect(next.toISOString()).toBe("2026-11-02T17:00:00.000Z"); + expect(zonedParts(next, "America/Los_Angeles").hour).toBe(9); + }); +}); diff --git a/packages/workflows/src/schedule/cron.ts b/packages/workflows/src/schedule/cron.ts new file mode 100644 index 0000000000..34d48c5b12 --- /dev/null +++ b/packages/workflows/src/schedule/cron.ts @@ -0,0 +1,362 @@ +// The single 5-field cron grammar `@corbits/workflows` speaks: one parser +// shared by validation (does this expression make sense at save time?) +// and execution (does this expression match this minute?). Before this +// module existed, `trigger.ts` and the hub's scheduler each hand-rolled +// their own field parser — format-only, no range checking, incompatible +// clause orderings — so an expression could validate as saveable and +// then never fire, or fire on one parser's reading and not the other's. +// One parser closes that gap: whatever validates here is exactly what +// matches here. +// +// Semantics match Vixie/POSIX cron on the cases that matter: +// - `*/N` steps from the field minimum (so `*/2` on day-of-month is +// 1,3,5… not 2,4,6…). +// - When both day-of-month and day-of-week are restricted, they OR +// (`0 0 13 * 5` = the 13th or any Friday). +// - Day-of-week accepts both 0 and 7 as Sunday. +// - Matching can be evaluated in an IANA timezone; `nextFireAt` is still +// stored as a UTC instant. +export type CronField = + "minute" | "hour" | "dayOfMonth" | "month" | "dayOfWeek"; + +/** Field order in a 5-field cron expression, paired with its valid range. */ +export const CRON_FIELD_RANGES: Readonly< + Record +> = { + minute: [0, 59], + hour: [0, 23], + dayOfMonth: [1, 31], + month: [1, 12], + // 0 and 7 are both Sunday (POSIX); validation accepts either, matching + // normalises 7 → 0 so a Date's getUTCDay() of 0 still matches `7`. + dayOfWeek: [0, 7], +}; + +const CRON_FIELD_ORDER: readonly CronField[] = [ + "minute", + "hour", + "dayOfMonth", + "month", + "dayOfWeek", +]; + +type CronClause = { + readonly base: "*" | number; + readonly rangeEnd?: number; + readonly step?: number; +}; + +// Standard cron clause order is base, then an optional range, then an +// optional step: `5`, `5-10`, `*/2`, `5-10/2`. Only this order is +// accepted — the reversed `5/2-10` idiom neither cron nor either of +// this repo's previous hand-rolled parsers meaningfully supported. +const CLAUSE_PATTERN = /^(\*|[0-9]+)(?:-([0-9]+))?(?:\/([0-9]+))?$/; + +function parseCronClause(raw: string): CronClause | undefined { + const match = CLAUSE_PATTERN.exec(raw); + if (match === null) return undefined; + const [, base, rangeEnd, step] = match; + const clauseBase: CronClause = { base: base === "*" ? "*" : Number(base) }; + const withRangeEnd = + rangeEnd !== undefined + ? { ...clauseBase, rangeEnd: Number(rangeEnd) } + : clauseBase; + return step !== undefined + ? { ...withRangeEnd, step: Number(step) } + : withRangeEnd; +} + +/** + * True when `clause` is meaningful for a field whose valid values span + * `[min, max]`: every literal value in range, and — the case the old + * format-only validators missed — a reversed range (`10-5`) rejected + * rather than accepted as an expression that is syntactically fine and + * unconditionally never true. + */ +function clauseInRange( + clause: CronClause, + [min, max]: readonly [number, number], +): boolean { + if (clause.step !== undefined && clause.step <= 0) return false; + if (clause.base === "*") return true; + if (clause.base < min || clause.base > max) return false; + if (clause.rangeEnd === undefined) return true; + if (clause.rangeEnd < min || clause.rangeEnd > max) return false; + return clause.rangeEnd >= clause.base; +} + +/** + * Does `clause` match `value` for a field whose minimum is `min`? + * Star-with-step (asterisk-slash-N) steps from `min`, not from zero — so on a + * 1-based day-of-month, that pattern yields 1,3,5… rather than 2,4,6…. + */ +function clauseMatches( + clause: CronClause, + value: number, + min: number, +): boolean { + if (clause.base === "*") { + return clause.step === undefined ? true : (value - min) % clause.step === 0; + } + if (clause.rangeEnd === undefined && clause.step === undefined) { + return value === clause.base; + } + const upper = clause.rangeEnd ?? clause.base; + if (value < clause.base || value > upper) return false; + if (clause.step === undefined) return true; + return (value - clause.base) % clause.step === 0; +} + +function everyClause( + field: string, + test: (clause: CronClause) => boolean, +): boolean { + const clauses = field.split(",").map(parseCronClause); + if (clauses.length === 0) return false; + return clauses.every((clause) => clause !== undefined && test(clause)); +} + +function someClause( + field: string, + test: (clause: CronClause) => boolean, +): boolean { + return field.split(",").some((raw) => { + const clause = parseCronClause(raw); + return clause !== undefined && test(clause); + }); +} + +/** + * Loud, eager validation for a raw 5-field cron expression + * (minute hour day-of-month month day-of-week): every field's syntax + * AND every field's values must be sane for that position — a minute + * of 60, a month of 13, or a reversed range all fail here, never + * silently accepted only to fail at fire-time. + * + * This is syntactic + range only. Whether the expression can ever + * actually fire (e.g. `0 0 31 2 *` — Feb 31) is `cronExpressionCanFire`. + */ +export function isValidCronExpression(expression: string): boolean { + const fields = expression.trim().split(/\s+/); + if (fields.length !== 5) return false; + return fields.every((field, index) => { + const cronField = CRON_FIELD_ORDER[index]; + if (cronField === undefined) return false; + return everyClause(field, (clause) => + clauseInRange(clause, CRON_FIELD_RANGES[cronField]), + ); + }); +} + +function fieldMatches(field: string, value: number, min: number): boolean { + return someClause(field, (clause) => clauseMatches(clause, value, min)); +} + +/** + * Day-of-week match with 0/7 both meaning Sunday. A clause of `7` matches + * a Date whose day is 0, and a clause of `0` matches the same. + */ +function dayOfWeekMatches(field: string, dayOfWeek: number): boolean { + const [min] = CRON_FIELD_RANGES.dayOfWeek; + if (fieldMatches(field, dayOfWeek, min)) return true; + // Date APIs report Sunday as 0; expressions may say 7. + if (dayOfWeek === 0 && fieldMatches(field, 7, min)) return true; + return false; +} + +/** + * True when the day-of-month field is restricted (not a bare `*`, and not + * only `*` with a step that still covers every day). Vixie OR-semantics + * for DOM/DOW only apply when *both* fields are restricted. + */ +function isDayFieldRestricted(field: string): boolean { + const trimmed = field.trim(); + if (trimmed === "*") return false; + // `*/1` is every day — unrestricted in effect — but any other form + // (including `*/2`, `1-5`, `1,15`) is a restriction. + if (trimmed === "*/1") return false; + return true; +} + +export type ZonedParts = { + readonly year: number; + readonly month: number; + readonly day: number; + readonly hour: number; + readonly minute: number; + readonly dayOfWeek: number; +}; + +/** + * Wall-clock parts of `at` in `timeZone` (IANA). Falls back to UTC when + * `timeZone` is omitted or `"UTC"`. Throws if `timeZone` is not a valid + * IANA name — call sites that accept user input must validate first via + * `isValidTimeZone`. + */ +export function zonedParts(at: Date, timeZone: string = "UTC"): ZonedParts { + if (timeZone === "UTC") { + return { + year: at.getUTCFullYear(), + month: at.getUTCMonth() + 1, + day: at.getUTCDate(), + hour: at.getUTCHours(), + minute: at.getUTCMinutes(), + dayOfWeek: at.getUTCDay(), + }; + } + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + weekday: "short", + hourCycle: "h23", + }).formatToParts(at); + + let year = 0; + let month = 0; + let day = 0; + let hour = 0; + let minute = 0; + let weekday = ""; + for (const part of parts) { + if (part.type === "year") year = Number(part.value); + else if (part.type === "month") month = Number(part.value); + else if (part.type === "day") day = Number(part.value); + else if (part.type === "hour") hour = Number(part.value); + else if (part.type === "minute") minute = Number(part.value); + else if (part.type === "weekday") weekday = part.value; + } + // en-US short weekday → 0=Sun … 6=Sat + const dowMap: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + }; + const dayOfWeek = dowMap[weekday]; + if (dayOfWeek === undefined) { + throw new Error( + `zonedParts: could not resolve weekday "${weekday}" in ${timeZone}`, + ); + } + return { year, month, day, hour, minute, dayOfWeek }; +} + +/** True when `timeZone` is a recognised IANA name (or `"UTC"`). */ +export function isValidTimeZone(timeZone: string): boolean { + if (timeZone === "UTC") return true; + try { + new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date()); + return true; + } catch { + return false; + } +} + +/** + * True when `expression`'s fields match the wall-clock minute of `at` + * in `timeZone` (default UTC). DOM and DOW OR when both are restricted + * (Vixie/POSIX); otherwise AND. + */ +export function cronMatchesMinute( + expression: string, + at: Date, + timeZone: string = "UTC", +): boolean { + const fields = expression.trim().split(/\s+/); + const [minute, hour, dayOfMonth, month, dayOfWeek] = fields; + if ( + minute === undefined || + hour === undefined || + dayOfMonth === undefined || + month === undefined || + dayOfWeek === undefined + ) { + throw new Error(`"${expression}" is not a 5-field cron expression`); + } + + const parts = zonedParts(at, timeZone); + const timeAndMonth = + fieldMatches(minute, parts.minute, CRON_FIELD_RANGES.minute[0]) && + fieldMatches(hour, parts.hour, CRON_FIELD_RANGES.hour[0]) && + fieldMatches(month, parts.month, CRON_FIELD_RANGES.month[0]); + if (!timeAndMonth) return false; + + const domOk = fieldMatches( + dayOfMonth, + parts.day, + CRON_FIELD_RANGES.dayOfMonth[0], + ); + const dowOk = dayOfWeekMatches(dayOfWeek, parts.dayOfWeek); + + if (isDayFieldRestricted(dayOfMonth) && isDayFieldRestricted(dayOfWeek)) { + // Vixie: either day-of-month or day-of-week may match. + return domOk || dowOk; + } + return domOk && dowOk; +} + +/** The UTC minute `at` falls in, as a stable, comparable integer key. */ +export function minuteKey(at: Date): number { + return Math.floor(at.getTime() / 60_000); +} + +/** + * Bounds how far ahead `nextCronFireAfter` will search before giving up. + * One leap year of minutes is enough for any expression that fires at + * least annually; impossible expressions (Feb 31, etc.) fail here — and + * at save time via `cronExpressionCanFire` — never inside a claim + * transaction that would otherwise spin for millions of iterations. + */ +export const MAX_LOOKAHEAD_MINUTES = 366 * 24 * 60; + +/** + * The next minute at or after `after` (exclusive) that `expression` + * matches in `timeZone` — the closed-form "when does this actually fire + * next" calculation, used both to persist a routine's `nextFireAt` and + * to render a UI's next-run estimate against the exact semantics that + * fire it. `nextFireAt` is always a UTC instant even when matching is + * zoned. + */ +export function nextCronFireAfter( + expression: string, + after: Date, + timeZone: string = "UTC", +): Date { + const start = minuteKey(after) + 1; + for (let minute = start; minute - start <= MAX_LOOKAHEAD_MINUTES; minute++) { + const candidate = new Date(minute * 60_000); + if (cronMatchesMinute(expression, candidate, timeZone)) return candidate; + } + throw new Error( + `"${expression}" has no fire time within the lookahead window` + + (timeZone === "UTC" ? "" : ` in ${timeZone}`), + ); +} + +/** + * True when `expression` has at least one fire inside the lookahead + * window from `from` (default: Unix epoch). Used at save time so an + * impossible expression (`0 0 31 2 *`) is rejected before it can ever + * reach the scheduler's claim path. + */ +export function cronExpressionCanFire( + expression: string, + timeZone: string = "UTC", + from: Date = new Date(0), +): boolean { + if (!isValidCronExpression(expression)) return false; + if (!isValidTimeZone(timeZone)) return false; + try { + nextCronFireAfter(expression, from, timeZone); + return true; + } catch { + return false; + } +} diff --git a/packages/workflows/src/schedule/from-projection.test.ts b/packages/workflows/src/schedule/from-projection.test.ts new file mode 100644 index 0000000000..b923e91139 --- /dev/null +++ b/packages/workflows/src/schedule/from-projection.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; + +import { scheduleCronFromProjection } from "./from-projection"; + +const baseProjection = { + id: "wf_digest", + stepOrder: ["digest"], + steps: { digest: { kind: "step" } }, +}; + +describe("scheduleCronFromProjection", () => { + test("returns the first schedule cron", () => { + expect( + scheduleCronFromProjection({ + ...baseProjection, + triggers: [ + { type: "mail", to: "digest@example.com" }, + { type: "schedule", cron: "0 9 * * *" }, + { type: "schedule", cron: "0 18 * * *" }, + ], + }), + ).toBe("0 9 * * *"); + }); + + test("returns undefined when there is no schedule trigger", () => { + expect( + scheduleCronFromProjection({ + ...baseProjection, + triggers: [{ type: "mail", to: "digest@example.com" }], + }), + ).toBeUndefined(); + }); + + test("returns undefined for an unparseable projection", () => { + expect(scheduleCronFromProjection({ triggers: [] })).toBeUndefined(); + }); + + test("returns undefined when the first schedule cron is invalid", () => { + expect( + scheduleCronFromProjection({ + ...baseProjection, + triggers: [{ type: "schedule", cron: "99 99 99 99 99" }], + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/workflows/src/schedule/from-projection.ts b/packages/workflows/src/schedule/from-projection.ts new file mode 100644 index 0000000000..96c83871a5 --- /dev/null +++ b/packages/workflows/src/schedule/from-projection.ts @@ -0,0 +1,27 @@ +// First `{ type: "schedule", cron }` on a frozen inert projection — the +// hashed cadence the hub poller ticks. Later schedule triggers are ignored +// on purpose: one definition, one cadence. An unparseable projection or an +// invalid cron is not a schedule (fail closed). +import { type } from "arktype"; +import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; + +import { isValidCronExpression } from "./cron"; + +const ScheduleTrigger = type({ + type: "'schedule'", + cron: "string", +}); + +export function scheduleCronFromProjection( + wireProjection: unknown, +): string | undefined { + const projection = WorkflowProjectionDefinition(wireProjection); + if (projection instanceof type.errors) return undefined; + for (const trigger of projection.triggers) { + const parsed = ScheduleTrigger(trigger); + if (parsed instanceof type.errors) continue; + if (!isValidCronExpression(parsed.cron)) return undefined; + return parsed.cron; + } + return undefined; +} diff --git a/vendor/intx/db/migrations/0089_workflow_definition_schedule_claimed_minute.sql b/vendor/intx/db/migrations/0089_workflow_definition_schedule_claimed_minute.sql new file mode 100644 index 0000000000..354965fcd0 --- /dev/null +++ b/vendor/intx/db/migrations/0089_workflow_definition_schedule_claimed_minute.sql @@ -0,0 +1,4 @@ +-- WORKBENCH DELTA (see VENDORED.md, CL-4455): per-minute CAS for the hub's +-- native ScheduleTrigger poller. Text stores String(minuteKey(at)). Null = +-- never claimed. Not part of wire_hash. +ALTER TABLE "workflow_definition" ADD COLUMN "schedule_claimed_minute" text; diff --git a/vendor/intx/db/migrations/meta/_journal.json b/vendor/intx/db/migrations/meta/_journal.json index d35207e9e6..7aeacb6d55 100644 --- a/vendor/intx/db/migrations/meta/_journal.json +++ b/vendor/intx/db/migrations/meta/_journal.json @@ -617,6 +617,13 @@ "when": 1787900000000, "tag": "0088_workflow_definition_version_tolerate_body_failure", "breakpoints": true + }, + { + "idx": 89, + "version": "7", + "when": 1788000000000, + "tag": "0089_workflow_definition_schedule_claimed_minute", + "breakpoints": true } ] } diff --git a/vendor/intx/db/src/migrate.test.ts b/vendor/intx/db/src/migrate.test.ts index 3b944c176e..4cf6517380 100644 --- a/vendor/intx/db/src/migrate.test.ts +++ b/vendor/intx/db/src/migrate.test.ts @@ -27,7 +27,7 @@ describeIfDb("runMigrations replays the shipped SQL into a fresh schema", () => const config = configFromUrl(databaseUrl); afterAll(() => dropSchema(config, { schema })); - test("lands upstream's approval run index and both workbench columns", async () => { + test("lands upstream's approval run index and workbench columns", async () => { await runMigrations(config, { schema }); const sql = postgres({ ...config, max: 1, onnotice: () => undefined }); try { @@ -36,9 +36,11 @@ describeIfDb("runMigrations replays the shipped SQL into a fresh schema", () => WHERE table_schema = ${schema} AND (table_name, column_name) IN ( ('workflow_definition_version', 'wire_projection'), - ('workflow_definition', 'origin'))`; + ('workflow_definition', 'origin'), + ('workflow_definition', 'schedule_claimed_minute'))`; expect(columns.map((row) => row.column_name).sort()).toEqual([ "origin", + "schedule_claimed_minute", "wire_projection", ]); const indexes = await sql<{ indexname: string }[]>` diff --git a/vendor/intx/db/src/schema/workflow-definitions.ts b/vendor/intx/db/src/schema/workflow-definitions.ts index 24131abf25..13d3783324 100644 --- a/vendor/intx/db/src/schema/workflow-definitions.ts +++ b/vendor/intx/db/src/schema/workflow-definitions.ts @@ -64,6 +64,10 @@ export const workflowDefinition = pgTable( origin: text("origin", { enum: ["authored", "run"] }) .notNull() .default("authored"), + // WORKBENCH DELTA (see VENDORED.md, CL-4455): last UTC minuteKey this + // definition's native ScheduleTrigger already claimed. Null means never. + // Not part of wire_hash. + scheduleClaimedMinute: text("schedule_claimed_minute"), // Grant requirements manifest, resolved at launch into materialized grants. // Validated as GrantRequirement[] at parse time. grantRequirements: jsonb("grant_requirements"), From 5ed9b716b4bcbe998ce2ddf5a92fd9d3ecc3cc60 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 14:37:29 -0700 Subject: [PATCH 3/9] Stop planting default routine wrappers (CL-4455) Seed no longer POSTs /routines. Re-seed only deletes pristine leftover workbench-digest and last-30-days-research rows; a member-touched or pre-presetKey row is kept. Overlapping prune treats 404 as already retired. Digest ticks from ScheduleTrigger; last-30-days-research stays a deployed workflow. --- apps/hub/src/system-seed.ts | 15 +- docs/seed-reconciliation.md | 27 +- packages/seeding/package.json | 2 +- packages/seeding/src/default-routines.ts | 320 +--------- packages/seeding/src/index.ts | 9 +- packages/seeding/src/seed.ts | 34 +- .../seeding/test/default-routines.test.ts | 567 +++--------------- packages/seeding/test/seed.test.ts | 22 +- 8 files changed, 162 insertions(+), 834 deletions(-) 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/docs/seed-reconciliation.md b/docs/seed-reconciliation.md index cc2d6f8f4e..acb07ed217 100644 --- a/docs/seed-reconciliation.md +++ b/docs/seed-reconciliation.md @@ -49,22 +49,17 @@ answers 503 — never a 404, which would read as "no such template". ## Default routine presets (onboarding / boot-time seeding) -`ensureDefaultRoutines` (`packages/seeding/src/default-routines.ts`) -plants `DEFAULT_ROUTINE_PRESETS` through `POST /routines` with a -`presetKey`, backed by `@corbits/routines`' `createRoutineIfAbsent`. - -- Preset routines are born disabled server-side — no "Created routine" - notice, no fire, no follow-up PATCH. The member enabling one is the - announcement. -- A member-deleted preset routine leaves a soft-delete tombstone; the - hub answers a re-create with 204 and refuses to resurrect it. -- Existing rows are matched by `presetKey` (name only for rows planted - before the key existed), so renaming a routine does not cause a - re-plant. -- Once planted, a routine's schedule, input, and name belong to the - bench: a moved preset shapes only freshly-planted rows. -- A routine whose preset no longer ships is deleted only while pristine - (`updatedAt` still equals `createdAt`); a member-touched row is kept. +`pruneDroppedPresetRoutines` (`packages/seeding/src/default-routines.ts`) +runs at the end of `seedTenant`. Seed never POSTs `/routines`. Native +`ScheduleTrigger` ticks digest; last-30-days-research stays a deployed +workflow in `DEFAULT_WORKFLOWS`, not a wrapper row. + +- A leftover row whose `presetKey` is a retired preset + (`workbench-digest`, `last-30-days-research`) is deleted only while + pristine (`updatedAt` still equals `createdAt`); a member-touched row + is kept. +- Pre-`presetKey` legacy rows are never deleted — there is no honest + way to tell them apart from a person-authored routine. ## Default skills (boot-time seeding) diff --git a/packages/seeding/package.json b/packages/seeding/package.json index e998e7bf9e..7c4ac3ff6e 100644 --- a/packages/seeding/package.json +++ b/packages/seeding/package.json @@ -1,7 +1,7 @@ { "name": "@corbits/seeding", "private": true, - "description": "Tenant seeding: deploy the default workflow set, plant preset routines and skills, and confirm every deployment answers", + "description": "Tenant seeding: deploy the default workflow set, plant skills, prune leftover preset routine wrappers, and confirm every deployment answers", "version": "0.0.1", "license": "LGPL-2.1-or-later", "type": "module", diff --git a/packages/seeding/src/default-routines.ts b/packages/seeding/src/default-routines.ts index bc6ba96aa7..b1e3635f30 100644 --- a/packages/seeding/src/default-routines.ts +++ b/packages/seeding/src/default-routines.ts @@ -1,165 +1,34 @@ -// Preset Routine rows every real tenant starts with, planted after -// `seedTenant`'s own workflow-deploy loop (see seed.ts's `seedTenant`, -// which calls `ensureDefaultRoutines` last). Deploying a workflow only -// makes it launchable; a run-now-only utility still needs a `routine` -// row before it shows up in the Routines picker (CL-6201). -// -// workbench-digest is not a preset here: its cadence is the native -// `ScheduleTrigger` on the frozen definition (CL-4455), so a wrapper -// row would be a second schedule. A previously-planted pristine -// "Daily digest" row is retired by `pruneDroppedPresetRoutines`. -// -// Every remaining preset is created DISABLED (`enabled: false`): a -// scheduled preset must never start firing just because a bench was -// minted. A disabled routine's "Run now" still works (the run-now -// route never checks `enabled` — see `packages/routines/src/routes.ts`), -// so a preset is inspectable and runnable the moment it's seeded. -// -// Idempotent by a stable `presetKey` (each preset's own `assetName`), -// enforced server-side by `@corbits/routines`' `createRoutineIfAbsent` -// (a real `INSERT ... ON CONFLICT DO NOTHING`, unique per -// `(tenantId, presetKey)` — see packages/routines/src/store.ts and -// migrations.ts' 0005). The list-then-skip check below is only a fast -// path that avoids a redundant deploy lookup and API round trip on the -// common case; it is never the thing that prevents a duplicate. Two -// overlapping `ensureDefaultRoutines` calls (e.g. two "finish setup" -// requests racing, as `pending-seed.ts` explicitly allows) can both -// pass this check and both POST — the server-side conflict target is -// what guarantees exactly one row and one "Created routine" notice. -import { AssetWithOriginResponse } from "@intx/types"; +// Leftover `@corbits/routines` wrapper rows from earlier seed passes. +// Native `ScheduleTrigger` (digest) and deployed automations +// (last-30-days-research in `DEFAULT_WORKFLOWS`) no longer get a +// routine row. Re-seed still DELETEs a pristine leftover whose +// `presetKey` is a retired preset; a member-touched row and any +// pre-`presetKey` legacy row stay. import { type } from "arktype"; import { HubApiError, parseAs, type ApiCall } from "@corbits/hub-api-client"; -const WorkflowDeploymentListItem = type({ - id: "string", - definitionAssetId: "string", - status: "string", -}); - const RoutineListItem = type({ id: "string", name: "string", - enabled: "boolean", - deliveryWorkbenchId: "string | null", presetKey: "string | null", createdAt: "string", updatedAt: "string", }); -/** - * A preset's schedule — the same wire shape `@corbits/routines`' - * `RoutineTrigger` accepts on `POST /routines`, spelled out locally so - * this package never depends on `@corbits/routines` just to describe - * data it hands the hub over HTTP. `null` is the manual, run-now-only - * shape: last-30-days-research needs a fresh topic every run, so it is - * never sensibly put on a fixed cadence. - */ -export type DefaultRoutineTrigger = { - readonly kind: "daily"; - readonly hour: number; - readonly minute: number; -} | null; - -export type DefaultRoutinePreset = { - /** Unique within a tenant's routines; the idempotency key a re-seed - * matches an existing row against. */ - readonly name: string; - /** The `DEFAULT_WORKFLOWS` asset name whose deployed definition this - * preset targets. */ - readonly assetName: string; - readonly trigger: DefaultRoutineTrigger; - /** Rendered verbatim into the launch's trigger mail — see - * `@corbits/routines`' `renderRoutineInput`. Empty for a preset with - * nothing honest to pre-fill. */ - readonly input: Record; -}; - -export const DEFAULT_ROUTINE_PRESETS: readonly DefaultRoutinePreset[] = [ - { - name: "Last 30 days research", - assetName: "last-30-days-research", - trigger: null, - // No starter topic: the workflow's own system prompt refuses to - // invent one and replies with one plain sentence instead — an - // honest teaching moment, not a broken run, if this fires with no - // topic set. - input: {}, - }, -]; - -/** - * Resolves an already-deployed default workflow to the stable - * `definitionAssetId` `POST /routines` now requires — the workflow - * asset's own id, not a `workflow_definition` row id. `/workflows/ - * definitions` (vendored `@intx/hub-api`) never exposes an asset id, so - * this instead finds the asset by name (`/assets?kind=workflow`, the - * same lookup `ensureWorkflowAsset` in `seed.ts` uses) and confirms a - * live deployment exists for it (`/workflows/deployments`, matching - * `ensureDeployment`'s own check) before handing the asset id back. - */ -async function resolveDeployedDefinitionAssetId( - api: ApiCall, - cookies: string[], - tenantId: string, - assetName: string, -): Promise { - const listedAssets = await api( - "GET", - `/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`, - undefined, - cookies, - ); - const assets = parseAs( - AssetWithOriginResponse.array(), - listedAssets.data, - "assets response", - ); - const asset = assets.find((a) => a.name === assetName); - if (asset === undefined) return undefined; - - const listedDeployments = await api( - "GET", - `/api/tenants/${tenantId}/workflows/deployments`, - undefined, - cookies, - ); - const deployments = parseAs( - WorkflowDeploymentListItem.array(), - listedDeployments.data, - "deployments response", - ); - // Mirrors `isLiveDeploymentStatus` in `seed.ts` — duplicated locally - // rather than imported to avoid a circular import (`seed.ts` imports - // `ensureDefaultRoutines` from this file). - const isLiveDeploymentStatus = (status: string): boolean => - status === "deployed" || status === "pending"; - const isDeployed = deployments.some( - (d) => d.definitionAssetId === asset.id && isLiveDeploymentStatus(d.status), - ); - return isDeployed ? asset.id : undefined; -} +const RETIRED_ROUTINE_PRESET_KEYS = new Set([ + "workbench-digest", + "last-30-days-research", +]); /** - * Reconciles `DEFAULT_ROUTINE_PRESETS` for one already-seeded tenant: - * every preset whose workflow is deployed and not already present (by - * `presetKey`, falling back to name for rows planted before the key - * existed) gets a routine row, born disabled server-side. A preset the - * member deleted stays deleted (the hub answers 204 and no row is - * re-created), and a routine for a preset that no longer ships is - * deleted only while pristine — never patched since it was planted - * (`updatedAt` still equals `createdAt`); a member-touched row is the - * member's and is kept. Existing rows are never updated to a preset's - * current shape: once planted, the schedule, input, and name belong to - * the bench, and a moved preset only shapes freshly-planted rows. - * Every preset after the first reuses the first preset's own delivery - * workbench — "the workbench's own workbench" is whichever space the - * delivery-precedence chain - * (`namedWorkbenchId ?? homeWorkbenchId ?? provisionedSpace?.workbenchId ?? - * null`, `packages/routines/src/routes.ts`) resolves the first preset - * to, since no workbench is named and no run-scoped home workbench exists - * at seed time — never a second workbench per preset. + * Deletes routine rows whose `presetKey` is a retired preset — but only + * pristine ones, never patched since they were planted (`updatedAt` + * still equals `createdAt`; any member PATCH, and any recorded fire + * failure, moves `updatedAt`). A touched row is the member's and is + * kept, as is any pre-`presetKey` legacy row (there is no honest way to + * tell it apart from a person-authored routine). */ -export async function ensureDefaultRoutines( +export async function pruneDroppedPresetRoutines( api: ApiCall, cookies: string[], tenantId: string, @@ -177,143 +46,11 @@ export async function ensureDefaultRoutines( "routines response", ).items; - let sharedDeliveryWorkbenchId = - existing.find((routine) => routine.deliveryWorkbenchId !== null) - ?.deliveryWorkbenchId ?? undefined; - - await pruneDroppedPresetRoutines(api, cookies, tenantId, existing, log); - - for (const preset of DEFAULT_ROUTINE_PRESETS) { - const already = existing.find( - (routine) => - routine.presetKey === preset.assetName || - (routine.presetKey === null && routine.name === preset.name), - ); - if (already !== undefined) { - log(`routine "${preset.name}" already exists (skipped)`); - continue; - } - - const definitionAssetId = await resolveDeployedDefinitionAssetId( - api, - cookies, - tenantId, - preset.assetName, - ); - if (definitionAssetId === undefined) { - log( - `routine "${preset.name}" skipped: no deployed definition named ` + - `"${preset.assetName}"`, - ); - continue; - } - - const body: Record = { - name: preset.name, - definitionAssetId, - trigger: preset.trigger, - scope: "bench", - input: preset.input, - // The create-if-absent identity — see this file's header comment. - presetKey: preset.assetName, - }; - if (sharedDeliveryWorkbenchId !== undefined) { - body.deliveryWorkbenchId = sharedDeliveryWorkbenchId; - } - - const created = await api( - "POST", - `/api/tenants/${tenantId}/routines`, - body, - cookies, - ); - if ( - created.status === 400 && - /deliveryWorkbenchId is required/.test(JSON.stringify(created.data)) - ) { - // This preset's definition needs somewhere to deliver to, and - // seeding names no workbench — a person hasn't picked one yet, and - // seeding must never invent one and name it after the routine - // (that's exactly the "Daily digest"/"New Workbench" pollution this - // preset-planting flow used to cause). The routine simply isn't - // planted until a member creates it by hand and picks a real - // destination. - log( - `routine "${preset.name}" skipped: its workflow needs a delivery ` + - `workbench and this preset names none — create it by hand and ` + - `pick one`, - ); - continue; - } - if ( - created.status === 400 && - /is required/.test(JSON.stringify(created.data)) - ) { - // The definition declares a required trigger input this preset has - // nothing honest to pre-fill (last-30-days-research's "Topic"). - // Seeding must never fabricate a value and must never fail the - // whole onboarding flow over an optional nicety — the workflow - // stays deployed and creatable by hand with a real topic. - log( - `routine "${preset.name}" skipped: its definition requires input ` + - `this preset cannot honestly pre-fill (${JSON.stringify(created.data)})`, - ); - continue; - } - // 201: this call genuinely minted the row, born disabled with no - // notice and no fire. 200: `presetKey` already resolved to an - // existing row (this preset's own prior seed, or the winner of a - // race against another overlapping seed call). 204: a member - // deleted this preset's routine and the hub refused to resurrect - // it — their choice stands. - if (created.status === 204) { - log(`routine "${preset.name}" was removed by a member (respected)`); - continue; - } - if (created.status !== 201 && created.status !== 200) { - throw new HubApiError( - `the hub rejected creation of the default routine "${preset.name}" with status ${created.status}: ${JSON.stringify(created.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - const row = parseAs(RoutineListItem, created.data, "routine response"); - + for (const routine of existing) { if ( - sharedDeliveryWorkbenchId === undefined && - row.deliveryWorkbenchId !== null + routine.presetKey === null || + !RETIRED_ROUTINE_PRESET_KEYS.has(routine.presetKey) ) { - sharedDeliveryWorkbenchId = row.deliveryWorkbenchId; - } - - if (created.status === 200) { - log(`routine "${preset.name}" already exists (skipped)`); - continue; - } - - log(`seeded routine "${preset.name}" (disabled)`); - } -} - -/** - * Deletes routine rows whose `presetKey` no longer names a shipped - * preset — but only pristine ones, never patched since they were - * planted (`updatedAt` still equals `createdAt`; any member PATCH, and - * any recorded fire failure, moves `updatedAt`). A touched row is the - * member's and is kept, as is any pre-`presetKey` legacy row (there is - * no honest way to tell it apart from a person-authored routine). - */ -async function pruneDroppedPresetRoutines( - api: ApiCall, - cookies: string[], - tenantId: string, - existing: readonly (typeof RoutineListItem.infer)[], - log: (line: string) => void, -): Promise { - const shippedKeys = new Set( - DEFAULT_ROUTINE_PRESETS.map((preset) => preset.assetName), - ); - for (const routine of existing) { - if (routine.presetKey === null || shippedKeys.has(routine.presetKey)) { continue; } if (routine.updatedAt !== routine.createdAt) { @@ -329,12 +66,17 @@ async function pruneDroppedPresetRoutines( undefined, cookies, ); - if (deleted.status !== 204) { - throw new HubApiError( - `the hub rejected deleting the retired preset routine "${routine.name}" with status ${deleted.status}: ${JSON.stringify(deleted.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); + if (deleted.status === 204) { + log(`routine "${routine.name}" retired (its preset no longer ships)`); + continue; } - log(`routine "${routine.name}" retired (its preset no longer ships)`); + if (deleted.status === 404) { + log(`routine "${routine.name}" already retired`); + continue; + } + throw new HubApiError( + `the hub rejected deleting the retired preset routine "${routine.name}" with status ${deleted.status}: ${JSON.stringify(deleted.data)}`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); } } diff --git a/packages/seeding/src/index.ts b/packages/seeding/src/index.ts index d79e5ab908..0a2954c4c0 100644 --- a/packages/seeding/src/index.ts +++ b/packages/seeding/src/index.ts @@ -1,8 +1,8 @@ // The library entry for `@corbits/seeding`: deploys the default workflow -// set for an already-known tenant, plants preset routines and skills, and -// confirms every deployment answers. Both the boot-time root-tenant seed -// and the first-login provisioning hook consume this so the tenant-seeding -// logic is implemented once. +// set for an already-known tenant, plants skills, prunes leftover preset +// routine wrappers, and confirms every deployment answers. Both the +// boot-time root-tenant seed and the first-login provisioning hook +// consume this so the tenant-seeding logic is implemented once. export type { DefaultWorkflow, @@ -47,4 +47,3 @@ export type { WorkbenchHostInferencePreference, } from "./catalog-seed-data"; export { createGitWorkflowPusher } from "./workflow-push"; -export { ensureDefaultRoutines } from "./default-routines"; diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index cb6bf34751..fb269258d4 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -57,7 +57,7 @@ import { type ApiCall, } from "@corbits/hub-api-client"; import { DEFAULT_SKILLS } from "./default-skills"; -import { ensureDefaultRoutines } from "./default-routines"; +import { pruneDroppedPresetRoutines } from "./default-routines"; import { CATALOG_SEEDS, type CatalogModelSpec } from "./catalog-seed-data"; import { fetchOllamaModelCatalog, @@ -291,10 +291,9 @@ export const DEFAULT_WORKFLOWS: readonly DefaultWorkflow[] = [ assetName: "last-30-days-research", displayName: catalogDisplayName("last-30-days-research"), automatable: catalogAutomatable("last-30-days-research"), - // CL-6201: deployed so `ensureDefaultRoutines` (default-routines.ts) - // has a real definition to un-strand into a routine row. It was - // never in this array before that ticket, which is exactly why the - // routine could never appear: nothing deployed its definition. + // Deployed automation in the default set — not because a routine + // wrapper row needs a definition. Native schedule and member-created + // routines target this asset; seed no longer POSTs a wrapper. buildJson: (tenantDomain, model) => serializeLast30DaysResearchWorkflow( buildLast30DaysResearchWorkflow({ @@ -350,10 +349,9 @@ export const SEED_GRANTS: readonly { resource: string; action: string }[] = [ { resource: "workflow:*", action: "read" }, { resource: "workflow-run:*", action: "manage" }, { resource: "workflow-run:*", action: "read" }, - // CL-6201: `ensureDefaultRoutines` lists deployed definitions (GET - // .../workflows/definitions) and creates/disables preset routines - // (POST/PATCH .../routines) — none of the grants above cover those - // routes, which gate on their own resource/action pairs. + // Workflow-definition read/update (stop a startStopped deploy, list + // definitions) and extra workflow-run verbs none of the grants above + // cover. Those routes gate on their own resource/action pairs. { resource: "workflow-definition:*", action: "read" }, { resource: "workflow-definition:*", action: "update" }, { resource: "workflow-run:*", action: "create" }, @@ -946,9 +944,10 @@ export type SeedTenantArgs = { * seeds it without re-authenticating or re-resolving the tenant by * slug. * - * Grants + workflows/routines only. Assumes the tenant hierarchy - * already exposes `corbits-tools` (published at `workbench setup` onto - * the root); seed does not pack tarballs or run freshness. + * Grants + workflows, then prune of leftover preset routine wrappers. + * Assumes the tenant hierarchy already exposes `corbits-tools` + * (published at `workbench setup` onto the root); seed does not pack + * tarballs or run freshness. */ export async function seedTenant(args: SeedTenantArgs): Promise { const { @@ -1056,13 +1055,10 @@ export async function seedTenant(args: SeedTenantArgs): Promise { ); } - // CL-6201: every default workflow above is now deployed, so any - // preset routine whose definition just landed can be planted. Runs - // after the deploy loop (never before) — a preset targeting a - // workflow this call didn't deploy is skipped with a log line rather - // than failing seeding outright, which keeps a narrowed `workflows` - // list (as several tests here pass) a partial-but-valid seed. - await ensureDefaultRoutines(api, cookies, tenant.tenantId, log); + // Native ScheduleTrigger + deployed automations already tick; seed + // no longer POSTs `/routines` wrappers. Re-seed still retires + // pristine leftover preset rows (Daily digest, last-30-days-research). + await pruneDroppedPresetRoutines(api, cookies, tenant.tenantId, log); log( confirmDeployments diff --git a/packages/seeding/test/default-routines.test.ts b/packages/seeding/test/default-routines.test.ts index f4583d4697..02bce323ec 100644 --- a/packages/seeding/test/default-routines.test.ts +++ b/packages/seeding/test/default-routines.test.ts @@ -1,543 +1,199 @@ import { describe, expect, test } from "bun:test"; import { HubApiError } from "@corbits/hub-api-client"; -import { - DEFAULT_ROUTINE_PRESETS, - ensureDefaultRoutines, -} from "../src/default-routines"; +import { pruneDroppedPresetRoutines } from "../src/default-routines"; import { collector, fakeAPI, TENANT_ID, type FakeHandler } from "./helpers"; const TIMESTAMP = "2026-01-01T00:00:00.000Z"; const TOUCHED_TIMESTAMP = "2026-01-02T12:00:00.000Z"; -function assetRow(id: string, name: string) { - return { - id, - tenantId: TENANT_ID, - kind: "workflow", - name, - displayName: null, - creatorPrincipalId: null, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - origin: { tenantId: TENANT_ID, direct: true }, - }; -} - -function deploymentRow( - id: string, - definitionAssetId: string, - status = "deployed", -) { - return { - id, - tenantId: TENANT_ID, - definitionAssetId, - status, - createdAt: TIMESTAMP, - }; -} - function routineRow(overrides: { id: string; name: string; - deliveryWorkbenchId?: string | null; - enabled?: boolean; presetKey?: string | null; updatedAt?: string; }) { return { id: overrides.id, name: overrides.name, - enabled: overrides.enabled ?? true, - deliveryWorkbenchId: overrides.deliveryWorkbenchId ?? null, presetKey: overrides.presetKey ?? null, createdAt: TIMESTAMP, updatedAt: overrides.updatedAt ?? TIMESTAMP, }; } -function assetsResponse(rows: ReturnType[]) { - return { status: 200, data: rows }; -} - -function deploymentsResponse(rows: ReturnType[]) { - return { status: 200, data: rows }; -} - -function deployedAssetsAndDeployments() { - return { - assets: [ - assetRow("ast_digest", "workbench-digest"), - assetRow("ast_research", "last-30-days-research"), - ], - deployments: [ - deploymentRow("dep_digest", "ast_digest"), - deploymentRow("dep_research", "ast_research"), - ], - }; -} - -describe("DEFAULT_ROUTINE_PRESETS", () => { - test("declares only the un-stranded last-30-days-research preset", () => { - expect(DEFAULT_ROUTINE_PRESETS.map((p) => p.name)).toEqual([ - "Last 30 days research", - ]); - }); - - test("does not plant a scheduled digest wrapper — native ScheduleTrigger is the cadence", () => { - expect( - DEFAULT_ROUTINE_PRESETS.some((p) => p.assetName === "workbench-digest"), - ).toBe(false); - }); - - test("targets the last-30-days-research asset", () => { - expect(DEFAULT_ROUTINE_PRESETS.map((p) => p.assetName)).toEqual([ - "last-30-days-research", - ]); - }); - - test("last-30-days-research is a manual, run-now-only preset", () => { - const research = DEFAULT_ROUTINE_PRESETS.find( - (p) => p.assetName === "last-30-days-research", - ); - expect(research?.trigger).toBeNull(); - }); -}); - -describe("ensureDefaultRoutines", () => { - test("plants every preset once, born disabled server-side, never PATCHed, sharing the first preset's delivery workbench", async () => { - const { lines, log } = collector(); - const createCalls: { name: string; body: unknown }[] = []; - let nextRoutineId = 1; - - const handler: FakeHandler = (method, path, body) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return assetsResponse(deployedAssetsAndDeployments().assets); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return deploymentsResponse(deployedAssetsAndDeployments().deployments); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { - return { status: 200, data: { items: [] } }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { - const parsed = body as { name: string; presetKey: string }; - createCalls.push({ name: parsed.name, body }); - const id = `rtn_${nextRoutineId}`; - nextRoutineId += 1; - // Every preset after the first names the shared workbench - // explicitly; the first preset names none, so the space it - // would have been auto-provisioned into is stood in here as a - // fixed id the test can assert every later preset reuses. - const deliveryWorkbenchId = - (parsed as { deliveryWorkbenchId?: string }).deliveryWorkbenchId ?? - "ch_provisioned"; - return { - status: 201, - data: routineRow({ - id, - name: parsed.name, - deliveryWorkbenchId, - enabled: false, - presetKey: parsed.presetKey, - }), - }; - } - return undefined; - }; - - await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); - - expect(createCalls).toHaveLength(1); - expect(createCalls[0]?.name).toBe("Last 30 days research"); - expect(createCalls[0]?.body).not.toHaveProperty("deliveryWorkbenchId"); - expect(createCalls[0]?.body).not.toHaveProperty("enabled"); - - const output = lines.join("\n"); - expect(output).toContain( - 'seeded routine "Last 30 days research" (disabled)', - ); - }); - - test("skips a preset whose workflow was never deployed", async () => { - const { lines, log } = collector(); +describe("pruneDroppedPresetRoutines", () => { + test("never POSTs /routines", async () => { + const { log } = collector(); + let posts = 0; const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return assetsResponse([]); + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { + posts += 1; } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] } }; } - return undefined; + throw new Error(`unexpected hub call: ${method} ${path}`); }; - await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); + await pruneDroppedPresetRoutines(fakeAPI(handler), [], TENANT_ID, log); - const output = lines.join("\n"); - expect(output).toContain( - 'routine "Last 30 days research" skipped: no deployed definition named "last-30-days-research"', - ); + expect(posts).toBe(0); }); - // The hub never auto-provisions a delivery workbench (that pollution — - // a workbench literally named after the preset — is exactly what this - // fix removes): a delivery-required preset seeded with no workbench - // named gets this 400, and seeding must skip it honestly rather than - // failing the whole seed or fabricating a destination. - test("skips a delivery-required preset honestly when the hub 400s for lacking a named workbench", async () => { + test("deletes pristine workbench-digest and last-30-days-research leftover wrappers", async () => { const { lines, log } = collector(); + const deleteCalls: string[] = []; const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return assetsResponse(deployedAssetsAndDeployments().assets); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return deploymentsResponse(deployedAssetsAndDeployments().deployments); - } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { - return { status: 200, data: { items: [] } }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { return { - status: 400, + status: 200, data: { - error: { - code: "bad_request", - message: "deliveryWorkbenchId is required for this workflow", - }, + items: [ + routineRow({ + id: "rtn_digest", + name: "Daily digest", + presetKey: "workbench-digest", + }), + routineRow({ + id: "rtn_research", + name: "Last 30 days research", + presetKey: "last-30-days-research", + }), + ], }, }; } - return undefined; + if ( + method === "DELETE" && + path.startsWith(`/api/tenants/${TENANT_ID}/routines/`) + ) { + deleteCalls.push(path.split("/").pop() ?? ""); + return { status: 204, data: undefined }; + } + throw new Error(`unexpected hub call: ${method} ${path}`); }; - await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); + await pruneDroppedPresetRoutines(fakeAPI(handler), [], TENANT_ID, log); + expect(deleteCalls).toEqual(["rtn_digest", "rtn_research"]); const output = lines.join("\n"); expect(output).toContain( - 'routine "Last 30 days research" skipped: its workflow needs a delivery workbench and this preset names none — create it by hand and pick one', + 'routine "Daily digest" retired (its preset no longer ships)', + ); + expect(output).toContain( + 'routine "Last 30 days research" retired (its preset no longer ships)', ); }); - test("a re-seed matches every preset by presetKey — even renamed — and creates nothing twice", async () => { + test("keeps a member-touched retired-preset row", async () => { const { lines, log } = collector(); + const deleteCalls: string[] = []; const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return assetsResponse(deployedAssetsAndDeployments().assets); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return deploymentsResponse(deployedAssetsAndDeployments().deployments); - } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [ routineRow({ - id: "rtn_2", - name: "Last 30 days research", - deliveryWorkbenchId: "ch_existing", - enabled: false, + id: "rtn_digest_touched", + name: "Daily digest I tuned", + presetKey: "workbench-digest", + updatedAt: TOUCHED_TIMESTAMP, + }), + routineRow({ + id: "rtn_research_touched", + name: "Research I tuned", presetKey: "last-30-days-research", + updatedAt: TOUCHED_TIMESTAMP, + }), + routineRow({ + id: "rtn_digest_pristine", + name: "Daily digest", + presetKey: "workbench-digest", }), ], }, }; } - throw new Error( - `must not touch routines on a re-seed: ${method} ${path}`, - ); + if ( + method === "DELETE" && + path.startsWith(`/api/tenants/${TENANT_ID}/routines/`) + ) { + deleteCalls.push(path.split("/").pop() ?? ""); + return { status: 204, data: undefined }; + } + throw new Error(`unexpected hub call: ${method} ${path}`); }; - await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); + await pruneDroppedPresetRoutines(fakeAPI(handler), [], TENANT_ID, log); + expect(deleteCalls).toEqual(["rtn_digest_pristine"]); const output = lines.join("\n"); expect(output).toContain( - 'routine "Last 30 days research" already exists (skipped)', + 'routine "Daily digest I tuned" outlived its preset but was touched by a member (kept)', + ); + expect(output).toContain( + 'routine "Research I tuned" outlived its preset but was touched by a member (kept)', ); }); - test("a legacy row without a presetKey is matched by name, never re-planted", async () => { + test("keeps a pre-presetKey legacy row", async () => { const { log } = collector(); const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return assetsResponse(deployedAssetsAndDeployments().assets); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return deploymentsResponse(deployedAssetsAndDeployments().deployments); - } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { - items: DEFAULT_ROUTINE_PRESETS.map((preset, index) => + items: [ routineRow({ - id: `rtn_${index}`, - name: preset.name, - enabled: false, + id: "rtn_legacy", + name: "Daily digest", presetKey: null, }), - ), + ], }, }; } throw new Error(`must not touch legacy rows: ${method} ${path}`); }; - await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); + await pruneDroppedPresetRoutines(fakeAPI(handler), [], TENANT_ID, log); }); - test("sends each preset's assetName as presetKey — the create-if-absent identity", async () => { - const { log } = collector(); - const createCalls: { body: unknown }[] = []; - const handler: FakeHandler = (method, path, body) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return assetsResponse([ - assetRow("ast_research", "last-30-days-research"), - ]); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return deploymentsResponse([ - deploymentRow("dep_research", "ast_research"), - ]); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { - return { status: 200, data: { items: [] } }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { - createCalls.push({ body }); - return { - status: 201, - data: routineRow({ - id: "rtn_1", - name: "Last 30 days research", - enabled: false, - presetKey: "last-30-days-research", - }), - }; - } - return undefined; - }; - - await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); - - expect(createCalls[0]?.body).toMatchObject({ - presetKey: "last-30-days-research", - }); - }); - - // CL-6375: even when the app-level "already present" check races - // (two overlapping seed calls both list zero existing routines, so - // both POST), the server's own create-if-absent guarantee means only - // one of the two POSTs actually mints a row. `ensureDefaultRoutines` - // must read a 200 as "already exists" and leave the row alone. - test("a 200 create response (lost the create-if-absent race) is treated as already-seeded", async () => { + test("a concurrent prune 404 is already-retired, not a failure", async () => { const { lines, log } = collector(); const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return assetsResponse([ - assetRow("ast_research", "last-30-days-research"), - ]); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return deploymentsResponse([ - deploymentRow("dep_research", "ast_research"), - ]); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { - // The app-level pre-check itself raced and saw nothing yet — - // the server is the one that actually caught the duplicate. - return { status: 200, data: { items: [] } }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { - return { - status: 200, - data: routineRow({ - id: "rtn_winner", - name: "Last 30 days research", - deliveryWorkbenchId: "ch_winner", - enabled: false, - presetKey: "last-30-days-research", - }), - }; - } - return undefined; - }; - - await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); - - expect(lines.join("\n")).toContain( - 'routine "Last 30 days research" already exists (skipped)', - ); - }); - - // CL-6400: a member deleting a preset routine is a decision, not a - // gap for the next seed pass to fill back in. The hub answers such a - // create with 204 and no row; the seed respects it. - test("a preset the member deleted stays deleted (204 is respected, not fatal)", async () => { - const { lines, log } = collector(); - let posts = 0; - const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return assetsResponse(deployedAssetsAndDeployments().assets); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return deploymentsResponse(deployedAssetsAndDeployments().deployments); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { - return { status: 200, data: { items: [] } }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { - posts += 1; - return { status: 204, data: undefined }; - } - return undefined; - }; - - await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); - - expect(posts).toBe(DEFAULT_ROUTINE_PRESETS.length); - expect(lines.join("\n")).toContain("removed by a member (respected)"); - }); - - test("a pristine routine for a preset that no longer ships is deleted; a member-touched one is kept", async () => { - const { lines, log } = collector(); - const deleteCalls: string[] = []; - const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return assetsResponse(deployedAssetsAndDeployments().assets); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return deploymentsResponse(deployedAssetsAndDeployments().deployments); - } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [ routineRow({ - id: "rtn_pristine_orphan", - name: "Old preset", - enabled: false, - presetKey: "retired-preset", - }), - routineRow({ - id: "rtn_touched_orphan", - name: "Old preset I tuned", - enabled: true, - presetKey: "other-retired-preset", - updatedAt: TOUCHED_TIMESTAMP, + id: "rtn_digest", + name: "Daily digest", + presetKey: "workbench-digest", }), - ...DEFAULT_ROUTINE_PRESETS.map((preset, index) => - routineRow({ - id: `rtn_${index}`, - name: preset.name, - enabled: false, - presetKey: preset.assetName, - }), - ), ], }, }; } if ( method === "DELETE" && - path.startsWith(`/api/tenants/${TENANT_ID}/routines/`) + path === `/api/tenants/${TENANT_ID}/routines/rtn_digest` ) { - deleteCalls.push(path.split("/").pop() ?? ""); - return { status: 204, data: undefined }; + return { status: 404, data: { error: "not found" } }; } - return undefined; + throw new Error(`unexpected hub call: ${method} ${path}`); }; - await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); + await pruneDroppedPresetRoutines(fakeAPI(handler), [], TENANT_ID, log); - expect(deleteCalls).toEqual(["rtn_pristine_orphan"]); - const output = lines.join("\n"); - expect(output).toContain( - 'routine "Old preset" retired (its preset no longer ships)', - ); - expect(output).toContain( - 'routine "Old preset I tuned" outlived its preset but was touched by a member (kept)', - ); + expect(lines.join("\n")).toContain('routine "Daily digest" already retired'); }); - test("a pristine Daily digest wrapper is retired once digest is no longer a routine preset", async () => { - const { lines, log } = collector(); - const deleteCalls: string[] = []; + test("a non-204 delete is a loud failure naming the routine", async () => { + const { log } = collector(); const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return assetsResponse(deployedAssetsAndDeployments().assets); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return deploymentsResponse(deployedAssetsAndDeployments().deployments); - } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, @@ -546,68 +202,23 @@ describe("ensureDefaultRoutines", () => { routineRow({ id: "rtn_digest", name: "Daily digest", - enabled: false, presetKey: "workbench-digest", }), - routineRow({ - id: "rtn_research", - name: "Last 30 days research", - enabled: false, - presetKey: "last-30-days-research", - }), ], }, }; } if ( method === "DELETE" && - path.startsWith(`/api/tenants/${TENANT_ID}/routines/`) - ) { - deleteCalls.push(path.split("/").pop() ?? ""); - return { status: 204, data: undefined }; - } - return undefined; - }; - - await ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log); - - expect(deleteCalls).toEqual(["rtn_digest"]); - expect(lines.join("\n")).toContain( - 'routine "Daily digest" retired (its preset no longer ships)', - ); - }); - - test("a non-201 create response is a loud failure naming the preset", async () => { - const { log } = collector(); - const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + path === `/api/tenants/${TENANT_ID}/routines/rtn_digest` ) { - return assetsResponse([ - assetRow("ast_research", "last-30-days-research"), - ]); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return deploymentsResponse([ - deploymentRow("dep_research", "ast_research"), - ]); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { - return { status: 200, data: { items: [] } }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { - return { status: 400, data: { error: "bad request" } }; + return { status: 500, data: { error: "boom" } }; } return undefined; }; expect( - ensureDefaultRoutines(fakeAPI(handler), [], TENANT_ID, log), + pruneDroppedPresetRoutines(fakeAPI(handler), [], TENANT_ID, log), ).rejects.toThrow(HubApiError); }); }); diff --git a/packages/seeding/test/seed.test.ts b/packages/seeding/test/seed.test.ts index 0a93ef28b2..97380b520b 100644 --- a/packages/seeding/test/seed.test.ts +++ b/packages/seeding/test/seed.test.ts @@ -86,16 +86,11 @@ function baseRoutes(method: string, path: string) { return { status: 404, data: {} }; if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) return { status: 201, data: {} }; - // CL-6201: `ensureDefaultRoutines` runs at the end of every seed and - // lists the deployed workflow assets, their live deployments, and the - // tenant's existing routines before deciding what (if anything) to - // plant. Every test in this file that doesn't care about routine - // seeding gets an empty answer from all three, so the preset loop - // finds no deployed asset to target and skips quietly rather than the - // fake handler throwing "unexpected hub call". A test that defines - // its own handler for the assets/deployments paths (to drive the - // earlier asset-conflict or already-deployed flow) checks that - // handler before falling back to this one, so its own answer wins. + // `pruneDroppedPresetRoutines` runs at the end of every seed and + // lists the tenant's existing routines before deciding what (if + // anything) to DELETE. Every test in this file that doesn't care + // about leftover wrappers gets an empty list, so prune is a no-op + // rather than the fake handler throwing "unexpected hub call". if ( method === "GET" && path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` @@ -917,10 +912,9 @@ describe("seedTenant", () => { test("the default set consumed by real tenant provisioning is assistant, echo, workbench-digest, and last-30-days-research", () => { // provisionPersonalTenantIfNeeded (@workbench/onboarding) deploys // DEFAULT_WORKFLOWS for every real signup. workbench-digest is the - // seed automation the Routines picker can honestly offer. - // last-30-days-research (CL-6201) is deployed so - // `ensureDefaultRoutines` has a real definition to un-strand into a - // routine. The remaining catalog-test workflows exist only to + // seed automation the native ScheduleTrigger ticks. last-30-days- + // research is a deployed automation, not a routine wrapper. + // The remaining catalog-test workflows exist only to // exercise the platform continuously and must never reach a real // user through this array — they are seeded only via the explicit // CATALOG_TEST_WORKFLOWS opt-in. From 95696c80ca04976eaae3574d17eac6458b6c8968 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 17:09:44 -0700 Subject: [PATCH 4/9] Delete the routines parent; scheduled work is native definitions (CL-4455) Drop @corbits/routines and @corbits/routines-tools. Hub ticks ScheduleTrigger on deployed definitions; /routines lists those definitions (including stopped). Pause is definition status; run-now is POST /workflows/scheduled/:id/run. Enabled digest wrappers hand off to deployed, paused to stopped, then DROP SCHEMA. --- AGENTS.md | 2 +- PRODUCT.md | 12 +- apps/hub/README.md | 4 +- apps/hub/package.json | 1 - apps/hub/src/credential-expiry-sweep.ts | 9 +- apps/hub/src/index.ts | 355 +---- .../hub/src/native-workflow-routine-launch.ts | 6 +- apps/hub/src/routine-launcher.test.ts | 473 ------- apps/hub/src/routine-launcher.ts | 294 ---- apps/hub/src/routine-run-summary.ts | 33 - apps/hub/src/routine-scheduler.ts | 151 --- apps/hub/src/tool-grants.test.ts | 28 +- apps/hub/src/workflow-scheduler.ts | 6 +- apps/hub/test/cron-due.test.ts | 6 +- apps/hub/test/routine-mount.test.ts | 79 -- apps/hub/test/routine-scheduler.test.ts | 327 ----- .../workflow-substrate-factory/step-env.ts | 4 +- apps/web/README.md | 4 +- apps/web/package.json | 1 - apps/web/src/command-palette-actions.test.ts | 57 - apps/web/src/command-palette-actions.ts | 45 - apps/web/src/command-palette-provider.tsx | 19 +- apps/web/src/global-routines.ts | 190 +-- apps/web/src/insights-stats.test.ts | 25 +- apps/web/src/insights-stats.ts | 6 +- apps/web/src/pages/chat-page.tsx | 14 - apps/web/src/pages/insights-page.tsx | 10 +- apps/web/src/pages/routine-detail-page.tsx | 589 +------- apps/web/src/pages/routines-page.tsx | 378 +----- .../src/pages/run-status-tone-parity.test.ts | 7 - apps/web/src/pages/workbench-timeline.tsx | 114 +- apps/web/src/routes.tsx | 7 +- apps/web/src/routine-health-tone.ts | 27 - apps/web/src/routine-schedule.tsx | 334 ----- apps/web/src/routines-api.ts | 160 +-- apps/web/src/shell/canvas-column.tsx | 4 - apps/web/src/shell/context-menu/items.tsx | 4 +- .../src/shell/definition-target-picker.tsx | 245 ---- apps/web/src/shell/routine-panel.tsx | 799 ----------- apps/web/src/shell/shell-chrome-provider.tsx | 5 +- apps/web/src/workbench-timeline-merge.test.ts | 135 +- apps/web/src/workbench-timeline-merge.ts | 95 +- .../action-command-dispatch-race.test.tsx | 1 - apps/web/test/insights-page.test.tsx | 4 +- apps/web/test/routine-detail-page.test.tsx | 599 +-------- apps/web/test/routine-panel.test.tsx | 1002 -------------- apps/web/test/routine-schedule.test.ts | 121 -- apps/web/test/routines-page.test.tsx | 481 ++----- apps/web/test/shell-chrome-wiring.test.tsx | 3 - bun.lock | 68 +- docs/GLOSSARY.md | 2 +- docs/onboarding-huggingface-connect.md | 4 +- docs/package-migrations.md | 10 +- docs/seed-reconciliation.md | 20 +- docs/workflow-model.md | 52 +- .../src/agent-definition-drafting.ts | 10 +- .../src/resolve-myra-definition-id.ts | 8 +- packages/chat/src/run-participant.ts | 9 +- .../src/workflow-connection-routes.ts | 4 +- packages/evals/package.json | 2 - packages/evals/src/fakes/recording.ts | 3 +- packages/evals/src/index.ts | 8 - packages/evals/src/runner.test.ts | 1 - packages/evals/src/runner.ts | 1 - packages/evals/src/scorers/scorers.test.ts | 1 - packages/evals/src/scorers/scorers.ts | 9 +- packages/evals/src/scorers/tool-names.test.ts | 16 +- packages/evals/src/scorers/tool-names.ts | 6 +- .../evals/src/scorers/world-scorers.test.ts | 63 - packages/evals/src/scorers/world-scorers.ts | 50 - .../evals/src/targets/fire-routine.test.ts | 152 --- packages/evals/src/targets/fire-routine.ts | 115 -- packages/evals/src/targets/real-target.ts | 16 - .../evals/src/targets/world-snapshot.test.ts | 46 +- packages/evals/src/targets/world-snapshot.ts | 53 +- packages/evals/src/types.ts | 26 +- packages/folded-runs/README.md | 2 +- packages/folded-runs/test/launch.test.ts | 10 +- packages/longevity-sim/src/stack.ts | 2 +- packages/routines-tools/LICENSE | 176 --- packages/routines-tools/package.json | 26 - packages/routines-tools/src/client.test.ts | 239 ---- packages/routines-tools/src/client.ts | 259 ---- packages/routines-tools/src/index.ts | 20 - packages/routines-tools/src/tool.test.ts | 662 --------- packages/routines-tools/src/tool.ts | 704 ---------- packages/routines-tools/tsconfig.json | 8 - packages/routines/LICENSE | 176 --- packages/routines/README.md | 115 -- packages/routines/package.json | 41 - packages/routines/src/client.test.ts | 150 --- packages/routines/src/client.ts | 273 ---- packages/routines/src/cron.ts | 17 - packages/routines/src/health.test.ts | 557 -------- packages/routines/src/health.ts | 423 ------ packages/routines/src/index.ts | 101 -- packages/routines/src/migrations.ts | 206 --- packages/routines/src/render-input.test.ts | 72 - packages/routines/src/render-input.ts | 55 - packages/routines/src/routes.ts | 1057 --------------- packages/routines/src/routine-operations.ts | 91 -- packages/routines/src/run-language.test.ts | 47 - packages/routines/src/run-language.ts | 49 - .../routines/src/schedule-language.test.ts | 130 -- packages/routines/src/schedule-language.ts | 108 -- packages/routines/src/schema.ts | 109 -- packages/routines/src/store.ts | 887 ------------- packages/routines/src/targets-route.ts | 68 - packages/routines/src/targets.ts | 230 ---- packages/routines/src/trigger.test.ts | 149 --- packages/routines/src/trigger.ts | 320 ----- .../src/workflow-routine-routes.test.ts | 566 -------- .../routines/src/workflow-routine-routes.ts | 599 --------- packages/routines/test/cron.test.ts | 270 ---- packages/routines/test/migrations.test.ts | 333 ----- packages/routines/test/platform-stub.ts | 31 - packages/routines/test/routes.test.ts | 1181 ----------------- packages/routines/test/run-now-input.test.ts | 119 -- packages/routines/test/store.drizzle.test.ts | 191 --- packages/routines/test/store.test.ts | 315 ----- .../routines/test/targets.drizzle.test.ts | 404 ------ packages/routines/test/trigger.test.ts | 336 ----- packages/routines/tsconfig.json | 8 - packages/run-scope/src/scope-routes.ts | 27 +- packages/seeding/src/default-routines.ts | 82 -- packages/seeding/src/index.ts | 8 +- packages/seeding/src/seed.ts | 14 +- .../seeding/test/default-routines.test.ts | 224 ---- packages/seeding/test/seed.test.ts | 7 - .../settings-ui/src/granola-webhook-api.ts | 91 +- .../settings-ui/src/granola-webhook-card.tsx | 80 +- .../test/granola-webhook-api.test.ts | 80 +- .../test/granola-webhook-card.test.tsx | 96 +- .../granola-webhook-manage-state.test.tsx | 18 - .../src/describe.test.ts | 23 - .../tool-registry-publish/src/registry.ts | 1 - .../test/store.drizzle.test.ts | 3 +- packages/workflows/package.json | 1 + packages/workflows/src/catalog.test.ts | 8 +- packages/workflows/src/catalog.ts | 6 +- packages/workflows/src/client.ts | 1 + .../workflows/src/detail/definition-detail.ts | 2 +- packages/workflows/src/index.ts | 12 + packages/workflows/src/launchable/target.ts | 25 +- .../src/schedule/cron-sentence.test.ts | 55 + .../workflows/src/schedule/cron-sentence.ts | 54 + .../src/schedule/list-scheduled.test.ts | 99 ++ .../workflows/src/schedule/list-scheduled.ts | 98 ++ .../src/schedule/scheduled-route.test.ts | 112 ++ .../workflows/src/schedule/scheduled-route.ts | 89 ++ scripts/checks/browser-safe-subpaths.ts | 5 +- scripts/checks/no-product-tenancy.ts | 5 - .../checks/test/no-product-tenancy.test.ts | 25 +- scripts/db-setup.ts | 33 +- scripts/e2e/routine-alignment.test.ts | 836 ------------ scripts/e2e/routine-repeat.test.ts | 382 ------ scripts/e2e/routine-trigger-input.test.ts | 604 --------- scripts/e2e/smoke-webhook.test.ts | 91 +- templates/index.ts | 6 +- workflows/assistant/package.json | 3 +- workflows/assistant/src/index.ts | 6 +- workflows/assistant/test/definition.test.ts | 1 - .../test/manager-tools-scenario.test.ts | 106 +- workflows/morning-brief/README.md | 22 +- workflows/morning-brief/src/index.ts | 6 +- 165 files changed, 1219 insertions(+), 22150 deletions(-) delete mode 100644 apps/hub/src/routine-launcher.test.ts delete mode 100644 apps/hub/src/routine-launcher.ts delete mode 100644 apps/hub/src/routine-run-summary.ts delete mode 100644 apps/hub/src/routine-scheduler.ts delete mode 100644 apps/hub/test/routine-mount.test.ts delete mode 100644 apps/hub/test/routine-scheduler.test.ts delete mode 100644 apps/web/src/routine-health-tone.ts delete mode 100644 apps/web/src/routine-schedule.tsx delete mode 100644 apps/web/src/shell/definition-target-picker.tsx delete mode 100644 apps/web/src/shell/routine-panel.tsx delete mode 100644 apps/web/test/routine-panel.test.tsx delete mode 100644 apps/web/test/routine-schedule.test.ts delete mode 100644 packages/evals/src/targets/fire-routine.test.ts delete mode 100644 packages/evals/src/targets/fire-routine.ts delete mode 100644 packages/routines-tools/LICENSE delete mode 100644 packages/routines-tools/package.json delete mode 100644 packages/routines-tools/src/client.test.ts delete mode 100644 packages/routines-tools/src/client.ts delete mode 100644 packages/routines-tools/src/index.ts delete mode 100644 packages/routines-tools/src/tool.test.ts delete mode 100644 packages/routines-tools/src/tool.ts delete mode 100644 packages/routines-tools/tsconfig.json delete mode 100644 packages/routines/LICENSE delete mode 100644 packages/routines/README.md delete mode 100644 packages/routines/package.json delete mode 100644 packages/routines/src/client.test.ts delete mode 100644 packages/routines/src/client.ts delete mode 100644 packages/routines/src/cron.ts delete mode 100644 packages/routines/src/health.test.ts delete mode 100644 packages/routines/src/health.ts delete mode 100644 packages/routines/src/index.ts delete mode 100644 packages/routines/src/migrations.ts delete mode 100644 packages/routines/src/render-input.test.ts delete mode 100644 packages/routines/src/render-input.ts delete mode 100644 packages/routines/src/routes.ts delete mode 100644 packages/routines/src/routine-operations.ts delete mode 100644 packages/routines/src/run-language.test.ts delete mode 100644 packages/routines/src/run-language.ts delete mode 100644 packages/routines/src/schedule-language.test.ts delete mode 100644 packages/routines/src/schedule-language.ts delete mode 100644 packages/routines/src/schema.ts delete mode 100644 packages/routines/src/store.ts delete mode 100644 packages/routines/src/targets-route.ts delete mode 100644 packages/routines/src/targets.ts delete mode 100644 packages/routines/src/trigger.test.ts delete mode 100644 packages/routines/src/trigger.ts delete mode 100644 packages/routines/src/workflow-routine-routes.test.ts delete mode 100644 packages/routines/src/workflow-routine-routes.ts delete mode 100644 packages/routines/test/cron.test.ts delete mode 100644 packages/routines/test/migrations.test.ts delete mode 100644 packages/routines/test/platform-stub.ts delete mode 100644 packages/routines/test/routes.test.ts delete mode 100644 packages/routines/test/run-now-input.test.ts delete mode 100644 packages/routines/test/store.drizzle.test.ts delete mode 100644 packages/routines/test/store.test.ts delete mode 100644 packages/routines/test/targets.drizzle.test.ts delete mode 100644 packages/routines/test/trigger.test.ts delete mode 100644 packages/routines/tsconfig.json delete mode 100644 packages/seeding/src/default-routines.ts delete mode 100644 packages/seeding/test/default-routines.test.ts create mode 100644 packages/workflows/src/schedule/cron-sentence.test.ts create mode 100644 packages/workflows/src/schedule/cron-sentence.ts create mode 100644 packages/workflows/src/schedule/list-scheduled.test.ts create mode 100644 packages/workflows/src/schedule/list-scheduled.ts create mode 100644 packages/workflows/src/schedule/scheduled-route.test.ts create mode 100644 packages/workflows/src/schedule/scheduled-route.ts delete mode 100644 scripts/e2e/routine-alignment.test.ts delete mode 100644 scripts/e2e/routine-repeat.test.ts delete mode 100644 scripts/e2e/routine-trigger-input.test.ts 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/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/index.ts b/apps/hub/src/index.ts index 50f8cb9a2e..acd39fdda2 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -108,7 +108,6 @@ import { postRoomMessage, recordSourcesDigest, startWorkflowCommand, - sendWorkbenchMessage, settleConnectedService, workbenchLaunchPersistExtra, } from "@corbits/chat"; @@ -189,8 +188,6 @@ import { import { deliveryWorkbenchRequiredForWorkflowName, isConversationalWorkflowName, - validateTriggerFieldsAtCreate, - workflowCatalogEntry, workflowDisplayName, workbenchTemplateLibraryEntries, } from "@workbench/templates"; @@ -199,20 +196,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,17 +359,15 @@ 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, } from "./workflow-scheduler"; +import { triggerNativeWorkflowRoutineRun } from "./native-workflow-routine-launch"; import { createToolGrantsForPins } from "./tool-grants"; import { createMcpCredentialBindingsFor } from "./mcp-credential-bindings"; import { reconcilePinnedToolPackagesAfterConnect } from "./connection-live-reconcile"; @@ -1643,23 +1628,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, @@ -1812,6 +1780,23 @@ export async function createHub(config: HubConfig) { }), }), ); + app.route( + `${TENANT_PREFIX}/workflows`, + createScheduledWorkflowRoutes({ + db, + requireGrant: createRequireGrant({ + grantStore: chatGrantStore, + conditionRegistry: chatConditionRegistry, + }), + runNow: async (args) => { + const { runId } = await triggerNativeWorkflowRoutineRun( + { db, sidecarRouter }, + args, + ); + return { runId }; + }, + }), + ); // 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 @@ -2849,34 +2834,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({ @@ -2885,275 +2843,15 @@ 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({ - 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, - }), - ); - // Recurring auto-fire: two pollers. `routine-scheduler.ts` ticks enabled - // `@corbits/routines` rows so a user-created daily/cron routine keeps firing. - // `workflow-scheduler.ts` ticks authored, deployed definitions whose frozen - // projection carries a native ScheduleTrigger (workbench-digest). This hub - // has no general job-runner today, so each loop is scoped to exactly one - // job rather than standing up a bespoke cron daemon as a hidden dependency. - // Every hub replica can safely run both: each routine fire is claimed on - // `nextFireAt`, each native fire on `workflow_definition.schedule_claimed_minute`. - const routineScheduler = createRoutineScheduler({ - store: routineStore, - launcher: routineLauncher, - deliveryWorkbenchRequired: routineDeliveryWorkbenchRequired, - ...(config.routineSchedulerPollIntervalMs !== undefined - ? { pollIntervalMs: config.routineSchedulerPollIntervalMs } - : {}), - }); + // 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), @@ -3647,7 +3345,6 @@ export async function createHub(config: HubConfig) { } envCredentialPlant.stop(); chatOrchestrator.dispose(); - routineScheduler.stop(); workflowScheduler.stop(); credentialExpirySweep.stop(); inboxUnsnoozeSweep.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/tool-grants.test.ts b/apps/hub/src/tool-grants.test.ts index b2636f3045..5382e2ed47 100644 --- a/apps/hub/src/tool-grants.test.ts +++ b/apps/hub/src/tool-grants.test.ts @@ -9,22 +9,22 @@ 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 +32,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 +51,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, + grants.find((g) => g.resource.endsWith("memory_add"))?.effect, ).toBe("ask"); expect( - grants.find((g) => g.resource.endsWith("routine_list"))?.effect, + 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 index 250442b9a9..8c7ce466cb 100644 --- a/apps/hub/src/workflow-scheduler.ts +++ b/apps/hub/src/workflow-scheduler.ts @@ -1,7 +1,7 @@ // A minimal periodic loop that ticks native ScheduleTrigger cadences on -// authored, deployed workflow definitions. `@corbits/routines` is not in -// this path: the hashed cron lives on the frozen inert projection, and -// launch goes through `triggerNativeWorkflowRoutineRun`. +// 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` 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/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..f2ca176f9b 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -53,7 +53,6 @@ import { ArtifactListPageSchema, useAPIQuery } from "./api"; import { useBench } from "./bench-context"; import { useCloseCanvas, - useOpenRoutineInCanvas, } from "./shell/canvas-availability"; import { listMcpServers } from "@corbits/plugins-ui"; import { @@ -61,7 +60,7 @@ import { 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 +105,6 @@ export function CommandPaletteProvider({ const [recents, setRecents] = useState([]); const { cycleMode } = useTheme(); const closeCanvas = useCloseCanvas(); - const openRoutine = useOpenRoutineInCanvas(); const recentsStore = useMemo( () => @@ -267,7 +265,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 +359,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 +402,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 +518,7 @@ 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 runScheduledWorkflowNow(selectedTenantId, routineId); } navigate(`/routines/${encodeURIComponent(routineId)}`); } else if (id.startsWith("action:")) { @@ -533,7 +528,6 @@ export function CommandPaletteProvider({ tenantId: selectedTenantId, cycleTheme: cycleMode, closeCanvas, - openRoutine, }); } else if (id.startsWith("route:")) { const routePath = id.slice("route:".length); @@ -596,7 +590,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..c19e363292 100644 --- a/apps/web/src/global-routines.ts +++ b/apps/web/src/global-routines.ts @@ -1,30 +1,10 @@ -// 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 { useMemo } from "react"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -36,61 +16,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 +67,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 +94,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 +117,43 @@ 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) { 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 }); - invalidate(row.tenantId); - } catch (cause) { - toast( - routineActionFailedToast( - enabled ? "resume" : "pause", - row.routine.name, - describeApiError( - cause, - enabled ? "resuming this routine" : "pausing this routine", - ), - ), + await setScheduledWorkflowStatus( + row.tenantId, + row.definition.definitionId, + enabled ? "deployed" : "stopped", ); - } - }, - 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 }, - }); + invalidate(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..89cb0ad17d 100644 --- a/apps/web/src/insights-stats.test.ts +++ b/apps/web/src/insights-stats.test.ts @@ -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..b7d72157b0 100644 --- a/apps/web/src/insights-stats.ts +++ b/apps/web/src/insights-stats.ts @@ -9,7 +9,7 @@ import { } from "@corbits/routines/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.tsx b/apps/web/src/pages/insights-page.tsx index 48b23edfae..aae8855af7 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -112,7 +112,7 @@ 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 +706,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 +1373,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 +1752,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/routine-detail-page.tsx b/apps/web/src/pages/routine-detail-page.tsx index 4ded234983..aff497d1a4 100644 --- a/apps/web/src/pages/routine-detail-page.tsx +++ b/apps/web/src/pages/routine-detail-page.tsx @@ -1,556 +1,116 @@ -// `/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 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"; - -/** 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); -} +import { scheduleSentence } from "./routines-page"; -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; }) { 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…" /> - -
+ ); } if (routinesQuery.kind === "error") { @@ -558,55 +118,22 @@ export function RoutineDetailRoute({ ); } - 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..0a1b6b8354 100644 --- a/apps/web/src/pages/workbench-timeline.tsx +++ b/apps/web/src/pages/workbench-timeline.tsx @@ -1,38 +1,29 @@ // 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 { 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 +35,6 @@ const FILTERS: readonly { }[] = [ { id: "all", label: "All" }, { id: "messages", label: "Messages" }, - { id: "runs", label: "Runs" }, { id: "approvals", label: "Approvals" }, ]; @@ -57,17 +47,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,12 +55,6 @@ 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"; } @@ -89,10 +62,8 @@ function markerClass(event: TimelineEvent): string { function TimelineRowBody({ event, - onOpenRun, }: { readonly event: TimelineEvent; - readonly onOpenRun: (runId: string) => void; }) { switch (event.kind) { case "message": @@ -113,26 +84,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 (
@@ -147,10 +98,8 @@ function TimelineRowBody({ function TimelineRow({ event, - onOpenRun, }: { readonly event: TimelineEvent; - readonly onOpenRun: (runId: string) => void; }) { const indented = event.kind === "thread-fork"; return ( @@ -166,7 +115,7 @@ function TimelineRow({ {timeOfDay(event.at)} - + ); } @@ -188,10 +137,6 @@ function DayKpiRow({
Agent turns
{kpi.agentTurns}
-
-
Routine runs
-
{kpi.routineRuns}
-
Approvals
{kpi.approvals}
@@ -204,11 +149,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 +170,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 +210,6 @@ export function WorkbenchTimelineView({ ))} @@ -294,12 +236,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 +266,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 +283,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..90f5481411 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -314,10 +314,9 @@ export const APP_ROUTES: readonly AppRoute[] = [ path: ROUTINE_DETAIL_PATH, label: "Routine", icon: , - render: (path: string, navigate: (to: string) => void) => ( + render: (path: string) => ( ), }, @@ -325,8 +324,8 @@ export const APP_ROUTES: readonly AppRoute[] = [ path: "/routines", label: "Routines", icon: , - render: (_path: string, navigate: (to: string) => void) => ( - + render: () => ( + ), }, { 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..0de02aaddc 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; + +const ScheduledWorkflowsResponse = type({ + items: ScheduledWorkflowDefinition.array(), +}); -/** 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 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,41 @@ async function request( return parsed; } -export function listRoutines(tenantId: string): Promise { - return request(routinesPath(tenantId), RoutinesResponse).then( - (page) => page.items, - ); +export function scheduledWorkflowsPath(tenantId: string): string { + return `/api/tenants/${tenantId}/workflows/scheduled`; } -export function getRoutine(tenantId: string, id: string): Promise { - return request(routinePath(tenantId, id), Routine); -} - -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" }), { + return request(scheduledWorkflowRunPath(tenantId, definitionId), RunNowResponse, { method: "POST", body: JSON.stringify({}), }); } -export function listRoutineRuns( +export function setScheduledWorkflowStatus( 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, - ); -} - -/** Every routine target in the tenant, cursor-walked. */ -export async function listAllRoutineTargets( - 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 +149,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.tsx b/apps/web/src/shell/context-menu/items.tsx index 52f6d1d20d..29a153d29f 100644 --- a/apps/web/src/shell/context-menu/items.tsx +++ b/apps/web/src/shell/context-menu/items.tsx @@ -35,7 +35,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,7 +155,7 @@ function routineMenu( label: "Run now", icon: , onSelect: () => { - void runRoutineNow(tenantId, target.id).then( + void runScheduledWorkflowNow(tenantId, target.id).then( () => { toast(`${target.name} started`); actions.onRoutineRan(tenantId); 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-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} -
- -
- -