@terreno/mcp 57.10.1 → 57.12.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.
Files changed (45) hide show
  1. package/dist/docs/upgrades/57.11.0.md +80 -0
  2. package/dist/docs/upgrades/57.12.0.md +89 -0
  3. package/dist/docs/versioned/next/explanation/ai-workflows.md +4 -3
  4. package/dist/docs/versioned/next/explanation/durable-agent-harness.md +17 -4
  5. package/dist/docs/versioned/next/explanation/local-first-data.md +1 -0
  6. package/dist/docs/versioned/next/explanation/roadmap-process.md +4 -2
  7. package/dist/docs/versioned/next/how-to/README.md +1 -0
  8. package/dist/docs/versioned/next/how-to/call-external-apis.md +1 -1
  9. package/dist/docs/versioned/next/how-to/circleci.md +6 -2
  10. package/dist/docs/versioned/next/how-to/github-actions-ci.md +5 -6
  11. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
  12. package/dist/docs/versioned/next/how-to/preview-demo.md +7 -3
  13. package/dist/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
  14. package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +1 -1
  15. package/dist/docs/versioned/next/reference/ai-harness.md +138 -17
  16. package/dist/docs/versioned/next/reference/ai.md +33 -4
  17. package/dist/docs/versioned/next/reference/api.md +64 -2
  18. package/dist/docs/versioned/next/reference/environment-variables.md +1 -1
  19. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
  20. package/dist/docs/versioned/next/reference/syncdb.md +156 -6
  21. package/dist/docs/versioned/next/reference/ui.md +14 -5
  22. package/dist/docs/versioned/next/tasks/durable-agent-harness.md +4 -2
  23. package/package.json +3 -3
  24. package/src/docs/upgrades/57.11.0.md +80 -0
  25. package/src/docs/upgrades/57.12.0.md +89 -0
  26. package/src/docs/versioned/next/explanation/ai-workflows.md +4 -3
  27. package/src/docs/versioned/next/explanation/durable-agent-harness.md +17 -4
  28. package/src/docs/versioned/next/explanation/local-first-data.md +1 -0
  29. package/src/docs/versioned/next/explanation/roadmap-process.md +4 -2
  30. package/src/docs/versioned/next/how-to/README.md +1 -0
  31. package/src/docs/versioned/next/how-to/call-external-apis.md +1 -1
  32. package/src/docs/versioned/next/how-to/circleci.md +6 -2
  33. package/src/docs/versioned/next/how-to/github-actions-ci.md +5 -6
  34. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
  35. package/src/docs/versioned/next/how-to/preview-demo.md +7 -3
  36. package/src/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
  37. package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +1 -1
  38. package/src/docs/versioned/next/reference/ai-harness.md +138 -17
  39. package/src/docs/versioned/next/reference/ai.md +33 -4
  40. package/src/docs/versioned/next/reference/api.md +64 -2
  41. package/src/docs/versioned/next/reference/environment-variables.md +1 -1
  42. package/src/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
  43. package/src/docs/versioned/next/reference/syncdb.md +156 -6
  44. package/src/docs/versioned/next/reference/ui.md +14 -5
  45. package/src/docs/versioned/next/tasks/durable-agent-harness.md +4 -2
