Skip to content

Commit 137db3b

Browse files
committed
Let a catalog target run each example on its own Worker
An authoring target can now declare grouping: 'per-example'. The generated registration then registers one target for each example, with its own task queue, workflow bundle, and activities. The default, 'shared', keeps the current output unchanged.
1 parent 5696caf commit 137db3b

4 files changed

Lines changed: 359 additions & 14 deletions

File tree

‎src/lib/catalog/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ An API key cannot be combined with any TLS variable. The CA certificate and serv
6868

6969
A target is a worker specification: a namespace, a task queue, a workflow bundle, and its activities. The catalog runs **one worker per target**, all in a single process sharing one connection. Starting the worker with two targets registered starts two workers.
7070

71+
### Grouping examples into targets
72+
73+
An authoring target declares how its examples map to workers with `grouping`:
74+
75+
| Grouping | Workers | Task queue | Workflow bundle | Same activity name in two examples |
76+
| ----------------------- | ----------------------- | ----------------------------- | --------------------------- | ---------------------------------- |
77+
| `shared` (the default) | One for all examples | `defaultTaskQueue` | The generated workflows module | An error |
78+
| `per-example` | One for each example | `<defaultTaskQueue>-<example>` | The example's own `workflow.ts` | Allowed |
79+
80+
Use `shared` for many small examples that exercise primitives. Use `per-example` when each example must have clear boundaries: its own task queue, bundle, and activities. Both groupings run in the same process. The generated catalog lists each example's task queue, so the browser does not need to know which grouping produced it.
81+
82+
A `per-example` target registers targets named `<target id>-<example id>`. Validation fails when a derived target ID or task queue collides with another target. Moving an example to a target with a different grouping changes its task queue.
83+
7184
The worker waits for the server rather than exiting if it starts first, printing progress while it retries. Non-connection failures, such as an invalid API key, still fail immediately.
7285

7386
On a plaintext local connection, the worker creates any Nexus endpoints its examples declare. When it lacks the authority to do so — any credentialed connection, including Temporal Cloud — it prints the exact `temporal operator nexus endpoint create` command instead, and the catalog page shows the same command as copyable text.

