diff --git a/packages/onboarding/src/tenant-seed.ts b/packages/onboarding/src/tenant-seed.ts index a514c2da15..a77cff274d 100644 --- a/packages/onboarding/src/tenant-seed.ts +++ b/packages/onboarding/src/tenant-seed.ts @@ -282,9 +282,10 @@ export const CATALOG_WORKFLOWS: readonly DefaultWorkflow[] = [ assetName: "workbench-digest", displayName: catalogDisplayName("workbench-digest"), automatable: catalogAutomatable("workbench-digest"), - buildJson: (_tenantDomain, inferencePreferences) => + buildJson: (tenantDomain, inferencePreferences) => serializeWorkbenchDigestWorkflow( buildWorkbenchDigestWorkflow({ + triggerAddress: `workbench-digest@${tenantDomain}`, inferencePreferences, turnTimeoutMs: WORKBENCH_DIGEST_TURN_TIMEOUT_MS, }), diff --git a/scripts/e2e/workbench-digest.test.ts b/scripts/e2e/workbench-digest.test.ts index 2d2900be4a..dc38312dfa 100644 --- a/scripts/e2e/workbench-digest.test.ts +++ b/scripts/e2e/workbench-digest.test.ts @@ -161,6 +161,7 @@ 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/credential-expiry/README.md b/workflows/credential-expiry/README.md index 70c944e631..a6017a099e 100644 --- a/workflows/credential-expiry/README.md +++ b/workflows/credential-expiry/README.md @@ -1,8 +1,9 @@ # @corbits/credential-expiry-workflow -A schedule-triggered workflow that checks this tenant's credentials once -a day and mails a reconnect notice for any credential that is `active` -but past its own `expiresAt`. Replaces the hub-owned periodic loop +A mail-triggered workflow that checks this tenant's credentials on each +trigger and mails a reconnect notice for any credential that is `active` +but past its own `expiresAt`. `@corbits/cron` delivers a tick mail once +a day. Replaces the hub-owned periodic loop `apps/hub/src/credential-expiry-sweep.ts` (CL-8181): the same decision runs as a deployed Routine instead of inside the hub process. @@ -37,6 +38,7 @@ import { } from "@corbits/credential-expiry-workflow"; const definition = buildCredentialExpiryWorkflow({ + triggerAddress: "ins_dep000000000000@example.test", inferencePreferences: [{ provider: "anthropic", model: "claude-haiku" }], turnTimeoutMs: 60_000, }); diff --git a/workflows/credential-expiry/src/index.ts b/workflows/credential-expiry/src/index.ts index 10c281db65..be9ad7a6ad 100644 --- a/workflows/credential-expiry/src/index.ts +++ b/workflows/credential-expiry/src/index.ts @@ -1,12 +1,14 @@ -// The credential-expiry workflow (CL-8181): on a schedule, check this +// The credential-expiry workflow (CL-8181): on each trigger, check this // tenant's credentials and mail a reconnect notice for each one that is // active but past its own `expiresAt`. Replaces the hub-owned periodic // loop `apps/hub/src/credential-expiry-sweep.ts` — that loop's own // decision logic now lives in `./decide.ts` (moved rather than // reimplemented; it needs no DB and no mail infrastructure to stay -// correct), and this definition is deployed like any other scheduled -// Routine instead of running inside the hub process for every tenant at -// once. +// correct), and this definition is deployed like any other Routine +// instead of running inside the hub process for every tenant at once. +// It is mail-triggered, not schedule-triggered: `@corbits/cron` +// delivers a tick mail on the daily cadence, so this definition never +// declares `type: "schedule"` itself (CL-8183). // // Scope narrows on the port: the old sweep also refreshed // `mcp:` OAuth tokens in place before ever mailing anyone @@ -34,10 +36,6 @@ import { CREDENTIAL_EXPIRY_CHECK_TOOL_NAME } from "./tool"; export const CREDENTIAL_EXPIRY_WORKFLOW_ID = "wf_credential_expiry"; export const CREDENTIAL_EXPIRY_STEP_ID = "credential-expiry-check"; -/** Daily at 08:00 UTC — ahead of `workbench-digest`'s 09:00 slot, so a - * reconnect notice for the day lands before the day's own digest. */ -export const CREDENTIAL_EXPIRY_SCHEDULE_CRON = "0 8 * * *"; - export const CREDENTIAL_EXPIRY_SYSTEM_PROMPT = [ "You are a scheduled credential-expiry check for this workbench's " + "tenant. Nobody is watching this run happen — write for the tenant " + @@ -55,10 +53,12 @@ export const CREDENTIAL_EXPIRY_SYSTEM_PROMPT = [ /** * Everything the definition needs that is per-deployment data. The - * schedule trigger is fixed on the definition; inference and the - * per-turn timeout are resolved at deploy time. + * trigger address names a specific deployment's inbox, so a definition + * built here is per-deployment by construction. */ export interface CredentialExpiryWorkflowInput { + /** 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. */ @@ -78,6 +78,9 @@ export interface CredentialExpiryWorkflowInput { export function buildCredentialExpiryWorkflow( input: CredentialExpiryWorkflowInput, ): WorkflowDefinition { + if (input.triggerAddress === "") { + throw new Error("buildCredentialExpiryWorkflow requires a non-empty triggerAddress"); + } if (!Number.isInteger(input.turnTimeoutMs) || input.turnTimeoutMs <= 0) { throw new Error( "buildCredentialExpiryWorkflow requires turnTimeoutMs to be a positive integer", @@ -85,7 +88,7 @@ export function buildCredentialExpiryWorkflow( } return defineWorkflow({ id: CREDENTIAL_EXPIRY_WORKFLOW_ID, - trigger: { type: "schedule", cron: CREDENTIAL_EXPIRY_SCHEDULE_CRON }, + trigger: { type: "mail", to: input.triggerAddress }, steps: { [CREDENTIAL_EXPIRY_STEP_ID]: step({ agent: defineAgent({ diff --git a/workflows/workbench-digest/README.md b/workflows/workbench-digest/README.md index 5238f27f73..a187dde875 100644 --- a/workflows/workbench-digest/README.md +++ b/workflows/workbench-digest/README.md @@ -1,19 +1,18 @@ # @corbits/workbench-digest-workflow -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`). +A single mail-triggered step meant to be deployed against a workbench's +own timeline address. `@corbits/cron` delivers a tick mail on the +digest's schedule (daily at 09:00 UTC), and each tick 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 schedule is fixed on the definition; this package never -computes the digest line itself, so its output is exactly as -deterministic as its input. +its own. This package never computes the digest line itself, so its +output is exactly as deterministic as its input. ## Cost profile @@ -25,7 +24,7 @@ constant, locally served SSE response, so running this on a tight schedule costs nothing. The trade-off: `noop-inference` always replies with empty text by design (see its header comment), so under this pin no visible digest line is actually posted — the run still proves the -scheduling and workbench-mail-posting paths stay alive, just without +mail-trigger and workbench-mail-posting paths stay alive, just without visible output. **Pinned to a real catalog model:** point `inferencePreferences` at any @@ -42,6 +41,7 @@ import { } from "@corbits/workbench-digest-workflow"; const definition = buildWorkbenchDigestWorkflow({ + triggerAddress: "ins_dep000000000000@example.test", inferencePreferences: [{ provider: "anthropic", model: "noop" }], turnTimeoutMs: 60_000, }); diff --git a/workflows/workbench-digest/src/index.ts b/workflows/workbench-digest/src/index.ts index 4e57c15ff2..f3d90b186c 100644 --- a/workflows/workbench-digest/src/index.ts +++ b/workflows/workbench-digest/src/index.ts @@ -4,10 +4,12 @@ // 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). 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. +// `connectorReplyContent` handling). Fired by mail to the deployment's +// own trigger address: `@corbits/cron` delivers a tick mail on the +// digest's schedule, so this definition never declares `type: +// "schedule"` itself. 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. // // No agent-free step primitive runs on the deployed host today (see // `@corbits/heartbeat-workflow`'s own header comment for why), so this @@ -26,7 +28,6 @@ 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 " + @@ -35,11 +36,13 @@ export const WORKBENCH_DIGEST_SYSTEM_PROMPT = "commentary, no formatting of your own."; /** - * 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. + * Everything the definition needs that is per-deployment data. The + * trigger address names a specific deployment's inbox, so a definition + * built here is per-deployment by construction. */ 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. */ @@ -58,12 +61,15 @@ 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"); } return defineWorkflow({ id: WORKBENCH_DIGEST_WORKFLOW_ID, - trigger: { type: "schedule", cron: WORKBENCH_DIGEST_SCHEDULE_CRON }, + trigger: { type: "mail", to: input.triggerAddress }, 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 a168a9a986..48b6de6c1e 100644 --- a/workflows/workbench-digest/test/definition.test.ts +++ b/workflows/workbench-digest/test/definition.test.ts @@ -7,7 +7,6 @@ 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, @@ -16,6 +15,7 @@ import { } from "../src/index"; const INPUT = { + triggerAddress: "ins_dep000000000000@example.test", inferencePreferences: [{ provider: "anthropic", model: "claude-test" }], turnTimeoutMs: 60000, } as const; @@ -39,11 +39,10 @@ test("the step carries an explicit per-turn timeout", () => { expect(digestStep(definition).timeout).toBe(INPUT.turnTimeoutMs); }); -test("the workflow is triggered on the daily 09:00 UTC schedule", () => { +test("the workflow is triggered by mail to the given deployment address", () => { const definition = buildWorkbenchDigestWorkflow(INPUT); expect(definition.id).toBe(WORKBENCH_DIGEST_WORKFLOW_ID); - expect(definition.triggers).toEqual([{ type: "schedule", cron: "0 9 * * *" }]); - expect(WORKBENCH_DIGEST_SCHEDULE_CRON).toBe("0 9 * * *"); + expect(definition.triggers).toEqual([{ type: "mail", to: INPUT.triggerAddress }]); }); test("the agent instructs relaying the exact summary line, carries the preferences, and inlines no tools", () => { @@ -86,6 +85,12 @@ 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 })).toThrow( /turnTimeoutMs/,