Skip to content

Commit 2d2f33b

Browse files
btraversclaude
andcommitted
feat(worker)!: signal drop policy, contract-misuse failures, and worker surface fixes
- D2: invalid signal payloads are dropped and logged via @temporalio/workflow's log.warn instead of throwing; SignalInputValidationError is deleted - D3: new ContractMisuseError (non-retryable ApplicationFailure) replaces every plain Error thrown from workflow-sandbox code (handler binding guards, sync-schema guards, buildRawActivitiesProxy options-coverage errors) - item 20: declareActivitiesHandler iterates contract definitions (fail fast on declared-but-missing implementations), rejects workflow-name/global-activity collisions (defense-in-depth, message aligned with defineContract), and errors on stray root-level keys whether or not contract.activities exists - item 21: TypedChildWorkflowHandle gains a typed signals map (validate on send, transmit original per D1) and firstExecutionRunId - item 22: declareWorkflow throws ContractMisuseError for unknown workflow names, listing the available ones - item 23: activities is optional on createWorker (workflow-only workers, key omitted from Worker.create when absent); key remapping drops empty "workflowName: {}" placeholder entries from the implementations map (NoInfer keeps lambda contextual typing intact); extractHandlerInput maps zero args to undefined, pairing with input-less signal/query/update definitions - item 24: rename exported qualify -> qualifyFailure (no alias; unshipped beta) - item 25: ValidationError name property non-enumerable; new SignalDefOf / QueryDefOf / UpdateDefOf type helpers replace the triple-repeated conditionals; deduped the update sync-validation message - item 26: README quick start rewritten around createWorker + workflowsPathFromURL (ESM, .js imports, named contract export, conditional cause spread); TSDoc fixes (child-workflow contract-required bullets, .getOrThrow -> .get, declareActivitiesHandler example uses createWorker, cause idiom); worker integration tests migrated to the new TypedClient.create({ client }).for(contract) client surface Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f6882fe commit 2d2f33b

20 files changed

Lines changed: 879 additions & 235 deletions

packages/worker/README.md

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ pnpm add @temporal-contract/worker @temporal-contract/contract @temporalio/workf
1717
import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity";
1818
import { fromPromise } from "unthrown";
1919

