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
1 change: 0 additions & 1 deletion apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
"@corbits/tool-registry-publish": "workspace:*",
"@corbits/turn-artifacts": "workspace:*",
"@corbits/url-path": "workspace:*",
"@corbits/code-review-workflow": "workspace:*",
"@corbits/webhook-triggers": "workspace:*",
"@corbits/workflows": "workspace:*",
"@intx/authz": "0.3.0",
Expand Down
72 changes: 35 additions & 37 deletions apps/hub/src/templates/block-workflows.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,33 @@
// The source form a template's referenced block workflow deploys as —
// the missing half of instantiation CL-6405 closes: `./instantiate.ts`
// resolves a manifest's participants through the agent-directory create
// path, and this module resolves its `blocks` into the same
// source-form deploy (a serialized definition rendered into a
// `@corbits/workflows`'s `./source` tree and projected onto a
// `workflow_definition` row — see `./template-block-routes.ts` and the
// hub's `deployWorkflowSource` binding).
// The source form a catalog workflow deploys as when someone asks for it
// on demand — the instantiate path's block-workflow resolution
// (CL-6405) generalized to any catalog entry with a source package
// under `workflows/<name>` (CL-7073), not just `code-review`.
// `@corbits/seeding`'s `CATALOG_WORKFLOWS` is the one source of truth
// for which asset names are deployable this way and how to build each
// one's definition JSON — the exact same list and `buildJson` the CLI's
// `workbench seed` / the first-login provisioning hook would use to
// deploy the same asset via its own HTTP self-call path. This module
// only adapts that shared build step to the tenant's real, ordered
// inference preferences and to the source-form deploy this route uses
// (see `./template-block-routes.ts` and the hub's `deployWorkflowSource`
// binding).
//
// Server-only, on purpose: building `code-review`'s definition pulls in
// `@corbits/code-review-workflow` and with it `@intx/agent`/`@intx/workflow`
// — the heavy graph `./templates.ts` keeps every manifest consumer off.
// Only `./template-block-routes.ts` (mounted in `apps/hub`) imports
// this; it is deliberately not re-exported from the package root.

import {
buildCodeReviewWorkflow,
serializeCodeReviewWorkflow,
type CodeReviewWorkflowInput,
} from "@corbits/code-review-workflow";
// Server-only, on purpose: building a catalog workflow's definition
// pulls in its workflow package (e.g. `@corbits/granola-call-workflow`)
// and with it `@intx/agent`/`@intx/workflow` — the heavy graph
// `./templates.ts` keeps every manifest consumer off. Only
// `./template-block-routes.ts` (mounted in `apps/hub`) imports this; it
// is deliberately not re-exported from the package root.

import { deployableCatalogWorkflow } from "@corbits/seeding";
import { workflowCatalogEntry } from "@workbench/templates";

// Matches the conversational default every folded builder in this
// codebase uses (`AGENT_DEFINITION_TURN_TIMEOUT_MS`,
// `ASSISTANT_TURN_TIMEOUT_MS`): a review turn reads a diff and posts
// one review, the same order of work as a research or assistant turn.
const BLOCK_WORKFLOW_TURN_TIMEOUT_MS = 2 * 60 * 1000;

export interface BlockWorkflowBuildInput {
readonly tenantDomain: string;
readonly inferencePreferences: CodeReviewWorkflowInput["inferencePreferences"];
readonly inferencePreferences: readonly {
readonly provider: string;
readonly model: string;
}[];
}

