octaflow 0.37.0 → 0.38.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.
@@ -189,7 +189,7 @@ function checkSchemaSubset(schema, path) {
189
189
  return issues;
190
190
  }
191
191
  for (const keyword of Object.keys(s)) {
192
- if (keyword === "type" || ANNOTATIONS.has(keyword) || BY_TYPE[type].has(keyword)) continue;
192
+ if (keyword === "type" || ANNOTATIONS.has(keyword) || keyword.startsWith("x-") || BY_TYPE[type].has(keyword)) continue;
193
193
  at(`${path}.${keyword}`, `"${keyword}" is not part of the schema subset for type ${type}`);
194
194
  }
195
195
  if (type === "string" && s.format !== void 0 && (typeof s.format !== "string" || !FORMATS.has(s.format))) at(`${path}.format`, `format must be one of ${[...FORMATS].join(", ")}`);
@@ -1586,8 +1586,27 @@ const humanApproveKind = defineKind({
1586
1586
  subject: z.unknown(),
1587
1587
  instructions: z.string().optional(),
1588
1588
  /** What the person may decide; `approve` among them. Default approve and reject — add `revise`, or `expired` for a timeout's answer. */
1589
- decisions: z.array(z.string().min(1)).min(2).refine((d) => d.includes("approve"), "the decisions include \"approve\"").optional()
1589
+ decisions: z.array(z.string().min(1)).min(2).refine((d) => d.includes("approve"), "the decisions include \"approve\"").optional(),
1590
+ /**
1591
+ * Approvals of the same run this one must come from someone else than — four eyes. The compiler holds
1592
+ * each to a step upstream that says who answered it (`output.by`, which an approval does); the host
1593
+ * holds an answer to it, since only it knows who answers.
1594
+ */
1595
+ distinctFrom: z.array(z.string().min(1)).optional()
1590
1596
  },
1597
+ check: ({ params, checkExpr }) => {
1598
+ const others = params.distinctFrom;
1599
+ if (others === void 0) return [];
1600
+ if (!Array.isArray(others) || !others.every((k) => typeof k === "string")) return [{
1601
+ path: "distinctFrom",
1602
+ message: "names the approval steps by key"
1603
+ }];
1604
+ return others.flatMap((k, i) => checkExpr(`steps.${k}.output.by`).errors.length ? [{
1605
+ path: `distinctFrom.${i}`,
1606
+ message: `"${k}" is no approval upstream of this one`
1607
+ }] : []);
1608
+ },
1609
+ reads: (params) => Array.isArray(params.distinctFrom) ? params.distinctFrom.map((k) => `steps.${k}.output.by`) : [],
1591
1610
  actor: "person",
1592
1611
  approval: true,
1593
1612
  wait: true,
@@ -44,16 +44,22 @@ export interface SqlExecutor {
44
44
  */
45
45
  export declare function poolExecutor(pool: Pool): SqlExecutor;
46
46
  /**
47
- * The slice of a postgres.js `sql` instance {@link postgresJsExecutor} uses —
48
- * structural, so the driver is never imported. `sql.unsafe` resolves to the row
49
- * list with the affected-row `count` on it; `sql.begin` runs its callback on one
50
- * reserved connection inside `BEGIN`/`COMMIT` and hands it a `sql` of the same shape.
47
+ * The slice of a postgres.js transaction `sql` {@link postgresJsExecutor} uses: `unsafe` alone. postgres.js hands
48
+ * `begin`'s callback a `TransactionSql`, which has no `begin` of its own, and the executor never nests.
51
49
  */
52
- export interface PostgresJsSqlLike {
50
+ export interface PostgresJsTransactionLike {
53
51
  unsafe(query: string, params?: unknown[]): PromiseLike<unknown[] & {
54
52
  count?: number | null;
55
53
  }>;
56
- begin<T>(fn: (tx: PostgresJsSqlLike) => Promise<T>): PromiseLike<T>;
54
+ }
55
+ /**
56
+ * The slice of a postgres.js `sql` instance {@link postgresJsExecutor} uses —
57
+ * structural, so the driver is never imported. `sql.unsafe` resolves to the row
58
+ * list with the affected-row `count` on it; `sql.begin` runs its callback on one
59
+ * reserved connection inside `BEGIN`/`COMMIT` and hands it a transaction `sql`.
60
+ */
61
+ export interface PostgresJsSqlLike extends PostgresJsTransactionLike {
62
+ begin<T>(fn: (tx: PostgresJsTransactionLike) => Promise<T>): PromiseLike<T>;
57
63
  }
58
64
  /**
59
65
  * Build a {@link SqlExecutor} over a postgres.js `sql` instance: top-level
@@ -150,19 +150,21 @@ const isoDates = (params) => params.map((p) => p instanceof Date ? p.toISOString
150
150
  * {@link poolExecutor}.
151
151
  */
152
152
  function postgresJsExecutor(sql) {
153
- const over = (conn, inTransaction) => ({
154
- async query(text, params) {
155
- const rows = await conn.unsafe(text, isoDates(params ?? []));
156
- return {
157
- rows: Array.from(rows),
158
- rowCount: rows.count ?? rows.length
159
- };
160
- },
161
- transaction(fn) {
162
- return inTransaction ? fn(over(conn, true)) : Promise.resolve(conn.begin((tx) => fn(over(tx, true))));
163
- }
153
+ const query = (conn) => async function(text, params) {
154
+ const rows = await conn.unsafe(text, isoDates(params ?? []));
155
+ return {
156
+ rows: Array.from(rows),
157
+ rowCount: rows.count ?? rows.length
158
+ };
159
+ };
160
+ const inTransaction = (tx) => ({
161
+ query: query(tx),
162
+ transaction: (fn) => fn(inTransaction(tx))
164
163
  });
165
- return over(sql, false);
164
+ return {
165
+ query: query(sql),
166
+ transaction: (fn) => Promise.resolve(sql.begin((tx) => fn(inTransaction(tx))))
167
+ };
166
168
  }
167
169
  /**
168
170
  * Build a {@link SqlExecutor} over a PGlite instance — the embedded Postgres a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octaflow",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Durable DAG workflow engine: Zod-typed steps over Postgres + pg-boss. The model layer is @octabits-io/models.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -66,7 +66,6 @@
66
66
  "drizzle-orm": ">=0.36.0 <1",
67
67
  "pg": "^8.0.0",
68
68
  "pg-boss": "^12.21.0",
69
- "typescript": "^5 || ^6 || ^7",
70
69
  "zod": "^4.6.0"
71
70
  },
72
71
  "peerDependenciesMeta": {
@@ -19,10 +19,10 @@ the case.
19
19
  … body … ─► result ─► decide (manager) ─► next ─► move N+1 | case closed
20
20
  ```
21
21
 
22
- Shown end to end in `apps/demo-task-loop/src/moves.ts` (Postgres, pg-boss, a customer agent
23
- that answers by resuming the move that waits for it), with whole cases on scripted models in
24
- `apps/demo-task-loop/src/moves.test.ts`. The loop's own tests are
25
- `packages/octaflow/src/ai/case-loop.test.ts`.
22
+ Shown end to end, written as a case document, in the office: `apps/demo-office/src/features/office/case/`
23
+ (the document, the office's kinds, a customer agent that answers by resuming the move that waits
24
+ for it), with whole cases on the scripted model in `apps/demo-office/src/overdue-invoice.test.ts`.
25
+ The loop's own tests are `packages/octaflow/src/ai/case-loop.test.ts`.
26
26
 
27
27
  **Which pattern?**
28
28
 
@@ -110,7 +110,7 @@ means the move stopped waiting — keep the answer where the next `wait` move fi
110
110
  ## Prove it works
111
111
 
112
112
  A test on the in-memory engine with one scripted model whose lines are routed by the system
113
- prompt — `apps/demo-task-loop/src/moves.test.ts` is the template — that asserts:
113
+ prompt — `apps/demo-office/src/overdue-invoice.test.ts` is the template — that asserts:
114
114
 
115
115
  - a whole case: the moves in order, the outcome the host recorded;
116
116
  - a refused choice is in `decide`'s `refusals` with its reason, and the next choice is taken;