Skip to content

Commit 4cd1d72

Browse files
authored
feat(workflows): finalize public package integration (#6)
* ci(release): migrate npm publishing to oidc * ci(release): verify npm trusted publishing * feat: redesign workflow actor integration * ci(release): force npm oidc publishing * test: pin verified rivetkit preview
1 parent a8415b7 commit 4cd1d72

18 files changed

Lines changed: 722 additions & 256 deletions

.github/workflows/publish.yml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,20 @@ jobs:
3434
- uses: actions/setup-node@v4
3535
with:
3636
node-version: 24
37-
cache: pnpm
38-
cache-dependency-path: pnpm-lock.yaml
3937
registry-url: https://registry.npmjs.org
38+
- name: Install OIDC-capable npm
39+
run: npm install --global npm@11.16.0
4040
- run: pnpm install --frozen-lockfile
4141
- id: release
4242
run: >-
4343
node scripts/resolve-release.mjs
4444
--version=${{ inputs.version }}
4545
--tag=${{ inputs.dist_tag }}
4646
--branch=${{ github.ref_name }}
47-
- name: Verify npm authentication and unused version
48-
env:
49-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
47+
- name: Verify npm registry and unused version
5048
run: |
5149
set -euo pipefail
52-
if [ -n "${NODE_AUTH_TOKEN:-}" ]; then npm whoami; else npm ping; fi
50+
npm ping
5351
if npm view "@rivet-dev/workflows@${{ steps.release.outputs.version }}" version >/dev/null 2>&1; then
5452
echo "@rivet-dev/workflows@${{ steps.release.outputs.version }} already exists" >&2
5553
exit 1
@@ -63,7 +61,8 @@ jobs:
6361
npm pack ./packages/workflows --silent --pack-destination .pack
6462
- name: Publish
6563
env:
66-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
64+
# setup-node provides a dummy token; clear it so npm uses OIDC.
65+
NODE_AUTH_TOKEN: ""
6766
run: >-
6867
npm publish
6968
.pack/rivet-dev-workflows-${{ steps.release.outputs.version }}.tgz

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@ pnpm add @rivet-dev/workflows rivetkit
99
```
1010

1111
```ts
12-
import { actor } from "rivetkit";
1312
import { workflow } from "@rivet-dev/workflows";
1413

15-
export const report = actor({
16-
run: workflow(async (ctx) => {
14+
export const report = workflow({
15+
run: async (ctx) => {
1716
await ctx.step("generate", async (step) => {
1817
step.log.info("generating report");
1918
});
20-
}),
19+
},
2120
});
2221
```
2322

packages/workflows/README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@ Durable, replayable workflows for Rivet Actors.
55
[Documentation](https://rivet.dev/workflows/docs)
66

77
```ts
8-
import { actor } from "rivetkit";
98
import { workflow } from "@rivet-dev/workflows";
109

11-
export const example = actor({
12-
run: workflow(async (ctx) => {
10+
export const example = workflow({
11+
run: async (ctx) => {
1312
await ctx.step("hello", async () => "world");
14-
}),
13+
},
1514
});
1615
```
1716

packages/workflows/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
"version": "2.3.10",
44
"description": "Durable, replayable workflows for Rivet Actors",
55
"license": "Apache-2.0",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/rivet-dev/workflows.git",
9+
"directory": "packages/workflows"
10+
},
611
"keywords": [
712
"rivet",
813
"workflow",
@@ -67,7 +72,7 @@
6772
"commander": "^12.0.0",
6873
"legacy-rivetkit": "npm:rivetkit@2.3.7",
6974
"legacy-workflow-engine": "npm:@rivetkit/workflow-engine@2.3.7",
70-
"rivetkit": "0.0.0-feat-workflows-public-host-apis.0ff6164",
75+
"rivetkit": "0.0.0-feat-workflows-public-host-apis.1550fe4",
7176
"tsup": "^8.4.0",
7277
"tsx": "^4.7.0",
7378
"typescript": "^5.7.3",

packages/workflows/src/rivetkit/driver.ts

Lines changed: 178 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import type { ActorQueue, ActorRun, RunContext } from "rivetkit";
2-
import {
3-
WORKFLOW_STORAGE_V1,
4-
type WorkflowStorageHandle,
5-
} from "rivetkit/storage";
2+
import type { RawAccess } from "rivetkit/db";
63
import type {
74
EngineDriver,
85
KVEntry,
@@ -12,6 +9,14 @@ import type {
129
WorkflowMessageIdentity,
1310
} from "../index.js";
1411

12+
const WORKFLOW_STORAGE_PREFIX = new Uint8Array([6, 1]);
13+
const WORKFLOW_UPSERT_SQL =
14+
"INSERT INTO _rivet_wf_kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value";
15+
16+
const WORKFLOW_SQLITE_MAX_VALUE_BYTES = 256 * 1024;
17+
const WORKFLOW_SQLITE_MAX_BATCH_ROWS = 128;
18+
const WORKFLOW_SQLITE_MAX_BATCH_BYTES = 512 * 1024;
19+
1520
function track<T>(
1621
runCtx: RunContext<any, any, any, any, any, any, any, any>,
1722
promise: Promise<T>,
@@ -25,6 +30,169 @@ function track<T>(
2530
return promise;
2631
}
2732

33+
function prefixWorkflowKey(key: Uint8Array): Uint8Array {
34+
const prefixed = new Uint8Array(
35+
WORKFLOW_STORAGE_PREFIX.byteLength + key.byteLength,
36+
);
37+
prefixed.set(WORKFLOW_STORAGE_PREFIX);
38+
prefixed.set(key, WORKFLOW_STORAGE_PREFIX.byteLength);
39+
return prefixed;
40+
}
41+
42+
function stripWorkflowKey(key: Uint8Array): Uint8Array {
43+
if (
44+
key.byteLength < WORKFLOW_STORAGE_PREFIX.byteLength ||
45+
!WORKFLOW_STORAGE_PREFIX.every((byte, index) => key[index] === byte)
46+
) {
47+
throw new Error("workflow SQLite key escaped the [6, 1] namespace");
48+
}
49+
return key.slice(WORKFLOW_STORAGE_PREFIX.byteLength);
50+
}
51+
52+
function computeUpperBound(prefix: Uint8Array): Uint8Array {
53+
const upperBound = prefix.slice();
54+
for (let index = upperBound.length - 1; index >= 0; index--) {
55+
if (upperBound[index] !== 0xff) {
56+
upperBound[index]++;
57+
return upperBound.slice(0, index + 1);
58+
}
59+
}
60+
61+
// Every workflow key begins with 6, so a finite upper bound always exists.
62+
throw new Error("workflow storage prefix has no upper bound");
63+
}
64+
65+
function normalizeSqlBlob(value: unknown): Uint8Array {
66+
if (value instanceof Uint8Array) {
67+
return value;
68+
}
69+
if (value instanceof ArrayBuffer) {
70+
return new Uint8Array(value);
71+
}
72+
if (ArrayBuffer.isView(value)) {
73+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
74+
}
75+
if (Array.isArray(value)) {
76+
const bytes = new Uint8Array(value.length);
77+
for (const [index, byte] of value.entries()) {
78+
if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
79+
throw new Error("workflow SQLite value was not a byte array");
80+
}
81+
bytes[index] = byte;
82+
}
83+
return bytes;
84+
}
85+
throw new Error("workflow SQLite value was not a blob");
86+
}
87+
88+
function validateWrites(writes: KVWrite[]): void {
89+
if (writes.length > WORKFLOW_SQLITE_MAX_BATCH_ROWS) {
90+
throw new Error(
91+
`Workflow batch contains ${writes.length} rows, exceeding the ${WORKFLOW_SQLITE_MAX_BATCH_ROWS} row limit`,
92+
);
93+
}
94+
95+
let batchBytes = 0;
96+
for (const write of writes) {
97+
if (write.value.byteLength > WORKFLOW_SQLITE_MAX_VALUE_BYTES) {
98+
throw new Error(
99+
`Workflow value is ${write.value.byteLength} bytes, exceeding the ${WORKFLOW_SQLITE_MAX_VALUE_BYTES} byte limit`,
100+
);
101+
}
102+
batchBytes +=
103+
WORKFLOW_STORAGE_PREFIX.byteLength +
104+
write.key.byteLength +
105+
write.value.byteLength;
106+
}
107+
108+
if (batchBytes > WORKFLOW_SQLITE_MAX_BATCH_BYTES) {
109+
throw new Error(
110+
`Workflow batch is ${batchBytes} bytes, exceeding the ${WORKFLOW_SQLITE_MAX_BATCH_BYTES} byte limit`,
111+
);
112+
}
113+
}
114+
115+
class WorkflowStorage {
116+
#db: RawAccess;
117+
118+
constructor(db: RawAccess) {
119+
this.#db = db;
120+
}
121+
122+
async get(key: Uint8Array): Promise<Uint8Array | null> {
123+
const rows = await this.#db.execute<{ value: unknown }>(
124+
"SELECT value FROM _rivet_wf_kv WHERE key = ?",
125+
prefixWorkflowKey(key),
126+
);
127+
const value = rows[0]?.value;
128+
return value == null ? null : normalizeSqlBlob(value);
129+
}
130+
131+
async set(key: Uint8Array, value: Uint8Array): Promise<void> {
132+
await this.batch([{ key, value }], false);
133+
}
134+
135+
async delete(key: Uint8Array): Promise<void> {
136+
await this.#db.execute(
137+
"DELETE FROM _rivet_wf_kv WHERE key = ?",
138+
prefixWorkflowKey(key),
139+
);
140+
}
141+
142+
async deletePrefix(prefix: Uint8Array): Promise<void> {
143+
const start = prefixWorkflowKey(prefix);
144+
await this.#db.execute(
145+
"DELETE FROM _rivet_wf_kv WHERE key >= ? AND key < ?",
146+
start,
147+
computeUpperBound(start),
148+
);
149+
}
150+
151+
async deleteRange(start: Uint8Array, end: Uint8Array): Promise<void> {
152+
await this.#db.execute(
153+
"DELETE FROM _rivet_wf_kv WHERE key >= ? AND key < ?",
154+
prefixWorkflowKey(start),
155+
prefixWorkflowKey(end),
156+
);
157+
}
158+
159+
async list(prefix: Uint8Array): Promise<KVEntry[]> {
160+
const start = prefixWorkflowKey(prefix);
161+
const rows = await this.#db.execute<{ key: unknown; value: unknown }>(
162+
"SELECT key, value FROM _rivet_wf_kv WHERE key >= ? AND key < ? ORDER BY key ASC",
163+
start,
164+
computeUpperBound(start),
165+
);
166+
return rows.map((row) => ({
167+
key: stripWorkflowKey(normalizeSqlBlob(row.key)),
168+
value: normalizeSqlBlob(row.value),
169+
}));
170+
}
171+
172+
async batch(writes: KVWrite[], includeState: boolean): Promise<void> {
173+
if (writes.length === 0) return;
174+
validateWrites(writes);
175+
176+
const commit = async (tx: RawAccess) => {
177+
for (const write of writes) {
178+
await tx.execute(
179+
WORKFLOW_UPSERT_SQL,
180+
prefixWorkflowKey(write.key),
181+
write.value,
182+
);
183+
}
184+
};
185+
186+
if (includeState) {
187+
await this.#db.transaction(commit, {
188+
experimental: { includeState: true },
189+
});
190+
} else {
191+
await this.#db.transaction(commit);
192+
}
193+
}
194+
}
195+
28196
class ActorWorkflowMessageDriver implements WorkflowMessageDriver {
29197
#runCtx: RunContext<any, any, any, any, any, any, any, any>;
30198
#queue: ActorQueue;
@@ -95,15 +263,15 @@ export class ActorWorkflowDriver implements EngineDriver {
95263
readonly workerPollInterval = 100;
96264
readonly messageDriver: WorkflowMessageDriver;
97265
#runCtx: RunContext<any, any, any, any, any, any, any, any>;
98-
#storage: WorkflowStorageHandle;
266+
#storage: WorkflowStorage;
99267
#queue: ActorQueue;
100268
#run: ActorRun;
101269

102270
constructor(runCtx: RunContext<any, any, any, any, any, any, any, any>) {
103271
this.#runCtx = runCtx;
104272
this.messageDriver = new ActorWorkflowMessageDriver(runCtx);
105273
this.#queue = runCtx.queue;
106-
this.#storage = runCtx.storage.open(WORKFLOW_STORAGE_V1);
274+
this.#storage = new WorkflowStorage(runCtx.db);
107275
this.#run = runCtx.run;
108276
}
109277

@@ -132,9 +300,7 @@ export class ActorWorkflowDriver implements EngineDriver {
132300
}
133301

134302
async batch(writes: KVWrite[]): Promise<void> {
135-
if (writes.length === 0) return;
136-
137-
await track(this.#runCtx, this.#storage.flushWithState(writes));
303+
await track(this.#runCtx, this.#storage.batch(writes, true));
138304
}
139305

140306
async setAlarm(_workflowId: string, wakeAt: number): Promise<void> {
@@ -184,11 +350,11 @@ export class ActorWorkflowControlDriver implements EngineDriver {
184350
readonly workerPollInterval = 100;
185351
readonly messageDriver: WorkflowMessageDriver =
186352
new NoopWorkflowMessageDriver();
187-
#storage: WorkflowStorageHandle;
353+
#storage: WorkflowStorage;
188354
#run: ActorRun;
189355

190356
constructor(runCtx: RunContext<any, any, any, any, any, any, any, any>) {
191-
this.#storage = runCtx.storage.open(WORKFLOW_STORAGE_V1);
357+
this.#storage = new WorkflowStorage(runCtx.db);
192358
this.#run = runCtx.run;
193359
}
194360

@@ -217,11 +383,7 @@ export class ActorWorkflowControlDriver implements EngineDriver {
217383
}
218384

219385
async batch(writes: KVWrite[]): Promise<void> {
220-
if (writes.length === 0) {
221-
return;
222-
}
223-
224-
await this.#storage.batch(writes);
386+
await this.#storage.batch(writes, false);
225387
}
226388

227389
async setAlarm(_workflowId: string, wakeAt: number): Promise<void> {

packages/workflows/src/rivetkit/inspector.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import * as transport from "rivetkit/inspector/workflow";
1+
import * as transport from "rivetkit/experimental/inspector/workflow";
22
import {
33
encodeWorkflowHistoryTransport,
44
encodeWorkflowInspectorValue,
55
type WorkflowInspectorAdapter,
6-
} from "rivetkit/inspector/workflow";
6+
} from "rivetkit/experimental/inspector/workflow";
77
import type {
88
BranchStatus,
99
BranchStatusType,

0 commit comments

Comments
 (0)