@@ -10,6 +10,7 @@ Local-first data layer for Terreno frontends. A TinyBase `MergeableStore` on dev
10
10
  - [createSyncDb configuration](#createsyncdb-configuration)
11
11
  - [SyncDb client methods](#syncdb-client-methods)
12
12
  - [React hooks](#react-hooks)
13
+ - [Query windows](#query-windows)
13
14
  - [Codegen](#codegen)
14
15
  - [Conflict API](#conflict-api)
15
16
  - [Sync status API](#sync-status-api)
@@ -25,7 +26,8 @@ Local-first data layer for Terreno frontends. A TinyBase `MergeableStore` on dev
25
26
  - `createSyncDb`, `SyncDb`, `SyncDbConfig`, `MutateArgs`
26
27
  - `betterAuthAdapter`, `bridgeBetterAuthReactClient`, `AuthProvider`
27
28
  - `listConflicts`, `wipeLocalData`, `generateMutationId`
28
- - React (`@terreno/syncdb/react`): `SyncDbProvider`, `useEntity`, `useQuery`, `useEntityIds`, `useMutate`, `useSyncStatus`, `useConflicts`, `useSyncDebugLog`, `createCollectionHooks`
29
+ - Queries: `QueryFieldNotAllowedError`, `ListRequestError`, `WhereFilter`, `SortSpec`, `WindowQuery`, `QueryWindowState`, `QueryWindows`, `compileWhere`, `DEFAULT_WINDOW_PAGE_SIZE`
30
+ - React (`@terreno/syncdb/react`): `SyncDbProvider`, `useEntity`, `useQuery`, `useEntityIds`, `useWindowQuery`, `useMutate`, `useSyncStatus`, `useConflicts`, `useSyncDebugLog`, `createCollectionHooks`
29
31
  - CLI: `terreno-syncdb-codegen` (generates `SYNC_COLLECTIONS` + friendly hooks from OpenAPI)
30
32
  - Testing (`@terreno/syncdb/testing`): `createFakeTransport`
31
33
 
@@ -165,6 +167,7 @@ be passed to `betterAuthAdapter` directly.
165
167
  | `name` | `string` | — (required) | Persisted database name |
166
168
  | `collections` | `string[]` | — (required) | Collection names to sync (local tables + subscriptions) |
167
169
  | `windowCollections` | `string[]` | `[]` | Collections that join `{collection}\|admin`, skip snapshots/reconcile, and hydrate only known REST membership ids |
170
+ | `queryCollections` | `Array<string \| {collection, path?, fullSync?}>` | `[]` | Collections too large to sync whole (or `fullSync: true` to keep snapshot sync and add windows). No snapshot/reconcile paging; rows arrive through [query windows](#query-windows) against the `modelRouter` list endpoint (`path`, default `/{collection}`) plus live deltas on the user's normal streams. Each entry must also be in `collections` and not in `windowCollections`. |
168
171
  | `organizationIdProvider` | `() => string \| undefined` | — | Read at send time: sets `X-Organization-Id` on HTTP sync calls and `organizationId` on socket mutate payloads |
169
172
  | `authProvider` | `AuthProvider` | — (required) | `{getToken, getUserId, onAuthChange, refresh?}` |
170
173
  | `baseUrl` | `string` | — | Server origin; required unless both `transport` and `httpChannel` are injected |
@@ -200,6 +203,7 @@ be passed to `betterAuthAdapter` directly.
200
203
  | `mutate({collection, operation, id?, data?})` | Optimistic local write + durable outbox enqueue + fire-and-forget replay. Returns `{mutationId, id}`. |
201
204
  | `reconcile()` | HTTP snapshot catch-up for every known stream; runs tombstone compaction on success. Also runs automatically on (re)connect, on a rate-limited seq-jump hint, and on the periodic timer; each `sync:subscribed` confirmation additionally pages just the streams it names. |
202
205
  | `hydrateWindow({collection, ids, restRows?})` | Admin window upsert: REST rows (optional) land immediately; every requested id is also fetched from `GET /sync/entities` before this resolves so seq/deleted metadata is canonical for immediate update/delete. A `{collection}|admin` delta that lands while the fetch is in flight wins, so hydration never rewinds a row to an older seq. Unknown ids are ignored. |
206
+ | `queryWindows` | Server query windows for `queryCollections`: `fetchWindow({query, nextPage?})`, `getWindow(query)`, `retain(query)` → release, `refetchRetained()`, `pruneUnretained()`, `subscribe(cb)`, `keyFor(query)`. See [Query windows](#query-windows). |
203
207
  | `forceResync()` | Purge every known stream locally and re-bootstrap from cursor 0 (outbox/conflicts untouched). Returns `{ok, reason?, streams, purged, repaired}`. After discovery and each stream bootstrap it waits for in-flight `start`/`stop`/auth-change work, then abandons with `reason: "superseded"` when that work switched users or generations. |
204
208
  | `replayOutbox()` | Drain queued mutations for the current user now. |
205
209
  | `resolveConflict({mutationId, strategy})` | Apply `"useServer"` or `"keepMine"` to a recorded conflict. |
@@ -271,13 +275,26 @@ Subscribe to a single entity. Re-renders when that row changes. `isPending` is t
271
275
 
272
276
  ```typescript
273
277
  const todos = useQuery<Todo>("todos", {
274
- filter: (t) => !t.completed,
275
- sort: (a, b) => b.created.localeCompare(a.created),
276
- includeDeleted: false,
278
+ where: {completed: false, created: {$gte: since}},
279
+ sort: "-created",
280
+ limit: 20,
277
281
  });
282
+
283
+ // Callbacks still work and combine with `where`:
284
+ const mine = useQuery<Todo>("todos", {filter: (t) => t.ownerId === userId, sort: byCreatedDesc});
278
285
  ```
279
286
 
280
- Returns decoded entity data arrays. Tombstones excluded unless `includeDeleted: true`. Filter and sort run in JS — memoize callbacks when collections are large.
287
+ Returns decoded entity data arrays from the local store. Tombstones excluded unless `includeDeleted: true`.
288
+
289
+ | Option | Type | Description |
290
+ |--------|------|-------------|
291
+ | `where` | `WhereFilter` | Mongo-style filter: equality, dot paths, `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin` `$exists`, `$and` / `$or`. An array field matches when any element equals the value. Compared by value, so inline objects are fine. Throws for operators only the server can run (`$search`, `$regex`). |
292
+ | `filter` | `(data) => boolean` | Arbitrary predicate, applied after `where`. |
293
+ | `sort` | `SortSpec \| comparator` | `"-created name"`, `{created: "descending"}`, or `(a, b) => number`. |
294
+ | `limit` | `number` | Keep the first N rows after filter and sort. |
295
+ | `includeDeleted` | `boolean` | Include tombstones. |
296
+
297
+ Filter and sort run in JS — memoize `filter`/comparator callbacks when collections are large.
281
298
 
282
299
  ### `useEntityIds(collection, options?)`
283
300
 
@@ -288,6 +305,38 @@ return ids.map((id) => <TodoRow key={id} id={id} />);
288
305
 
289
306
  Same options as `useQuery`, but returns only ordered ids with **referential stability** — the array identity changes only when membership or order changes, not on field updates. Pair with per-row `useEntity` for large lists.
290
307
 
308
+ ### `useWindowQuery(collection, options?)`
309
+
310
+ ```typescript
311
+ const {data, isLoading, isFetching, hasMore, fetchNextPage, refetch, total, error} =
312
+ useWindowQuery<Message>("messages", {
313
+ where: {threadId, deleted: false},
314
+ sort: "-created",
315
+ pageSize: 30,
316
+ });
317
+ ```
318
+
319
+ Server-filtered list over a `queryCollections` collection. See [Query windows](#query-windows).
320
+
321
+ | Option | Type | Default | Description |
322
+ |--------|------|---------|-------------|
323
+ | `where` | `WhereFilter` | — | Sent as qs list params (`status[$in][0]=open`). Fields must be in the route's `queryFields`. |
324
+ | `sort` | `SortSpec` | route default | Server `sort`; also orders the local view. |
325
+ | `pageSize` | `number` | `50` | Server `limit` per page (capped by the route's `maxLimit`). |
326
+ | `skip` | `boolean` | `false` | Do not retain or fetch (e.g. a required param is missing). |
327
+ | `liveMatch` | `boolean` | `true` | Show cached rows that newly match `where` inside the loaded range. |
328
+
329
+ | Result | Description |
330
+ |--------|-------------|
331
+ | `data` / `ids` | This window's rows in view order. |
332
+ | `isLoading` | No membership yet (first fetch in flight). Cached membership renders with `isLoading: false`. |
333
+ | `isFetching` | Any request for this window is in flight. |
334
+ | `isError` / `error` | Last request failed (cached rows still render). |
335
+ | `errorCode` | Server error code of the last failure; `"query-param-not-allowed"` means a `where` field is missing from `queryFields`. |
336
+ | `hasMore` / `total` | From the list response's `more` / `total`. |
337
+ | `fetchNextPage()` | Append the next server page. |
338
+ | `refetch()` | Reload every loaded page in one request, replacing membership. |
339
+
291
340
  ### `useMutate(collection)`
292
341
 
293
342
  ```typescript
@@ -331,7 +380,7 @@ Returns the `SyncDb` instance from context (escape hatch for imperative calls li
331
380
 
332
381
  ### `createCollectionHooks`
333
382
 
334
- Factory used by `terreno-syncdb-codegen` and by hand-written custom collections. Returns five operation hooks (`useListQuery`, `useReadQuery`, `useCreateMutation`, `useUpdateMutation`, `useDeleteMutation`). Generated SDKs rename them to friendly names (`useTodos`, `useTodo`, `useCreateTodo`, …). Mutation hooks return `[trigger]`; triggers apply locally and return `{mutationId, id}` synchronously. Optional `retries` maps to `maxAttempts` (`false` → 1, a number → that many, omitted → engine default).
383
+ Factory used by `terreno-syncdb-codegen` and by hand-written custom collections. Returns six operation hooks (`useListQuery`, `useWindowQuery`, `useReadQuery`, `useCreateMutation`, `useUpdateMutation`, `useDeleteMutation`). Generated SDKs rename them to friendly names (`useTodos`, `useTodosWindow`, `useTodo`, `useCreateTodo`, …). `useWindowQuery` works only for collections listed in `queryCollections`. Mutation hooks return `[trigger]`; triggers apply locally and return `{mutationId, id}` synchronously. Optional `retries` maps to `maxAttempts` (`false` → 1, a number → that many, omitted → engine default).
335
384
 
336
385
  ```typescript
337
386
  export const {useListQuery: useNotes, useCreateMutation: useCreateNote} =
@@ -340,6 +389,107 @@ export const {useListQuery: useNotes, useCreateMutation: useCreateNote} =
340
389
  });
341
390
  ```
342
391
 
392
+ ## Query windows
393
+
394
+ Use query windows when a collection is too large to sync whole (messages, events, audit
395
+ rows). They match what an RTK list query did — `where`, `sort`, `limit`, `page`, `more`,
396
+ `total` — but keep the data local-first and deduplicated.
397
+
398
+ ```typescript
399
+ export const syncDb = createSyncDb({
400
+ collections: ["todos", "messages"],
401
+ queryCollections: [{collection: "messages", path: "/messages"}],
402
+ // ...
403
+ });
404
+ ```
405
+
406
+ **Membership, not copies.** A window is one list query against the collection's
407
+ `modelRouter` list endpoint. Its result is an ordered id list stored in the reserved
408
+ `_queryWindows` table. Each row lands once in the collection's entity table, keyed by id.
409
+ Two windows that overlap share those rows:
410
+
411
+ - A delta, mutation, or refetch for a shared row writes the store once; every window that
412
+ lists the row re-renders from it.
413
+ - Each window lists only its own members. Window B never shows rows that only window A
414
+ fetched.
415
+
416
+ **Live membership.** When `where` is locally evaluable (see `useQuery`'s `where`), a window
417
+ also:
418
+
419
+ - hides a member whose fields stop matching (an unpinned message leaves the "pinned" window);
420
+ - shows cached rows that newly match (a live delta, or a local create) when they sort inside
421
+ the loaded range. With more pages unloaded, a row that sorts after the last loaded row
422
+ waits for `fetchNextPage()`. Without a `sort` and with more pages, only local creates are
423
+ admitted.
424
+
425
+ A full fetch also records cached rows that matched `where` locally but that the server did
426
+ not return (deleted, changed, or no longer visible while this device was offline). They stay
427
+ out of the view until a newer delta or a local edit changes them, so stale cached data never
428
+ resurrects a row.
429
+
430
+ Set `liveMatch: false` to show server membership only. Operators only the server runs
431
+ (`$search`) also fall back to server membership.
432
+
433
+ **Fetching.** The first `retain` of a window in a session fetches it; persisted membership
434
+ renders meanwhile, so windows work offline. Concurrent fetches of the same window share one
435
+ request; a refetch and a `fetchNextPage()` on one window run one after the other. Retained
436
+ windows refetch on reconnect and after `forceResync()` — query collections
437
+ skip snapshot catch-up, so this is how deltas missed offline are recovered. Windows not on
438
+ screen at that moment refetch the next time they are shown. Window fetches
439
+ started before a `stop()` or user switch are discarded.
440
+
441
+ **Seq.** List rows that carry `_syncSeq` (the `syncPlugin` field) are written with that seq.
442
+ Rows without it (a `responseHandler` that strips it) are re-read from `GET /sync/entities` so
443
+ a later mutation never sends a stale `baseVersion`. Rows with a pending outbox mutation, or
444
+ with a newer seq from a delta, are never overwritten. A list `responseHandler` must return the
445
+ same shape as the sync serializer; otherwise list rows and delta rows differ.
446
+
447
+ **Missing `queryFields`.** Every `where` field must be in the route's `queryFields`. The
448
+ server answers 400 `query-param-not-allowed` naming the field, the model, and the allowed
449
+ list. syncdb turns it into `QueryFieldNotAllowedError` (`field`, `allowedQueryFields`,
450
+ `model`), logs it with `console.error`, sets the window's `errorCode` to
451
+ `"query-param-not-allowed"`, and rejects `fetchWindow` / `refetch` / `fetchNextPage` with it.
452
+ Other list failures throw `ListRequestError` (`status`, `code`, `detail`).
453
+
454
+ ```typescript
455
+ import {QueryFieldNotAllowedError} from "@terreno/syncdb";
456
+
457
+ try {
458
+ await refetch();
459
+ } catch (error) {
460
+ if (error instanceof QueryFieldNotAllowedError) {
461
+ // error.field === "title", error.allowedQueryFields === ["completed", "created", "ownerId"]
462
+ }
463
+ }
464
+ ```
465
+
466
+ **`maxLimit` and pipelining.** `modelRouter` clamps `limit` to the route's `maxLimit` and
467
+ echoes the applied value. Window requests always send `page` (so the server never logs a
468
+ truncated unpaginated list). When a fetch needs more rows than one request returns — a
469
+ `refetch()` after several `fetchNextPage()` calls, or a `pageSize` above `maxLimit` — syncdb
470
+ learns the cap from the echoed `limit` and splits the range into sequential page requests
471
+ of `maxLimit` rows starting at the aligned page that contains the range, trimming the extra
472
+ rows, so offsets stay exact. Each pipelined fetch logs a
473
+ `console.warn` naming the row count, the cap, and the request count. Fix it by loading fewer
474
+ pages, lowering `pageSize`, or raising `maxLimit` on the route.
475
+
476
+ **Fully synced collections.** `queryCollections: [{collection: "todos", fullSync: true}]`
477
+ keeps normal snapshot sync and also enables windows, for server-side filtering, paging, and
478
+ totals over a collection that is already local. See `example-frontend/app/todo-windows.tsx`.
479
+
480
+ **Limits.**
481
+
482
+ - `refetch()` with several pages loaded reloads `pageSize × pages` rows (see below).
483
+ - Page appends dedupe ids. A delete between page loads can shift offsets and skip one row
484
+ until the next refetch.
485
+ - `null` in `where` is rejected (qs would send the string `"null"`); use `{$exists: false}`.
486
+ A top-level `{$in: []}` returns an empty window without a request; an empty array anywhere
487
+ else throws instead of silently widening the query.
488
+ - Rows stay in the entity table after their windows unmount. `start()` prunes persisted
489
+ membership of unretained windows not fetched for 7 days (`pruneStale`);
490
+ `pruneUnretained()` drops all unretained membership. Rows are dropped by leave-purge,
491
+ tombstone compaction, or a wipe.
492
+
343
493
  ## Codegen
344
494
 
345
495
  `terreno-syncdb-codegen` is a bin of `@terreno/syncdb`. It reads OpenAPI, discovers list operations with `x-terreno-sync`, and writes typed hooks plus `SYNC_COLLECTIONS`.
@@ -725,11 +725,20 @@ Master-detail layout. Pass `listViewData` plus `renderListViewItem` for the list
725
725
  screens the detail replaces the list until the user goes back.
726
726
 
727
727
  `desktopChildrenMinWidth` opts into a minimum pixel width for each desktop child when there
728
- are one or two children. `narrowViewportChildLabels` opts into a labeled full-width pager on
729
- the narrow viewport. `narrowBelowWidth` uses that viewport when the window is at or below the
730
- given pixel width; when omitted, the narrow viewport follows `isNarrowViewport()`. These props
731
- are web only; the native `SplitPage` ignores them. See `SplitPageProps` for when each prop
732
- applies and what is ignored.
728
+ are one or two children. `childColumnRounding` sets the border radius on each desktop detail
729
+ pane (defaults to `md`, 4px); the list column is excluded. The pane clips to that radius. The
730
+ column inside it scrolls: content taller than the pane scrolls in the column, and a child with
731
+ `height: "100%"` fills the visible pane so a scroll view inside that child scrolls on its own.
732
+ On a narrow web viewport the pager page is height-bounded to that same visible pane. Its rounded
733
+ corners still clip, and a `height: "100%"` child scrolls inside the page. On the labeled pager,
734
+ previous and next sit in fixed left and right slots in a row under the column. A missing direction
735
+ leaves that slot empty, so the other arrow stays put. The column height is the measured pane minus
736
+ top spacing, the navigation row, bottom spacing, and `bottomNavBarHeight`.
737
+ `narrowViewportChildLabels`
738
+ opts into a labeled full-width pager on the narrow viewport. `narrowBelowWidth` uses that
739
+ viewport when the window is at or below the given pixel width; when omitted, the narrow
740
+ viewport follows `isNarrowViewport()`. These props are web only; the native `SplitPage`
741
+ ignores them. See `SplitPageProps` for when each prop applies and what is ignored.
733
742
  `IconButton`'s `backgroundOpacity` tints only that button's background.
734
743
 
735
744
  ```typescript
@@ -131,21 +131,23 @@ created in 1.1 and extended by every later task — never deferred.
131
131
 
132
132
  ## Phase 2 — Multi-instance
133
133
 
134
- - [ ] **Task 2.0**: `InProcessRunner` concurrency
134
+ - [x] **Task 2.0**: `InProcessRunner` concurrency
135
135
  - Delivers: `new InProcessRunner({concurrency})` (default 8) runs up to N claimed tasks at once, each with its own lease and heartbeat; a slow phase no longer delays other tasks' wakes; `stop()` waits for all in-flight phases
136
136
  - Files: `ai/src/harness/runners/inProcessRunner.ts`, tests
137
137
  - Blocked by: 1.12
138
138
  - Skills: `terreno-backend-api`
139
139
  - Docs: reference "Runners" (concurrency option, ordering guarantees)
140
140
  - Acceptance: bun test — a phase blocked on a gate does not prevent a second task from completing; never more than N concurrent; no task runs twice concurrently
141
+ - Shipped: fake-context cases in `ai/src/harness/runners/inProcessRunner.test.ts` (default 8, gate, peak, stop waits for all, invalid concurrency) and real-Mongo cases in `ai/src/harness/concurrency.test.ts` (blocked task does not hold up a second; 7 two-phase tasks at concurrency 3 peak at exactly 3 with no task running twice). Roast: PASS (2026-10-04; 76 targeted tests, three mutation checks red)
141
142
 
142
- - [ ] **Task 2.1**: `JobsRunner`
143
+ - [x] **Task 2.1**: `JobsRunner`
143
144
  - Delivers: `@terreno/ai/harness/jobsRunner` with optional peer `@terreno/jobs`; job `terreno.harness.phase` keyed `taskId:phase:attempt`; harness task lease + heartbeat is the authority; importing the root `@terreno/ai` never loads jobs
144
145
  - Files: `ai/src/harness/runners/jobsRunner.ts`, `ai/package.json`, tests
145
146
  - Blocked by: 2.0
146
147
  - Skills: `terreno-backend-api`
147
148
  - Docs: reference "Runners"; how-to "Run on multiple instances"
148
149
  - Acceptance: AC11 — two workers race one phase; exactly one commit; stale owner rejected; knip clean without jobs installed
150
+ - Shipped: `ai/src/harness/runners/jobsRunner.test.ts` (two-worker race, stale-lease commit rejected, re-dispatch of a dead job, root imports never reach `@terreno/jobs`). Drift: the job key is `taskId:phase:attempt:claims` — `claims` (new `HarnessTask` counter) gives a phase revisited by an event wake or crash replay its own job, since jobs idempotency keys are permanent. Built before Task 2.0; it does not depend on `InProcessRunner` concurrency. Roast: PASS (2026-10-04; 17 targeted tests, ai suite 1185/0, knip clean, two mutation checks red)
149
151
 
150
152
  - [ ] **Task 2.2**: Runs admin screen
151
153
  - Delivers: task list/tree with status filters, abort, resolve-interrupted (retry/abort/complete + reason), link to trace
package/package.json CHANGED
@@ -11,8 +11,8 @@
11
11
  "@modelcontextprotocol/node": "^2.0.0",
12
12
  "@modelcontextprotocol/server": "^2.0.0",
13
13
  "@sentry/bun": "^10.39.0",
14
- "@terreno/api": "57.10.1",
15
- "@terreno/blocks": "57.10.1",
14
+ "@terreno/api": "57.12.0",
15
+ "@terreno/blocks": "57.12.0",
16
16
  "express": "^5.2.1",
17
17
  "luxon": "^3.7.2",
18
18
  "minisearch": "^7.2.0",
@@ -105,5 +105,5 @@
105
105
  "test:coverage": "bun run ../scripts/check-coverage.ts"
106
106
  },
107
107
  "type": "module",
108
- "version": "57.10.1"
108
+ "version": "57.12.0"
109
109
  }
@@ -0,0 +1,80 @@
1
+ # Upgrading to 57.11.0
2
+
3
+ **Action required:** Yes
4
+ **Affected packages:** @terreno/ai, @terreno/api
5
+
6
+ ## Summary
7
+
8
+ `InProcessRunner` now runs up to eight harness tasks at once. List filters on a field that is not in `queryFields` return 400 before request validation, so that filter can no longer be stripped and ignored. Slack channel helpers and syncdb query windows are opt-in.
9
+
10
+ ## Breaking changes
11
+
12
+ ### InProcessRunner runs tasks in parallel
13
+
14
+ **What changed**
15
+
16
+ `new InProcessRunner()` runs up to 8 claimed tasks at once. Each task has its own lease. `stop()` waits for every in-flight task. Turns inside one conversation stay serial. Model calls run in parallel, so provider rate limits, cost, and stream-counter transaction retries all go up.
17
+
18
+ **Why**
19
+
20
+ A single slow model call was blocking every other task on that process.
21
+
22
+ **Migration**
23
+
24
+ ```typescript
25
+ // Before: one claimed task at a time
26
+ const runner = new InProcessRunner();
27
+
28
+ // After: keep that behavior
29
+ const runner = new InProcessRunner({concurrency: 1});
30
+
31
+ // Or accept the new default (8)
32
+ const runner = new InProcessRunner();
33
+ ```
34
+
35
+ ### List filters outside queryFields are no longer ignored
36
+
37
+ **What changed**
38
+
39
+ `GET` list on `modelRouter` checks every filter against `queryFields` before OpenAPI query validation. A field that is not listed returns 400 `query-param-not-allowed`. The body names the field, the model, and `meta.allowedQueryFields`. `@terreno/syncdb` raises `QueryFieldNotAllowedError` for the same response. Pagination params `limit`, `page`, and `sort` are unchanged.
40
+
41
+ **Why**
42
+
43
+ Query validation could strip an unknown filter and still return 200, so the client thought the filter applied.
44
+
45
+ **Migration**
46
+
47
+ ```typescript
48
+ // Before: ?title=ship could be stripped and the list still returned 200
49
+ modelRouter("/todos", Todo, {queryFields: ["completed", "ownerId"]});
50
+
51
+ // After: add the field, or stop sending it
52
+ modelRouter("/todos", Todo, {queryFields: ["completed", "ownerId", "title"]});
53
+ ```
54
+
55
+ A 400 looks like:
56
+
57
+ ```json
58
+ {
59
+ "status": 400,
60
+ "code": "query-param-not-allowed",
61
+ "title": "Query parameter not allowed",
62
+ "meta": {"queryParam": "title", "model": "Todo", "allowedQueryFields": ["completed", "ownerId"]}
63
+ }
64
+ ```
65
+
66
+ ## Deprecations
67
+
68
+ None
69
+
70
+ ## New capabilities
71
+
72
+ - `JobsRunner` from `@terreno/ai/harness/jobsRunner` runs each harness phase as a `@terreno/jobs` job (`terreno.harness.phase`) so more than one instance can share the work. `@terreno/jobs` is an optional peer. `HarnessTask.claims` defaults to 0 and needs no migration. See `docs/how-to/run-harness-on-multiple-instances.md`.
73
+ - `@terreno/api` Slack helpers use the existing `SLACK_BOT_TOKEN`: `createSlackPrivateChannel`, `inviteSlackUsersToChannel`, `postSlackMessage`, and `findSlackChannelByName`. The bot needs `groups:write`, `groups:write.invites`, `channels:write.invites`, `chat:write`, `channels:read`, and `groups:read`. A taken channel name returns 409.
74
+ - `@terreno/syncdb` query windows: set `queryCollections`, then use `client.queryWindows` or `useWindowQuery` (and generated `use{Collection}Window` hooks) for server-filtered, paged lists. `useQuery` and `useEntityIds` accept the same Mongo-style `where`, string sort, and `limit` for local queries. `fullSync: true` adds windows without turning off snapshot sync.
75
+
76
+ ## Verification
77
+
78
+ - Apps that construct `InProcessRunner` with no options: confirm parallel model calls are acceptable, or pass `{concurrency: 1}`.
79
+ - Hit one list route with a filter field that is not in `queryFields` and confirm 400 `query-param-not-allowed` instead of a 200 that ignored the filter.
80
+ - `bun run compile` and `bun run lint` at the app root.
@@ -0,0 +1,89 @@
1
+ # Upgrading to 57.12.0
2
+
3
+ **Action required:** Yes
4
+ **Affected packages:** @terreno/ai
5
+
6
+ ## Summary
7
+
8
+ Harness tasks can record a trace scope, tags, and prompt versions, and `@terreno/ai` publishes official subpath exports. The chat `Project` model registers on first use instead of at import, and harness task HTTP responses no longer include the lease fencing token.
9
+
10
+ ## Breaking changes
11
+
12
+ ### `Project` is no longer registered on import
13
+
14
+ **What changed**
15
+
16
+ Importing `@terreno/ai` does not register mongoose model `Project`. The exported `Project` is a lazy proxy. The first property access, `new Project(...)`, or `getProjectModel()` registers the model. Statics and `instanceof Project` still work. These do not:
17
+
18
+ - `Project === mongoose.models.Project` (and `Project === mongoose.model("Project")`)
19
+ - `Object.keys(Project)` (always empty)
20
+ - reading `Project.prototype` (throws)
21
+
22
+ `new AiApp()` still mounts `/gpt/projects` and registers the model when those routes load. `new AiApp({projects: false})` skips those routes.
23
+
24
+ **Why**
25
+
26
+ An app that imports `@terreno/ai` and owns its own model named `Project` was hitting a mongoose overwrite error at import time.
27
+
28
+ **Migration**
29
+
30
+ ```typescript
31
+ // Before
32
+ import {Project} from "@terreno/ai";
33
+
34
+ const project = await Project.findExactlyOne({_id: id});
35
+ if (mongoose.models.Project === Project) {
36
+ // identity check
37
+ }
38
+
39
+ // After
40
+ import {getProjectModel} from "@terreno/ai";
41
+
42
+ const Project = getProjectModel();
43
+ const project = await Project.findExactlyOne({_id: id});
44
+ ```
45
+
46
+ To keep a separate app model named `Project`, do not call `getProjectModel()` or touch the `Project` export, and skip the AI project routes:
47
+
48
+ ```typescript
49
+ new AiApp({aiService, projects: false});
50
+ ```
51
+
52
+ ### Harness task HTTP responses omit `lease`
53
+
54
+ **What changed**
55
+
56
+ `GET /harness/tasks/:id` no longer includes `lease`. The OpenAPI schema for that response omits it too. Collection list is disabled (`list: []`, 405). The token is still stored on the task document for runners.
57
+
58
+ **Why**
59
+
60
+ `lease.token` is the fencing token that decides which runner may commit. It is not a client field.
61
+
62
+ **Migration**
63
+
64
+ ```typescript
65
+ // Before
66
+ const token = task.lease?.token;
67
+
68
+ // After
69
+ // Do not read lease from the HTTP task. Runners load it on the server.
70
+ ```
71
+
72
+ Regenerate the OpenAPI SDK after upgrading `@terreno/ai` so generated task types drop `lease`.
73
+
74
+ ## Deprecations
75
+
76
+ `Project` from `@terreno/ai` still works for `find`, `create`, `new`, and `instanceof`. It is deprecated. Call `getProjectModel()` and use that model. A later major can remove the proxy.
77
+
78
+ ## New capabilities
79
+
80
+ - `harness.createTask(def, input, {trace: {scope, tags}, prompts})` writes a scope (tenant, workspace), tags, and prompt versions onto the task's `ObsTrace`. Child tasks and subagents share that trace. The admin trace list filters with `?scope=`.
81
+ - `rt.runAgent` accepts `prompts` and a registry `PromptVersionRef` as `instructions`. It runs `body` and records the version on the trace.
82
+ - Subpath exports: `@terreno/ai/admin`, `@terreno/ai/harness/agentLoop`, `@terreno/ai/harness/commit`, `@terreno/ai/harness/events`, `@terreno/ai/harness/internalRuntime`, `@terreno/ai/observability/observabilityApp`, `@terreno/ai/observability/localPlugin`, `@terreno/ai/observability/promptStore`.
83
+
84
+ ## Verification
85
+
86
+ - Code that imported `Project` from `@terreno/ai`: switch identity checks and `mongoose.models.Project` reads to `getProjectModel()`.
87
+ - An app that owns a model named `Project`: pass `projects: false` and do not call `getProjectModel()`.
88
+ - Harness clients: confirm `GET /harness/tasks/:id` JSON has no `lease`, then regenerate the SDK.
89
+ - `bun run compile` and `bun run lint` at the app root.
@@ -49,9 +49,10 @@ Maintains centralized agent guidance and generates the matching configuration fo
49
49
 
50
50
  Rulesync generates native hooks from `.rulesync/hooks.json`. Biome checks changed files
51
51
  after edits and staged files before commits. At agent stop,
52
- `.rulesync/hooks/quality-check.sh` runs lint, TypeScript compilation, Knip, and
53
- dependency-cruiser. It keeps stdout machine-readable for each host and prevents
54
- retry-triggered Stop hooks from rerunning the commands.
52
+ `.rulesync/hooks/quality-check.sh` runs `bun install --frozen-lockfile` when
53
+ `node_modules/typescript` or any workspace package link is missing, then runs lint,
54
+ TypeScript compilation, Knip, and dependency-cruiser. It keeps stdout machine-readable
55
+ for each host and prevents retry-triggered Stop hooks from rerunning the commands.
55
56
 
56
57
  Existing findings are ratcheted so the checks reject new debt without requiring unrelated
57
58
  cleanup. See [Static analysis](static-analysis.md).
@@ -47,8 +47,11 @@ agents, tools, and conversations (see [The agent loop](#the-agent-loop)), subage
47
47
  extensions, hooks, and memos (see [Extensions, hooks, and memos](#extensions-hooks-and-memos)),
48
48
  events, waits, and approvals with the admin inbox, the SSE event stream, and version
49
49
  pinning. The example backend's `clinic.intakeSummary` runs all of it end to end, including
50
- a crash drill: [Build a durable workflow](../how-to/build-a-durable-workflow.md). Other
51
- runners (jobs-backed, concurrent) come in Phase 2.
50
+ a crash drill: [Build a durable workflow](../how-to/build-a-durable-workflow.md).
51
+
52
+ Phase 2 so far: `InProcessRunner` concurrency (up to 8 tasks at once by
53
+ default) and the `JobsRunner`, which runs each phase as a `@terreno/jobs` job across many
54
+ instances. The Runs admin screen is still to come.
52
55
 
53
56
  ## Why the audit span shares the checkpoint transaction
54
57
 
@@ -237,8 +240,18 @@ tasks. Switching runners needs no data migration.
237
240
 
238
241
  | Runner | Status | Ownership |
239
242
  | --- | --- | --- |
240
- | `InProcessRunner` | Shipped | One process holds the `HarnessOwner` lease and drains `pending` tasks one at a time; others wait on standby and take over on lease expiry. |
241
- | `JobsRunner` | Planned (Phase 2) | Each runnable phase becomes a `@terreno/jobs` job. The task lease is the authority. |
243
+ | `InProcessRunner` | Shipped | One process holds the `HarnessOwner` lease and runs up to `concurrency` (default 8) claimed tasks at once, each under its own task lease; others wait on standby and take over on lease expiry. |
244
+ | `JobsRunner` | Shipped | Every instance dispatches runnable tasks as `terreno.harness.phase` jobs; a job claims its task, runs one phase, and hands the task back. The task lease is the authority. |
245
+
246
+ Why the jobs runner runs **one** phase per job: a phase is the unit that commits, so it is
247
+ the natural unit of work to spread across instances and to bound by a request or job
248
+ timeout. Why the task lease and not the job lock decides who commits: a jobs lock can
249
+ expire while a long phase still runs, and a queue can deliver a job twice. Both are
250
+ harmless here, because the claim is an atomic fenced update and every commit checks the
251
+ lease token. Why the job key carries `claims`: `taskId:phase:attempt` alone repeats when a
252
+ task returns to the same phase without a failure (an event wake or a crash replay), and a
253
+ permanent idempotency key would then swallow the new visit. `claims` changes on every
254
+ claim, so each visit gets one job.
242
255
 
243
256
  ## Version pinning
244
257
 
@@ -39,6 +39,7 @@ Terreno 57.
39
39
  3. **Encryption at rest (web)** — Web persistence uses AES-GCM via the default `createServerKeyProvider`; understand key fetch and `onDecryptFailure` for support flows.
40
40
  4. **Key lifecycle on logout** — Call `syncDb.stop()` and consider `wipeLocalData` when the authenticated user changes so one account does not read another's local store.
41
41
  5. **Backend sync registration** — Every synced collection needs `syncPlugin`, `isDeletedPlugin`, and a `sync` block on `modelRouter`, plus `SyncApp` and `RealtimeApp` on the server. Optional `sync.adminBroadcast: true` also emits `sync:delta` to `{collection}|admin`. Admin clients subscribe with `mode: "window"` (`createSyncDb({windowCollections})`) so they do not snapshot or reconcile that collection; they call `hydrateWindow` (REST membership ids + `GET /sync/entities`). Pass the same `accessControl` into `SyncApp` that `AdminApp` uses so `{collection}|admin` requires `admin:access`. `AdminApp.register` also installs that model's admin list/read/`queryFilter` on the admin window so hydrate and live `|admin` deltas match `/admin` REST. Without RBAC, `user.admin` is enough for the shell gate. Live deltas on that stream apply only to ids already in the window; Refresh is a REST re-query plus `hydrateWindow`.
42
+ 6. **Collections too large to sync whole** — List them in `createSyncDb({queryCollections})`. They skip snapshot bootstrap; screens load server-filtered *query windows* (`useWindowQuery`) from the `modelRouter` list endpoint. A window stores only membership (ordered ids); rows live once in the shared entity table. Overlapping windows therefore share rows — one delta updates both views — while each view lists only its own query's members, plus cached rows that newly match its `where` inside the loaded range. Live deltas still arrive on the user's normal streams; windows on screen refetch on reconnect to cover deltas missed offline.
42
43
 
43
44
  ## Tradeoffs
44
45
 
@@ -402,7 +402,7 @@ do not claim completion until done.
402
402
  | Workflow | Trigger | Purpose |
403
403
  | -------- | ------- | ------- |
404
404
  | [`.github/workflows/triage.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/triage.yml) | Issue opened | `status:needs-triage` + `area:*` from package dropdown + `type:*` from Kind when present; `area:*` / `type:*` labels the issue was opened with win, so `roadmap:sync` issues are not asked for an area |
405
- | [`.github/workflows/roadmap-generate.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-generate.yml) | Daily + manual | `roadmap:sync --check` for board drift, then regenerate `ROADMAP.md` from the board and open a pull request when it changes |
405
+ | [`.github/workflows/roadmap-generate.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-generate.yml) | Daily + manual | `roadmap:sync --check` for board drift, then regenerate `ROADMAP.md` from the board and open a pull request when roadmap content changes. A new **Last updated** timestamp alone does not open a pull request |
406
406
  | [`.github/workflows/roadmap-sync.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-sync.yml) | Taxonomy files change on `master` + manual | Apply labels and reconcile the board's fields and items |
407
407
  | [`.github/workflows/roadmap-reconcile.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-reconcile.yml) | IP or task files change on `master` + manual | Advance status from IP headers, add an entry for every new IP, open its tracking issue, push to the board, regenerate `ROADMAP.md` |
408
408
  All three share the `roadmap` concurrency group. They write the same board, and interleaving
@@ -495,4 +495,6 @@ GITHUB_TOKEN=$(gh auth token) TERRENO_PROJECT_NUMBER=... bun run roadmap:generat
495
495
  ```
496
496
 
497
497
  The generator exits non-zero when the project cannot be read, so a bad project number or a
498
- token without `read:project` fails loudly instead of writing an empty `ROADMAP.md`.
498
+ token without `read:project` fails loudly instead of writing an empty `ROADMAP.md`. When the
499
+ rendered list matches the file already on disk, it keeps that file's **Last updated** stamp
500
+ and does not write. The daily workflow then sees a clean diff and does not open a pull request.
@@ -37,6 +37,7 @@ Problem-oriented, practical steps. Use these when you know what you want to do.
37
37
  - [Develop an AI feature](ai-feature-development.md) — Dataset → prompt → evaluators → experiment → production label → live traces (SOP)
38
38
  - [Observe LLM calls](observe-llm-calls.md) — Register ObservabilityApp, primaries, price map, sampleRate
39
39
  - [Build a durable workflow](build-a-durable-workflow.md) — Phased harness task with an agent, a human sign-off, and a once-only side effect (`clinic.intakeSummary`)
40
+ - [Run the harness on multiple instances](run-harness-on-multiple-instances.md) — `JobsRunner`: harness phases as `@terreno/jobs` jobs on every instance
40
41
  - [Ship a new task version](ship-a-new-task-version.md) — Change a durable harness task without stranding in-flight runs
41
42
  - [GitHub issue lifecycle](github-issue-lifecycle.md) — Create pick-ready issues, Pick ⇄ Roast, or unattended `status:ready-for-dev` pickup
42
43
  - [Call external APIs](call-external-apis.md) — Authenticated HTTP client, retries, and error normalization for third-party integrations
@@ -94,7 +94,7 @@ Note the hook's output classification also feeds the client's retry decision —
94
94
 
95
95
  - **Your own backend's routes** — use the generated `@terreno/rtk` SDK (frontend) or call the function directly (backend).
96
96
  - **SDK-managed transport** (Twilio, googleapis) — don't fight the SDK's HTTP layer; adopt only `normalizeApiError`/`withApiErrorHandling` for consistent failure handling.
97
- - **Webhook notifiers** — `sendToSlack`/`sendToGoogleChat`/`sendToZoom` already exist; see [Webhooks & Notifications](../reference/api.md#webhooks--notifications). Slack @-mentions require a stored Slack member ID (`mentionUserIds` or `formatSlackUserMention`); names and emails in the text do not notify anyone.
97
+ - **Webhook notifiers** — `sendToSlack`/`sendToGoogleChat`/`sendToZoom` post to a fixed webhook. Slack channel creation, invites, and bot posts use `createSlackPrivateChannel`, `inviteSlackUsersToChannel`, and `postSlackMessage` with `SLACK_BOT_TOKEN`. See [Webhooks & Notifications](../reference/api.md#webhooks--notifications).
98
98
 
99
99
  ## Related
100
100
 
@@ -254,8 +254,12 @@ the review script so a PR cannot rewrite the reviewer.
254
254
  Netlify and GCP jobs **validate their context as the first step** and fail,
255
255
  before checkout or `bun install`, listing the missing variables. Preview jobs
256
256
  halt earlier on fork PRs, where CircleCI withholds contexts. Docker Layer
257
- Caching is off (200 credits per job). The docs Netlify target disables
258
- Docusaurus minification so the build stays within the 8 GB `large` executor.
257
+ Caching is off (200 credits per job). The docs Netlify target builds only the
258
+ current docs tree, disables Docusaurus minification, and pins SSG to one worker
259
+ thread so the build peaks near 3 GB and fits the 4 GB `medium` executor.
260
+ Released versions are not rebuilt: `archive-docs-version` (in `publish-release`,
261
+ `X.Y.0` tags only) builds each one once and uploads it to its GitHub release, and
262
+ `deploy-docs` unpacks those archives under `/<version>/`. See `website/README.md`.
259
263
 
260
264
  `terreno-gcp` uses CircleCI OIDC (`CIRCLE_OIDC_TOKEN_V2`), never a JSON service
261
265
  account key. Set `circleci_org_id`, `circleci_project_id`, and
@@ -7,24 +7,23 @@ describes how they behaved if one is ever re-enabled.
7
7
 
8
8
  ## Docs site (`docs-deploy.yml`)
9
9
 
10
- `Build docs site` is often the longest PR job because Docusaurus compiles every
11
- versioned tree under `website/versioned_docs/` and TypeDoc regenerates API
12
- pages.
10
+ `Build docs site` is often the longest PR job because TypeDoc regenerates API
11
+ pages. The site builds only the current docs tree; `website/versioned_docs/` is
12
+ markdown for the MCP server.
13
13
 
14
14
  PR builds set `DOCS_PREVIEW=true` and pass `--no-minify`. Both PR and
15
15
  `master` use Docusaurus Faster (Rspack/SWC via `@docusaurus/faster`).
16
16
 
17
17
  | Behavior | PR preview | `master` production |
18
18
  | --- | --- | --- |
19
- | Versioned docs (`57.1.0`, `0.30.0`, …) | Omitted (`disableVersioning`) | Built |
19
+ | Versioned docs (`57.1.0`, `0.30.0`, …) | Omitted | Prebuilt archives unpacked under `/<version>/` |
20
20
  | JS minify | Off | On |
21
21
  | Local search index | Skipped | Built |
22
22
  | Generated API + component MDX | Restored from cache when `api`/`rtk`/`ui` hashes match | Same cache, then full generate on miss |
23
23
  | TypeDoc workspace `tsc` | Once per generate (api+rtk deps share a process) | Same |
24
24
  | Rspack cache (`node_modules/.cache/rspack`) | Restored per `pull_request` vs `push` | Separate production key |
25
25
 
26
- Production still builds every version. Do not rely on `/57.1.0/…` URLs in a
27
- PR deploy preview.
26
+ Do not rely on `/57.1.0/…` URLs in a PR deploy preview.
28
27
 
29
28
  ## Other high-cost jobs
30
29