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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/onboarding/src/tenant-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
1 change: 1 addition & 0 deletions scripts/e2e/workbench-digest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
8 changes: 5 additions & 3 deletions workflows/credential-expiry/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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,
});
Expand Down
25 changes: 14 additions & 11 deletions workflows/credential-expiry/src/index.ts
Original file line number Diff line number Diff line change
@@ -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:<slug>` OAuth tokens in place before ever mailing anyone
Expand Down Expand Up @@ -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 " +
Expand All @@ -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. */
Expand All @@ -78,14 +78,17 @@ 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",
);
}
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({
Expand Down
20 changes: 10 additions & 10 deletions workflows/workbench-digest/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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,
});
Expand Down
24 changes: 15 additions & 9 deletions workflows/workbench-digest/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 " +
Expand All @@ -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. */
Expand All @@ -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({
Expand Down
13 changes: 9 additions & 4 deletions workflows/workbench-digest/test/definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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/,
Expand Down
Loading