‎src/lib/catalog/authoring.test.ts‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
createCatalogAuthoring as createCatalogAuthoringSdk,
2424
createCatalogLocalArtifactVitePlugin,
2525
defineCatalogArtifact,
26+
perExampleTargetId,
27+
perExampleTaskQueue,
2628
renderCatalogSourceAssembly,
2729
runCatalogCli,
2830
validateCatalogAuthoringGraph,
@@ -1630,3 +1632,185 @@ try {
16301632
).toThrow('browser boundary must declare a browser consumer');
16311633
});
16321634
});
1635+
1636+
describe('per-example catalog targets', () => {
1637+
const source = {
1638+
examplesPath: 'demos/examples',
1639+
id: 'demos',
1640+
label: 'Demos',
1641+
registrationOutputPath: 'demos/registration.ts',
1642+
registrationTypePath: 'demos/registration-source.ts',
1643+
};
1644+
const target = {
1645+
defaultNamespace: 'default',
1646+
defaultTaskQueue: 'demos',
1647+
grouping: 'per-example' as const,
1648+
id: 'demos',
1649+
sourceId: 'demos',
1650+
workflowsModulePath: 'demos/workflows.ts',
1651+
};
1652+
const workflowExample = (id: string, workflowType: string) => ({
1653+
definitionImportPath: `demos/examples/${id}/example.ts`,
1654+
id,
1655+
sourceFiles: [
1656+
`demos/examples/${id}/example.ts`,
1657+
`demos/examples/${id}/workflow.ts`,
1658+
],
1659+
sourceId: 'demos',
1660+
targetId: 'demos',
1661+
workflowExport: workflowType,
1662+
workflowImportPath: `demos/examples/${id}/workflow.ts`,
1663+
workflowType,
1664+
});
1665+
const graph = {
1666+
boundaries: {
1667+
browserPaths: ['demos/catalog.json'],
1668+
packageJsonPath: 'package.json',
1669+
workerPaths: ['demos/registration.ts', 'demos/workflows.ts'],
1670+
},
1671+
outputs: [
1672+
{ consumers: ['browser'] as const, path: 'demos/catalog.json' },
1673+
{ consumers: ['worker'] as const, path: 'demos/registration.ts' },
1674+
{ consumers: ['worker'] as const, path: 'demos/workflows.ts' },
1675+
],
1676+
sources: [source],
1677+
targets: [target],
1678+
};
1679+
1680+
it('derives a target id and task queue for each example', () => {
1681+
expect(perExampleTargetId(target, 'money-transfer')).toBe(
1682+
'demos-money-transfer',
1683+
);
1684+
expect(perExampleTaskQueue(target, 'money-transfer')).toBe(
1685+
'demos-money-transfer',
1686+
);
1687+
});
1688+
1689+
it('registers one target, task queue, and workflow bundle for each example', () => {
1690+
const [registration] = renderCatalogSourceAssembly({
1691+
examples: [
1692+
workflowExample('order-saga', 'orderSaga'),
1693+
workflowExample('money-transfer', 'moneyTransfer'),
1694+
],
1695+
source,
1696+
target,
1697+
});
1698+
const content = String(registration.content);
1699+
1700+
expect(content).not.toContain('import * as workflows');
1701+
expect(content).toContain(
1702+
"import { moneyTransfer as workflow0 } from './examples/money-transfer/workflow.js';",
1703+
);
1704+
expect(content).toContain(
1705+
[
1706+
' registry.registerTarget({',
1707+
" id: 'demos-money-transfer',",
1708+
" namespace: 'default',",
1709+
" taskQueue: 'demos-money-transfer',",
1710+
" workflowsPath: new URL('./examples/money-transfer/workflow.ts', import.meta.url).href,",
1711+
' workflowExports: { moneyTransfer: workflow0 },',
1712+
' });',
1713+
" registry.registerExample({ ...example0, targetId: 'demos-money-transfer' });",
1714+
].join('\n'),
1715+
);
1716+
expect(content).toContain("taskQueue: 'demos-order-saga'");
1717+
expect(content).not.toContain("taskQueue: 'demos',");
1718+
});
1719+
1720+
it('keeps the shared registration unchanged when grouping is omitted', () => {
1721+
const sharedTarget = { ...target, grouping: undefined };
1722+
const explicitShared = { ...target, grouping: 'shared' as const };
1723+
const examples = [workflowExample('money-transfer', 'moneyTransfer')];
1724+
1725+
const omitted = renderCatalogSourceAssembly({
1726+
examples,
1727+
source,
1728+
target: sharedTarget,
1729+
});
1730+
const explicit = renderCatalogSourceAssembly({
1731+
examples,
1732+
source,
1733+
target: explicitShared,
1734+
});
1735+
1736+
expect(omitted).toEqual(explicit);
1737+
expect(String(omitted[0].content)).toContain(
1738+
"import * as workflows from './workflows.js';",
1739+
);
1740+
expect(String(omitted[0].content)).toContain("taskQueue: 'demos',");
1741+
});
1742+
1743+
it('allows the same workflow type in two per-example demos', () => {
1744+
expect(() =>
1745+
validateCatalogAuthoringGraph(graph, [
1746+
workflowExample('first-demo', 'demoWorkflow'),
1747+
workflowExample('second-demo', 'demoWorkflow'),
1748+
]),
1749+
).not.toThrow();
1750+
});
1751+
1752+
it('still rejects the same workflow type twice in a shared target', () => {
1753+
const sharedGraph = {
1754+
...graph,
1755+
targets: [{ ...target, grouping: 'shared' as const }],
1756+
};
1757+
1758+
expect(() =>
1759+
validateCatalogAuthoringGraph(sharedGraph, [
1760+
workflowExample('first-demo', 'demoWorkflow'),
1761+
workflowExample('second-demo', 'demoWorkflow'),
1762+
]),
1763+
).toThrow('Workflow type "demoWorkflow" is declared by both');
1764+
});
1765+
1766+
it('rejects a derived task queue that collides with another target', () => {
1767+
const collidingGraph = {
1768+
...graph,
1769+
sources: [
1770+
source,
1771+
{
1772+
...source,
1773+
id: 'other',
1774+
registrationOutputPath: 'other/registration.ts',
1775+
},
1776+
],
1777+
outputs: [
1778+
...graph.outputs,
1779+
{ consumers: ['worker'] as const, path: 'other/registration.ts' },
1780+
{ consumers: ['worker'] as const, path: 'other/workflows.ts' },
1781+
],
1782+
targets: [
1783+
target,
1784+
{
1785+
defaultNamespace: 'default',
1786+
defaultTaskQueue: 'demos-money-transfer',
1787+
id: 'other',
1788+
sourceId: 'other',
1789+
workflowsModulePath: 'other/workflows.ts',
1790+
},
1791+
],
1792+
};
1793+
1794+
expect(() =>
1795+
validateCatalogAuthoringGraph(collidingGraph, [
1796+
workflowExample('money-transfer', 'moneyTransfer'),
1797+
]),
1798+
).toThrow(
1799+
'Catalog task queue "demos-money-transfer" is claimed by both target other and example money-transfer',
1800+
);
1801+
});
1802+
1803+
it('rejects an unknown grouping', () => {
1804+
expect(() =>
1805+
validateCatalogAuthoringGraph(
1806+
{
1807+
...graph,
1808+
targets: [
1809+
{ ...target, grouping: 'per-tenant' as unknown as 'shared' },
1810+
],
1811+
},
1812+
[],
1813+
),
1814+
).toThrow('Catalog target has an unknown grouping: per-tenant');
1815+
});
1816+
});

0 commit comments

Comments
 (0)