20+
import { myContract } from "./contract.js";
21+
2022
export const activities = declareActivitiesHandler({
2123
contract: myContract,
2224
activities: {
@@ -25,15 +27,20 @@ export const activities = declareActivitiesHandler({
2527
ApplicationFailure.create({
2628
type: "EMAIL_FAILED",
2729
message: error instanceof Error ? error.message : "Failed to send email",
28-
cause: error,
30+
// Omit `cause` entirely for non-Error rejections — don't pass undefined.
31+
...(error instanceof Error ? { cause: error } : {}),
2932
}),
3033
).map(() => ({ sent: true })),
3134
},
3235
});
36+
```
3337

38+
```typescript
3439
// workflows.ts
3540
import { declareWorkflow } from "@temporal-contract/worker/workflow";
3641

42+
import { myContract } from "./contract.js";
43+
3744
export const processOrder = declareWorkflow({
3845
workflowName: "processOrder",
3946
contract: myContract,
@@ -44,23 +51,48 @@ export const processOrder = declareWorkflow({
4451
return { success: true };
4552
},
4653
});
54+
```
4755

56+
```typescript
4857
// worker.ts
49-
import { Worker } from "@temporalio/worker";
50-
import { activities } from "./activities";
51-
import myContract from "./contract";
52-
53-
async function run() {
54-
const worker = await Worker.create({
55-
workflowsPath: require.resolve("./workflows"),
56-
activities,
57-
taskQueue: myContract.taskQueue,
58-
});
59-
60-
await worker.run();
58+
import { NativeConnection } from "@temporalio/worker";
59+
import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker";
60+
61+
import { activities } from "./activities.js";
62+
import { myContract } from "./contract.js";
63+
64+
const connection = await NativeConnection.connect({ address: "localhost:7233" });
65+
66+
// The task queue comes from the contract; the workflows path is resolved
67+
// from this module's URL (ESM — include the extension explicitly).
68+
const workerResult = await createWorker({
69+
contract: myContract,
70+
connection,
71+
workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
72+
activities,
73+
});
74+
if (workerResult.isDefect()) {
75+
// Bundling / connection failure — a TechnicalError-caused defect, not thrown.
76+
console.error("worker setup failed", workerResult.cause);
77+
process.exit(1);
6178
}
6279

63-
run().catch(console.error);
80+
await workerResult.value.run();
81+
```
82+
83+
### Workflow-only workers
84+
85+
`activities` is optional on `createWorker`. Omit it to run a worker that only
86+
executes workflows — useful when workflow code and activity code are deployed
87+
and scaled as separate processes on the same task queue:
88+
89+
```typescript
90+
const workerResult = await createWorker({
91+
contract: myContract,
92+
connection,
93+
workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
94+
// no `activities` — this process polls for Workflow Tasks only
95+
});
6496
```
6597

6698
### Child Workflows

packages/worker/src/__tests__/time-skipping.inprocess.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,12 @@ describe("time-skipping TestWorkflowEnvironment", () => {
8080
const worker = workerResult.value;
8181

8282
const clientResult = await TypedClient.create({
83-
contract: inprocessContract,
8483
client: testEnv.client,
8584
interceptors: [recording],
8685
});
8786
expect(clientResult.isOk()).toBe(true);
8887
if (!clientResult.isOk()) return;
89-
const client = clientResult.value;
88+
const client = clientResult.value.for(inprocessContract);
9089

9190
await worker.runUntil(async () => {
9291
// Happy path — the hour-long sleep is skipped, the accumulated

packages/worker/src/__tests__/worker.spec.ts

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { extname } from "node:path";
22
import { fileURLToPath } from "node:url";
33

4-
import { TypedClient, WorkflowValidationError } from "@temporal-contract/client";
4+
import {
5+
TypedClient,
6+
WorkflowValidationError,
7+
type ContractClient,
8+
} from "@temporal-contract/client";
59
import { it as baseIt } from "@temporal-contract/testing/extension";
610
import { Client, WorkflowFailedError } from "@temporalio/client";
711
import { type Worker } from "@temporalio/worker";
@@ -22,7 +26,7 @@ const errAsync = <E>(error: E): AsyncResult<never, E> => Err(error).toAsync();
2226

2327
const it = baseIt.extend<{
2428
worker: Worker;
25-
client: TypedClient<typeof testContract>;
29+
client: ContractClient<typeof testContract>;
2630
}>({
2731
worker: [
2832
async ({ workerConnection }, use) => {
@@ -55,17 +59,14 @@ const it = baseIt.extend<{
5559
{ auto: true },
5660
],
5761
client: async ({ clientConnection }, use) => {
58-
// Create typed client
62+
// Create the connection-scoped root, then bind the contract.
5963
const rawClient = new Client({
6064
connection: clientConnection,
6165
namespace: "default",
6266
});
63-
const clientResult = await TypedClient.create({ contract: testContract, client: rawClient });
64-
if (!clientResult.isOk()) {
65-
throw clientResult.isErr() ? clientResult.error : clientResult.cause;
66-
}
67+
const root = (await TypedClient.create({ client: rawClient })).get();
6768

68-
await use(clientResult.value);
69+
await use(root.for(testContract));
6970
},
7071
});
7172

@@ -78,8 +79,8 @@ const logMessages: string[] = [];
7879
const activities = declareActivitiesHandler({
7980
contract: testContract,
8081
activities: {
81-
simpleWorkflow: {},
82-
82+
// Workflows without declared activities (simpleWorkflow,
83+
// interactiveWorkflow, parentWorkflow, ...) no longer need `{}` entries.
8384
workflowWithActivities: {
8485
processPayment: ({ amount }) => {
8586
return okAsync({
@@ -95,14 +96,6 @@ const activities = declareActivitiesHandler({
9596
},
9697
},
9798

98-
interactiveWorkflow: {},
99-
100-
parentWorkflow: {},
101-
102-
childWorkflow: {},
103-
104-
workflowWithFailableActivity: {},
105-
10699
logMessage: ({ message }) => {
107100
logMessages.push(message);
108101
return okAsync({});
@@ -190,8 +183,8 @@ describe("Worker Package - Integration Tests", () => {
190183
args: input,
191184
});
192185

193-
// WHEN
194-
const handleResult = await client.getHandle("simpleWorkflow", workflowId);
186+
// WHEN — getHandle is synchronous in the new client surface
187+
const handleResult = client.getHandle("simpleWorkflow", workflowId);
195188

196189
// THEN
197190
expect(handleResult).toBeOk();

packages/worker/src/activity-contract-errors.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ describe("declareActivitiesHandler — contract errors", () => {
116116
const activities = declareActivitiesHandler({
117117
contract: transformingContract,
118118
activities: {
119-
noop: {},
120119
flaky: (_args, { errors }) => errAsync(errors.Nope({ reason: "declined" })),
121120
},
122121
});

0 commit comments

Comments
 (0)