export interface BlockWorkflowSource {
Expand All @@ -39,25 +37,25 @@ export interface BlockWorkflowSource {
}

/**
* The serialized source-form definition for one template block, or
* `undefined` for an asset name no template block builder covers yet —
* the GTM blocks resolve through their own (future) path, exactly like
* `instantiateWorkbenchTemplate`'s participants; a route answering
* The serialized source-form definition for one catalog workflow, or
* `undefined` for an asset name with no declared source package —
* `assistant` (seeded, never redeployed here) and `heartbeat`
* (test-only, never deployed onto a real bench) both answer `undefined`,
* same as any name outside the catalog entirely. A route answering
* `undefined` as a 404 is the honest statement of that gap.
*/
export function buildBlockWorkflowSource(
assetName: string,
input: BlockWorkflowBuildInput,
): BlockWorkflowSource | undefined {
if (assetName !== "code-review") return undefined;
const definition = buildCodeReviewWorkflow({
triggerAddress: `${assetName}@${input.tenantDomain}`,
inferencePreferences: input.inferencePreferences,
turnTimeoutMs: BLOCK_WORKFLOW_TURN_TIMEOUT_MS,
});
const entry = deployableCatalogWorkflow(assetName);
if (entry === undefined) return undefined;
return {
assetName,
displayName: workflowCatalogEntry(assetName)?.displayName ?? assetName,
workflowJson: serializeCodeReviewWorkflow(definition),
workflowJson: entry.buildJson(
input.tenantDomain,
input.inferencePreferences,
),
};
}
94 changes: 93 additions & 1 deletion apps/hub/src/templates/template-block-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import type { MiddlewareHandler } from "hono";
import type { RequireGrant, TenantEnv } from "@intx/hub-api";
import { makeErrorEnvelope } from "@corbits/error-sink";

import {
createTemplateBlockRoutes,
Expand Down Expand Up @@ -115,11 +116,102 @@ describe("POST /:assetName/deploy", () => {

test("an asset name no block builder covers answers 404, deploying nothing", async () => {
const { app, deployed } = buildApp();
const res = await app.request("/granola-call/deploy", { method: "POST" });
const res = await app.request("/does-not-exist/deploy", {
method: "POST",
});
expect(res.status).toBe(404);
expect(deployed).toHaveLength(0);
});

test("the GTM template's first block (exa-topic-watch) deploys through the route now that it is registered (CL-7073)", async () => {
const { app, deployed } = buildApp();
const res = await app.request("/exa-topic-watch/deploy", {
method: "POST",
});
expect(res.status).toBe(201);
expect(deployed).toHaveLength(1);
const source = deployed[0];
if (source === undefined) throw new Error("nothing deployed");
expect(source.assetName).toBe("exa-topic-watch");
const definition = JSON.parse(source.workflowJson) as {
triggers: { type: string; to: string }[];
};
expect(definition.triggers).toEqual([
{ type: "mail", to: "exa-topic-watch@acme.example" },
]);
});

test("a denied grant answers a 403 envelope with a refId, deploying nothing", async () => {
const { app, deployed } = buildApp({
requireGrant: () => async (c) => {
return c.json(
makeErrorEnvelope({
code: "forbidden",
userMessage: "You do not have permission to perform this action",
}),
403,
);
},
});
const res = await app.request("/code-review/deploy", { method: "POST" });
expect(res.status).toBe(403);
const body = (await res.json()) as {
error: { code: string; userMessage: string; refId: string };
};
expect(body.error.code).toBe("forbidden");
expect(typeof body.error.refId).toBe("string");
expect(body.error.refId.length).toBeGreaterThan(0);
expect(deployed).toHaveLength(0);
});

test("deploys any on-demand catalog workflow, not just code-review (CL-7073)", async () => {
const { app, deployed } = buildApp();
const res = await app.request("/last-30-days-research/deploy", {
method: "POST",
});
expect(res.status).toBe(201);
expect(deployed).toHaveLength(1);
const source = deployed[0];
if (source === undefined) throw new Error("nothing deployed");
expect(source.assetName).toBe("last-30-days-research");
const definition = JSON.parse(source.workflowJson) as {
triggers: { type: string; to: string }[];
};
expect(definition.triggers).toEqual([
{ type: "mail", to: "last-30-days-research@acme.example" },
]);
});

test("assistant is seeded, never deployed through this route", async () => {
const { app, deployed } = buildApp();
const res = await app.request("/assistant/deploy", { method: "POST" });
expect(res.status).toBe(404);
expect(deployed).toHaveLength(0);
});

test("heartbeat is test-only, never deployed through this route", async () => {
const { app, deployed } = buildApp();
const res = await app.request("/heartbeat/deploy", { method: "POST" });
expect(res.status).toBe(404);
expect(deployed).toHaveLength(0);
});

test("a credential-bound catalog workflow answers 409 not_deployable_yet, deploying nothing (CL-7073)", async () => {
const { app, deployed } = buildApp();
const res = await app.request("/granola-call/deploy", { method: "POST" });
expect(res.status).toBe(409);
const body = (await res.json()) as {
error: { code: string; userMessage: string; refId: string };
};
expect(body.error.code).toBe("not_deployable_yet");
expect(body.error.userMessage).not.toMatch(
/cipher|pin|vendor|Interchange/i,
);
expect(typeof body.error.refId).toBe("string");
expect(body.error.refId.length).toBeGreaterThan(0);
expect(deployed).toHaveLength(0);
});

test("a failing deploy port answers a 500 envelope, never a raw error", async () => {
const { app } = buildApp({
deployWorkflowSource: async () => {
Expand Down
20 changes: 20 additions & 0 deletions apps/hub/src/templates/template-block-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { Hono } from "hono";
import type { RequireGrant, TenantEnv } from "@intx/hub-api";
import { makeErrorEnvelope } from "@corbits/error-sink";
import { catalogWorkflowDeployableOnThisPin } from "@corbits/seeding";

import {
buildBlockWorkflowSource,
Expand All @@ -20,6 +21,15 @@ import {
const DEPLOY_FAILED_MESSAGE =
"Couldn't set up this template's workflow. Try again in a moment.";

// Six catalog workflows carry `credentialBindings` their definition needs
// resolved at deploy time; this pin's `deployWorkflowSource` port has no
// `credentialCipher` seam to resolve them (see
// `catalogWorkflowDeployableOnThisPin`, `docs/seed-reconciliation.md`).
// Refusing here, before `deployWorkflowSource` is ever called, keeps that
// gap an honest 409 instead of the deployer throwing into the 500 branch
// below.
const NOT_DEPLOYABLE_YET_MESSAGE = "Coming with the next platform update.";

export type TemplateBlockRoutesDeps = {
requireGrant: RequireGrant;
/** Where a failure's real cause goes — the same CL-6360 idiom
Expand Down Expand Up @@ -76,6 +86,16 @@ export function createTemplateBlockRoutes(
);
}

if (!catalogWorkflowDeployableOnThisPin(assetName)) {
return c.json(
makeErrorEnvelope({
code: "not_deployable_yet",
userMessage: NOT_DEPLOYABLE_YET_MESSAGE,
}),
409,
);
}

try {
const result = await deps.deployWorkflowSource({
tenantId: tenant.id,
Expand Down
12 changes: 11 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 65 additions & 6 deletions docs/seed-reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,27 @@ not plant wrapper rows.
real signup gets automatically: `assistant` (Myra), and nothing else. A
fresh bench used to also pay a git push and a sidecar probe for `echo`,
`workbench-digest`, and `last-30-days-research` — three workflows
nobody had asked for yet. Those three now live in `CATALOG_WORKFLOWS`,
same shape (`DefinitionWithAgentSteps`-backed `DefaultWorkflow`
entries), deployed the same way (`ensureWorkflowAsset` → `pushWorkflow`
→ `ensureDeployment`), but only when something asks for one by name —
the catalog/instantiate surface, or a test standing up its own fixture
bench — never automatically at signup.
nobody had asked for yet. Those three, plus every other
`workflows/<name>` source package that exports a builder
(`code-review`, `granola-call`, `morning-brief`, `exa-topic-watch`,
`process-granola-call`, `attio-task-agent`, `pain-point-collateral`,
`reddit-opportunity-scanner`, `collateral-generation`,
`diligence-brief`), now live in `CATALOG_WORKFLOWS`, same shape
(`DefinitionWithAgentSteps`-backed `DefaultWorkflow` entries) and
deployable through the catalog instantiate route (CL-7073), but never
automatically at signup.

Registration is total, not partial: `packages/seeding/test/workflow-source-registration.test.ts`
asserts every directory under `workflows/` appears in exactly one of
`DEFAULT_WORKFLOWS`, `CATALOG_WORKFLOWS`, `CATALOG_TEST_WORKFLOWS`, or
the explicit `EXCLUDED_WORKFLOW_SOURCES` list (a one-line reason per
entry, currently empty — every source directory is registered
somewhere today). A new `workflows/<name>` package that nobody wires up
fails that test instead of 404ing silently through the catalog
instantiate route, which is exactly the drift CL-7073's critique
caught: `CATALOG_WORKFLOWS` had four entries while a template's blocks
(the GTM template's, in particular) named source packages that were
never registered at all.

There is no orphan-retire for an entry that moved from
`DEFAULT_WORKFLOWS` to `CATALOG_WORKFLOWS`: an asset already deployed
Expand All @@ -75,6 +90,30 @@ on an existing bench from before the move is left exactly as it is.
signup set for workflows that exist only to exercise the platform
continuously.

### Deployable through the catalog (CL-7073)

`CATALOG_WORKFLOWS` is the one source of truth for which catalog asset
names have a source package under `workflows/<name>` and can be
deployed on demand. Two callers reuse it, sharing the same `buildJson`
per entry rather than each hand-rolling a definition:

- `seedTenant` (`workbench seed`, the first-login provisioning hook)
can deploy any of `CATALOG_WORKFLOWS` the same way it deploys
`DEFAULT_WORKFLOWS`, over its HTTP self-call path
(`ensureWorkflowAsset` → `pushWorkflow` → `ensureDeployment`).
- The hub's `POST /:assetName/deploy` template-block route
(`apps/hub/src/templates/template-block-routes.ts`,
`apps/hub/src/templates/block-workflows.ts`) deploys any
`CATALOG_WORKFLOWS` entry natively, in-process, against the tenant's
real inference preferences — the same route that already deployed
`code-review` for template instantiation, generalized (CL-7073) so
`code-review` is just another `CATALOG_WORKFLOWS` entry rather than a
hardcoded special case. Idempotent: a tenant that already carries a
deployed definition under that asset name answers with the existing
definition (`created: false`) rather than deploying a second time.
`assistant` (seeded already) and `heartbeat` (test-only,
`CATALOG_TEST_WORKFLOWS`) are never reachable through this route.

## Default skills (boot-time seeding)

`plantDefaultSkills` (`packages/seeding/src/seed.ts`) plants each
Expand Down Expand Up @@ -148,3 +187,23 @@ runs against that existing credential (`existingCredentialId`, no
missing rows are planted, existing ones 409-skip, nothing is deleted.
Removing an env var never deletes the planted credential: credentials
are operator data once planted, not seeds to garbage-collect.

## Credential-bound catalog workflows (CL-7073)

Six `CATALOG_WORKFLOWS` entries — granola-call, morning-brief,
process-granola-call, pain-point-collateral, collateral-generation,
diligence-brief — declare `credentialBindings` in their definition.
`deployCodeSourcedWorkflow` (`vendor/intx/hub-sessions`) refuses to
resolve those bindings without a `credentialCipher`, and the current
Interchange pin's `POST /template-blocks/:assetName/deploy` front (the
route the Routines "Available" catalog's Add action drives) has no
seam to supply one. `catalogWorkflowDeployableOnThisPin`
(`packages/seeding/src/seed.ts`) is the one place that knows this: the
deploy route refuses these six with a 409 `not_deployable_yet` before
the deployer ever throws, and the available-catalog route marks them
`deployable: false` so the UI offers them with disabled, honest copy
instead of a working-looking Add button. These six become addable at
the Interchange re-pin (CL-7107 / PR #632, pin 692c3106), which adds
the `credentialCipher` parameter this front is missing — no code in
this ledger's callers needs to change, only the entry's derived
`requiresCredentialCipher` result once the seam exists.
Loading
Loading