@littlebigbrain/create 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ // The records of a CSV or TSV file, as RFC 4180 defines them: fields split by
2
+ // the delimiter, records by a line break (CRLF, LF or CR), and a field in
3
+ // double quotes may hold the delimiter, line breaks and quotes (written `""`).
4
+ // Each record keeps its row number as RFC 7111 counts rows (from 1, the
5
+ // header is row 1) and its text in the file, the quote of its evidence.
6
+ //
7
+ // The parser reads bytes. The delimiter, the quote and the line breaks are
8
+ // ASCII, and no byte of a UTF-8 character other than ASCII is ASCII, so a
9
+ // record boundary is never inside a character. `readRecords` reads a file
10
+ // in windows of bytes (`client.files.content(sha256, { range })`) and keeps a
11
+ // record that a window does not end for the next window.
12
+
13
+ /** One record: its fields, its row from 1, its text without the line break, and where it starts and ends. */
14
+ export interface CsvRecord {
15
+ fields: string[];
16
+ row: number;
17
+ text: string;
18
+ /** The byte offset of the record's first byte in the file. */
19
+ start: number;
20
+ /** The byte offset after the record's line break: where the next record starts. */
21
+ next: number;
22
+ }
23
+
24
+ /** Where the bytes given to `csvRecords` are in the file, and whether the file ends with them. */
25
+ export interface CsvPart {
26
+ /** The byte offset of the first byte given. Default 0. */
27
+ base?: number;
28
+ /**
29
+ * The file ends with these bytes (default). With `false` the file goes on:
30
+ * a record the bytes do not end is not given, nor one that ends in a
31
+ * carriage return or a quote as the last byte (a line feed or a second
32
+ * quote can follow). The next read starts at the `next` of the last
33
+ * record given.
34
+ */
35
+ final?: boolean;
36
+ }
37
+
38
+ const QUOTE = 0x22;
39
+ const CR = 0x0d;
40
+ const LF = 0x0a;
41
+ const decoder = new TextDecoder();
42
+
43
+ /** The delimiter of a file: a tab for TSV (`text/tab-separated-values` or a `.tsv` name), else a comma. */
44
+ export function delimiterOf(mediaType: string, name: string): string {
45
+ return mediaType === "text/tab-separated-values" || /\.tsv$/i.test(name) ? "\t" : ",";
46
+ }
47
+
48
+ /**
49
+ * The records of `input` (bytes, or text as its UTF-8 bytes) from the byte
50
+ * offset `at` of the file, numbered from `row`. A byte order mark at the
51
+ * start of the file is dropped. A blank line is a record of one empty field,
52
+ * so the row numbers stay those of RFC 7111. A quote inside an unquoted
53
+ * field, and text after a closing quote, are kept as they are. A line break
54
+ * at the end of the file ends the last record. `part` says where the bytes
55
+ * are in the file and whether the file ends with them.
56
+ */
57
+ export function* csvRecords(
58
+ input: string | Uint8Array,
59
+ delimiter = ",",
60
+ at = 0,
61
+ row = 1,
62
+ part: CsvPart = {},
63
+ ): Generator<CsvRecord, void, undefined> {
64
+ const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
65
+ const base = part.base ?? 0;
66
+ const final = part.final ?? true;
67
+ const split = delimiter.charCodeAt(0);
68
+ const n = bytes.length;
69
+ const text = (from: number, to: number) => decoder.decode(bytes.subarray(from, to));
70
+ let i = at - base;
71
+ if (at === 0 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) i = 3;
72
+ while (i < n) {
73
+ const start = i;
74
+ const fields: string[] = [];
75
+ let field = "";
76
+ let from = i;
77
+ let quoted = false;
78
+ let atFieldStart = true;
79
+ let end = -1;
80
+ let next = -1;
81
+ while (i < n) {
82
+ const c = bytes[i]!;
83
+ if (quoted) {
84
+ if (c === QUOTE) {
85
+ if (i + 1 >= n && !final) return;
86
+ if (bytes[i + 1] === QUOTE) {
87
+ field += `${text(from, i)}"`;
88
+ i += 2;
89
+ } else {
90
+ field += text(from, i);
91
+ quoted = false;
92
+ i += 1;
93
+ }
94
+ from = i;
95
+ } else i += 1;
96
+ continue;
97
+ }
98
+ if (c === QUOTE && atFieldStart) {
99
+ quoted = true;
100
+ atFieldStart = false;
101
+ i += 1;
102
+ from = i;
103
+ continue;
104
+ }
105
+ if (c === split) {
106
+ fields.push(field + text(from, i));
107
+ field = "";
108
+ atFieldStart = true;
109
+ i += 1;
110
+ from = i;
111
+ continue;
112
+ }
113
+ if (c === CR || c === LF) {
114
+ if (c === CR && i + 1 >= n && !final) return;
115
+ end = i;
116
+ next = c === CR && bytes[i + 1] === LF ? i + 2 : i + 1;
117
+ break;
118
+ }
119
+ atFieldStart = false;
120
+ i += 1;
121
+ }
122
+ if (end < 0) {
123
+ if (!final) return;
124
+ end = n;
125
+ next = n;
126
+ }
127
+ fields.push(field + text(from, end));
128
+ yield { fields, row, text: text(start, end), start: base + start, next: base + next };
129
+ row += 1;
130
+ i = next;
131
+ }
132
+ }
133
+
134
+ /** Bytes `start` to `end` of a file, both included: fewer at its end, none from its end on. */
135
+ export type ReadRange = (start: number, end: number) => Promise<Uint8Array>;
136
+
137
+ /** Bytes a read holds that its records did not take: they start at `base`. */
138
+ export interface HeldBytes {
139
+ base: number;
140
+ bytes: Uint8Array;
141
+ /** The file ends with these bytes. */
142
+ eof: boolean;
143
+ }
144
+
145
+ /** What `readRecords` read. */
146
+ export interface RecordsRead {
147
+ records: CsvRecord[];
148
+ /** The byte offset after the last record: where the next read starts. */
149
+ next: number;
150
+ /** The row number of the record at `next`. */
151
+ row: number;
152
+ /** No byte of the file is left from `next` on. */
153
+ end: boolean;
154
+ /** The bytes read from the file. */
155
+ bytesRead: number;
156
+ /** The bytes read from `next` on: a later read of the same turn starts with them. */
157
+ held: HeldBytes;
158
+ }
159
+
160
+ /** How `readRecords` reads. */
161
+ export interface WindowOptions {
162
+ /** Bytes one read asks for. */
163
+ window: number;
164
+ /** The longest record: a record that is not complete after this many bytes stops the read with an error. */
165
+ maxRecord: number;
166
+ }
167
+
168
+ /** Bytes one read asks for by default: 4 MiB. */
169
+ export const WINDOW_BYTES = 4 * 1024 * 1024;
170
+ /** The longest record by default: 16 MiB, as a quote that never closes makes the rest of a file one record. */
171
+ export const MAX_RECORD_BYTES = 16 * 1024 * 1024;
172
+
173
+ function joined(a: Uint8Array, b: Uint8Array): Uint8Array {
174
+ if (!a.length) return b;
175
+ if (!b.length) return a;
176
+ const out = new Uint8Array(a.length + b.length);
177
+ out.set(a);
178
+ out.set(b, a.length);
179
+ return out;
180
+ }
181
+
182
+ /**
183
+ * At most `limit` complete records of a file from the byte offset `at`,
184
+ * numbered from `row`, read with `read` in windows of `options.window`
185
+ * bytes. A record that a window does not end is read on in the next
186
+ * window, so a quoted field can span windows and row numbers stay exact.
187
+ * `held` gives bytes an earlier read of the same turn holds from `at` on,
188
+ * which are not read again. Memory stays within the window and the longest
189
+ * record.
190
+ */
191
+ export async function readRecords(
192
+ read: ReadRange,
193
+ delimiter: string,
194
+ at: number,
195
+ row: number,
196
+ limit: number,
197
+ options: WindowOptions,
198
+ held?: HeldBytes,
199
+ ): Promise<RecordsRead> {
200
+ const records: CsvRecord[] = [];
201
+ let base = at;
202
+ let buffer: Uint8Array = new Uint8Array(0);
203
+ let eof = false;
204
+ let bytesRead = 0;
205
+ if (held && held.base <= at && at <= held.base + held.bytes.length) {
206
+ buffer = held.bytes.subarray(at - held.base);
207
+ eof = held.eof;
208
+ }
209
+ let first = true;
210
+ while (records.length < limit) {
211
+ if (!eof && !(first && buffer.length)) {
212
+ const from = base + buffer.length;
213
+ const chunk = await read(from, from + options.window - 1);
214
+ bytesRead += chunk.length;
215
+ eof = chunk.length < options.window;
216
+ buffer = joined(buffer, chunk);
217
+ }
218
+ first = false;
219
+ let taken = base;
220
+ for (const record of csvRecords(buffer, delimiter, base, row, { base, final: eof })) {
221
+ records.push(record);
222
+ taken = record.next;
223
+ row = record.row + 1;
224
+ if (records.length >= limit) break;
225
+ }
226
+ buffer = buffer.subarray(taken - base);
227
+ base = taken;
228
+ if (eof) break;
229
+ if (buffer.length > options.maxRecord)
230
+ throw new RangeError(
231
+ `row ${row} is longer than ${options.maxRecord} bytes from byte ${base}: a quote that never closes?`,
232
+ );
233
+ }
234
+ return {
235
+ records,
236
+ next: base,
237
+ row,
238
+ end: eof && !buffer.length,
239
+ bytesRead,
240
+ held: { base, bytes: buffer, eof },
241
+ };
242
+ }
243
+
244
+ /** A record of empty fields only: a blank line, or a row of delimiters. */
245
+ export function isBlank(record: Pick<CsvRecord, "fields">): boolean {
246
+ return record.fields.every((field) => !field.trim());
247
+ }
248
+
249
+ /**
250
+ * A header or property name for matching: letters and digits of any script,
251
+ * in lower case, without marks and without spaces or punctuation.
252
+ * `Customer ID`, `customer_id` and `CustomerId` are all `customerid`.
253
+ */
254
+ export function normalizeName(name: string): string {
255
+ return name
256
+ .normalize("NFKD")
257
+ .replace(/\p{M}/gu, "")
258
+ .toLowerCase()
259
+ .replace(/[^\p{L}\p{N}]+/gu, "");
260
+ }
@@ -0,0 +1,150 @@
1
+ {
2
+ "q3-customers.csv": {
3
+ "settings": { "className": "Customer", "key": "Customer ID", "represents": { "class": "Report" } },
4
+ "rows": 8,
5
+ "rows_without_key": 1,
6
+ "values_left_out": 1,
7
+ "columns": [
8
+ "Customer ID → customer_id",
9
+ "Email → email",
10
+ "Address → address",
11
+ "City → city",
12
+ "Customer since → customer_since"
13
+ ],
14
+ "columns_left_out": ["Name", "Account manager"],
15
+ "represents": "Q3 customers",
16
+ "instances": [
17
+ {
18
+ "key": "C-1001",
19
+ "name": "Harbor Lane Bakery",
20
+ "row": 2,
21
+ "properties": {
22
+ "customer_id": "C-1001",
23
+ "email": "orders@harborlane.example",
24
+ "address": "12 Quay Street",
25
+ "city": "Lisbon",
26
+ "customer_since": "2021-03-14T00:00:00Z"
27
+ }
28
+ },
29
+ {
30
+ "key": "C-1002",
31
+ "name": "Okafor, Ada",
32
+ "row": 3,
33
+ "properties": {
34
+ "customer_id": "C-1002",
35
+ "email": "ada.okafor@mail.example",
36
+ "address": "4 Palm Avenue",
37
+ "city": "Lagos",
38
+ "customer_since": "2019-11-02T00:00:00Z"
39
+ }
40
+ },
41
+ {
42
+ "key": "C-1003",
43
+ "name": "Studio \"Nord\"",
44
+ "row": 4,
45
+ "properties": {
46
+ "customer_id": "C-1003",
47
+ "email": "hello@studionord.example",
48
+ "address": "Strandgata 9",
49
+ "city": "Oslo",
50
+ "customer_since": "2022-06-30T00:00:00Z"
51
+ }
52
+ },
53
+ {
54
+ "key": "C-1004",
55
+ "name": "Meridian Tutoring",
56
+ "row": 5,
57
+ "why": "The address holds a line break inside its quotes, so the row spans two lines. 'not a date' does not take the type of customer_since and is left out.",
58
+ "properties": {
59
+ "customer_id": "C-1004",
60
+ "email": "info@meridian.example",
61
+ "address": "Rua das Flores 21\nFloor 2",
62
+ "city": "Porto"
63
+ }
64
+ },
65
+ {
66
+ "key": "C-1005",
67
+ "name": "Lindqvist Maja",
68
+ "row": 6,
69
+ "properties": {
70
+ "customer_id": "C-1005",
71
+ "email": "maja@lindqvist.example",
72
+ "address": "Kungsgatan 3",
73
+ "city": "Uppsala",
74
+ "customer_since": "2020-01-20T00:00:00Z"
75
+ }
76
+ },
77
+ {
78
+ "key": "C-1006",
79
+ "name": "Calder Repair Shop",
80
+ "row": 8,
81
+ "why": "Row 7 is blank. The email cell is empty, so the instance has no email.",
82
+ "properties": {
83
+ "customer_id": "C-1006",
84
+ "address": "1 Mill Lane",
85
+ "city": "Leeds",
86
+ "customer_since": "2023-09-05T00:00:00Z"
87
+ }
88
+ }
89
+ ],
90
+ "leave_out": [{ "row": 9, "why": "The row has no Customer ID: no instance." }]
91
+ },
92
+ "q3-orders.tsv": {
93
+ "settings": { "className": "Order", "key": "Order ID", "represents": { "class": "Report" } },
94
+ "rows": 4,
95
+ "rows_without_key": 0,
96
+ "values_left_out": 1,
97
+ "represents": "Q3 orders",
98
+ "instances": [
99
+ {
100
+ "key": "O-5001",
101
+ "name": "O-5001",
102
+ "row": 2,
103
+ "properties": {
104
+ "order_id": "O-5001",
105
+ "customer_id": "C-1001",
106
+ "placed_on": "2026-07-03T00:00:00Z",
107
+ "total": 182.4,
108
+ "status": "paid"
109
+ }
110
+ },
111
+ {
112
+ "key": "O-5002",
113
+ "name": "O-5002",
114
+ "row": 3,
115
+ "why": "1250,50 has a decimal comma; the status is quoted.",
116
+ "properties": {
117
+ "order_id": "O-5002",
118
+ "customer_id": "C-1002",
119
+ "placed_on": "2026-07-19T00:00:00Z",
120
+ "total": 1250.5,
121
+ "status": "on hold"
122
+ }
123
+ },
124
+ {
125
+ "key": "O-5003",
126
+ "name": "O-5003",
127
+ "row": 4,
128
+ "properties": {
129
+ "order_id": "O-5003",
130
+ "customer_id": "C-1003",
131
+ "placed_on": "2026-08-02T00:00:00Z",
132
+ "total": 64,
133
+ "status": "paid"
134
+ }
135
+ },
136
+ {
137
+ "key": "O-5004",
138
+ "name": "O-5004",
139
+ "row": 5,
140
+ "why": "n/a does not take the type of total and is left out.",
141
+ "properties": {
142
+ "order_id": "O-5004",
143
+ "customer_id": "C-1001",
144
+ "placed_on": "2026-09-28T00:00:00Z",
145
+ "status": "cancelled"
146
+ }
147
+ }
148
+ ]
149
+ }
150
+ }
@@ -0,0 +1,11 @@
1
+ [
2
+ { "name": "customer_id", "value_type": "keyword" },
3
+ { "name": "email", "value_type": "keyword" },
4
+ { "name": "address", "value_type": "text" },
5
+ { "name": "city", "value_type": "keyword" },
6
+ { "name": "customer_since", "value_type": "date_time" },
7
+ { "name": "order_id", "value_type": "keyword" },
8
+ { "name": "placed_on", "value_type": "date_time" },
9
+ { "name": "total", "value_type": "f64" },
10
+ { "name": "status", "value_type": "keyword" }
11
+ ]
@@ -0,0 +1,10 @@
1
+ Customer ID,Name,Email,Address,City,Customer since,Account manager
2
+ C-1001,Harbor Lane Bakery,orders@harborlane.example,12 Quay Street,Lisbon,2021-03-14,R. Silva
3
+ C-1002,"Okafor, Ada",ada.okafor@mail.example,4 Palm Avenue,Lagos,2019-11-02,T. Bello
4
+ C-1003,"Studio ""Nord""",hello@studionord.example,Strandgata 9,Oslo,2022-06-30,K. Berg
5
+ C-1004,Meridian Tutoring,info@meridian.example,"Rua das Flores 21
6
+ Floor 2",Porto,not a date,R. Silva
7
+ C-1005,Lindqvist Maja,maja@lindqvist.example,Kungsgatan 3,Uppsala,2020-01-20,K. Berg
8
+
9
+ C-1006,Calder Repair Shop,,1 Mill Lane,Leeds,2023-09-05,T. Bello
10
+ ,Walk-in customer,walkin@shop.example,,Leeds,2024-02-11,
@@ -0,0 +1,5 @@
1
+ Order ID Customer ID Placed on Total Status
2
+ O-5001 C-1001 2026-07-03 182.40 paid
3
+ O-5002 C-1002 2026-07-19 1250,50 "on hold"
4
+ O-5003 C-1003 2026-08-02 64 paid
5
+ O-5004 C-1001 2026-09-28 n/a cancelled
@@ -0,0 +1,5 @@
1
+ [
2
+ { "name": "Q3 customers", "text": "The customer list at the end of the third quarter." },
3
+ { "name": "Q3 orders", "text": "The orders of the third quarter." },
4
+ { "name": "Q2 customers", "text": "The customer list at the end of the second quarter." }
5
+ ]
@@ -0,0 +1,56 @@
1
+ // The samples of rows, for `npm run try`: an invented customer list
2
+ // (samples/q3-customers.csv) and order table (samples/q3-orders.tsv), the
3
+ // properties their columns fill (samples/properties.json), and three
4
+ // reports (samples/reports.json) that the files' names can name.
5
+ import { readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import type { LbbClient, Schemas } from "@littlebigbrain/client";
8
+ import { defaults } from "./workflow.js";
9
+
10
+ /** The sample files and their media types. */
11
+ export const SAMPLE_FILES = [
12
+ { name: "q3-customers.csv", contentType: "text/csv" },
13
+ { name: "q3-orders.tsv", contentType: "text/tab-separated-values" },
14
+ ];
15
+
16
+ /** Add the properties and the reports, then upload the sample files to the client's graph. */
17
+ export async function loadSamples(client: LbbClient, dir: string): Promise<void> {
18
+ const properties = JSON.parse(await readFile(join(dir, "properties.json"), "utf8")) as {
19
+ name: string;
20
+ value_type: string;
21
+ }[];
22
+ const reports = JSON.parse(await readFile(join(dir, "reports.json"), "utf8")) as {
23
+ name: string;
24
+ text: string;
25
+ }[];
26
+ const reportClass = defaults.represents?.class ?? "Report";
27
+ const ops: Schemas["OntologyEvolveOp"][] = [
28
+ { op: "add_entity_type", name: reportClass },
29
+ { op: "add_property", name: "text", value_type: "text" },
30
+ ...properties.map((p) => ({
31
+ op: "add_property" as const,
32
+ name: p.name,
33
+ value_type: p.value_type,
34
+ })),
35
+ ];
36
+ await client.ontology.evolve({ ops });
37
+ await client.commit(
38
+ {
39
+ entity_properties: reports.map((report) => ({
40
+ type: reportClass,
41
+ name: report.name,
42
+ properties: { text: report.text },
43
+ })),
44
+ },
45
+ { idempotencyKey: "rows-samples" },
46
+ );
47
+ console.log(`Committed ${reports.length} ${reportClass} records`);
48
+ for (const file of SAMPLE_FILES) {
49
+ const bytes = await readFile(join(dir, file.name));
50
+ const uploaded = await client.files.upload(new Uint8Array(bytes), {
51
+ name: file.name,
52
+ contentType: file.contentType,
53
+ });
54
+ console.log(`Uploaded ${file.name} (${uploaded.file.sha256.slice(0, 12)})`);
55
+ }
56
+ }
@@ -0,0 +1,18 @@
1
+ // Create the workflow on your graph: add the class and the relation it
2
+ // writes, then create the named workflow whose content trigger sends it each
3
+ // CSV and TSV upload. Each call is safe to repeat.
4
+ //
5
+ // LBB_URL=... LBB_API_KEY=... LBB_GRAPH=... npm run setup
6
+ import { LbbClient, lbbActivities } from "@littlebigbrain/client";
7
+ import { ontology, trigger } from "./workflow.js";
8
+
9
+ const graph = process.env.LBB_GRAPH ?? "main";
10
+ const client = new LbbClient({
11
+ baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
12
+ apiKey: process.env.LBB_API_KEY,
13
+ graph,
14
+ });
15
+ await lbbActivities(client).evolve(ontology());
16
+ const created = await client.workflows.named.put(trigger());
17
+ console.log(`Created ${created.name} on the graph ${graph}.`);
18
+ console.log(`Serve it with LBB_GRAPH=${graph} npm start.`);
@@ -0,0 +1,32 @@
1
+ // Try the workflow on its samples: create a graph (default try-rows), add
2
+ // the sample properties and reports, upload the sample files, and create the
3
+ // workflow there. Then serve it with npm start and the same LBB_GRAPH: the
4
+ // content trigger reads the files uploaded before it too.
5
+ //
6
+ // LBB_URL=... LBB_API_KEY=... LBB_GRAPH=try-rows npm run try
7
+ import { fileURLToPath } from "node:url";
8
+ import { LbbClient, LbbError, lbbActivities } from "@littlebigbrain/client";
9
+ import { loadSamples } from "./samples.js";
10
+ import { ontology, trigger } from "./workflow.js";
11
+
12
+ const graph = process.env.LBB_GRAPH ?? "try-rows";
13
+ const client = new LbbClient({
14
+ baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
15
+ apiKey: process.env.LBB_API_KEY,
16
+ graph,
17
+ });
18
+ try {
19
+ await client.graph(graph).create();
20
+ console.log(`Created the graph ${graph}`);
21
+ } catch (error) {
22
+ // 409: the graph exists already.
23
+ if (!(error instanceof LbbError && error.status === 409)) throw error;
24
+ }
25
+ await lbbActivities(client).evolve(ontology());
26
+ await loadSamples(client, fileURLToPath(new URL("../samples/", import.meta.url)));
27
+ const created = await client.workflows.named.put(trigger());
28
+ console.log(`Created ${created.name} on the graph ${graph}.`);
29
+ console.log(`Serve it with LBB_GRAPH=${graph} npm start, and follow it with:`);
30
+ console.log(
31
+ ` curl "$LBB_URL/v1/workflows/named/queue?graph=${graph}&name=${created.name}" -H "Authorization: Bearer $LBB_API_KEY"`,
32
+ );
@@ -0,0 +1,24 @@
1
+ // Serve the workflow: the worker claims the turns of its content trigger's
2
+ // messages and runs them, one at a time. Run more workers for more at once.
3
+ //
4
+ // LBB_URL=... LBB_API_KEY=... LBB_GRAPH=... npm start
5
+ import { LbbClient, WorkflowWorker, lbbActivities } from "@littlebigbrain/client";
6
+ import { createWorkflow } from "./workflow.js";
7
+
8
+ const graph = process.env.LBB_GRAPH ?? "main";
9
+ const client = new LbbClient({
10
+ baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
11
+ apiKey: process.env.LBB_API_KEY,
12
+ graph,
13
+ });
14
+ // The client reads the files' bytes and the derivations.
15
+ const workflow = createWorkflow(lbbActivities(client), client);
16
+ const stop = new AbortController();
17
+ process.once("SIGINT", () => stop.abort());
18
+ process.once("SIGTERM", () => stop.abort());
19
+ console.log(
20
+ `Serving ${workflow.name}@${workflow.version} for the graph ${graph}. Stop with Ctrl-C.`,
21
+ );
22
+ await new WorkflowWorker(client, [workflow], {
23
+ worker: process.env.LBB_WORKER_ID ?? `${workflow.name}-1`,
24
+ }).run({ signal: stop.signal });