@terreno/mcp 57.10.1 → 57.11.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.
- package/dist/docs/upgrades/57.11.0.md +80 -0
- package/dist/docs/versioned/next/explanation/durable-agent-harness.md +17 -4
- package/dist/docs/versioned/next/explanation/local-first-data.md +1 -0
- package/dist/docs/versioned/next/how-to/README.md +1 -0
- package/dist/docs/versioned/next/how-to/call-external-apis.md +1 -1
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
- package/dist/docs/versioned/next/how-to/preview-demo.md +7 -3
- package/dist/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
- package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +1 -1
- package/dist/docs/versioned/next/reference/ai-harness.md +105 -12
- package/dist/docs/versioned/next/reference/api.md +64 -2
- package/dist/docs/versioned/next/reference/environment-variables.md +1 -1
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
- package/dist/docs/versioned/next/reference/syncdb.md +156 -6
- package/dist/docs/versioned/next/reference/ui.md +6 -5
- package/dist/docs/versioned/next/tasks/durable-agent-harness.md +4 -2
- package/package.json +3 -3
- package/src/docs/upgrades/57.11.0.md +80 -0
- package/src/docs/versioned/next/explanation/durable-agent-harness.md +17 -4
- package/src/docs/versioned/next/explanation/local-first-data.md +1 -0
- package/src/docs/versioned/next/how-to/README.md +1 -0
- package/src/docs/versioned/next/how-to/call-external-apis.md +1 -1
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
- package/src/docs/versioned/next/how-to/preview-demo.md +7 -3
- package/src/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
- package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +1 -1
- package/src/docs/versioned/next/reference/ai-harness.md +105 -12
- package/src/docs/versioned/next/reference/api.md +64 -2
- package/src/docs/versioned/next/reference/environment-variables.md +1 -1
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
- package/src/docs/versioned/next/reference/syncdb.md +156 -6
- package/src/docs/versioned/next/reference/ui.md +6 -5
- package/src/docs/versioned/next/tasks/durable-agent-harness.md +4 -2
|
@@ -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.
|
|
@@ -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).
|
|
51
|
-
|
|
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
|
|
241
|
-
| `JobsRunner` |
|
|
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
|
|
|
@@ -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`
|
|
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
|
|
|
@@ -152,7 +152,37 @@ import {useQuery} from "@terreno/syncdb/react";
|
|
|
152
152
|
const todos = useQuery<Todo>("todos", {filter: (t) => !t.completed});
|
|
153
153
|
```
|
|
154
154
|
|
|
155
|
-
`filter` / `sort` run in JS
|
|
155
|
+
`where` / `filter` / `sort` / `limit` run in JS over the local store. Tombstones are excluded unless `{includeDeleted: true}`.
|
|
156
|
+
|
|
157
|
+
### Filtered, sorted, and paged list args
|
|
158
|
+
|
|
159
|
+
RTK list args map to the same syntax. Pick the hook by collection size:
|
|
160
|
+
|
|
161
|
+
| RTK | Whole collection synced (`collections`) | Too large to sync whole (`queryCollections`) |
|
|
162
|
+
|-----|------------------------------------------|----------------------------------------------|
|
|
163
|
+
| `useGetMessagesQuery({threadId})` | `useQuery("messages", {where: {threadId}})` | `useWindowQuery("messages", {where: {threadId}})` |
|
|
164
|
+
| `{status: {$in: ["open", "pending"]}}` | `where: {status: {$in: [...]}}` | same |
|
|
165
|
+
| `{sort: "-created"}` | `sort: "-created"` | same |
|
|
166
|
+
| `{limit: 20}` | `limit: 20` | `pageSize: 20` |
|
|
167
|
+
| `{page: 2}` / load more | grow `limit` | `fetchNextPage()` |
|
|
168
|
+
| `data.more` / `data.total` | — | `hasMore` / `total` |
|
|
169
|
+
| `isLoading` / `isFetching` / `refetch` | not needed | `isLoading` / `isFetching` / `refetch` |
|
|
170
|
+
| `{skip: !threadId}` | not needed | `skip: !threadId` |
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import {useWindowQuery} from "@terreno/syncdb/react";
|
|
174
|
+
|
|
175
|
+
const {data: messages, hasMore, fetchNextPage, isLoading} = useWindowQuery<Message>("messages", {
|
|
176
|
+
where: {threadId},
|
|
177
|
+
sort: "-created",
|
|
178
|
+
pageSize: 30,
|
|
179
|
+
skip: !threadId,
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Windows with different filters keep separate result lists but share one local row per
|
|
184
|
+
document, so an edit to a message shown in two windows updates once. See
|
|
185
|
+
[Query windows](../reference/syncdb.md#query-windows).
|
|
156
186
|
|
|
157
187
|
### Delete this RTK read code
|
|
158
188
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# Preview a component demo
|
|
2
2
|
|
|
3
|
-
Open a component demo and change the shareable query. The preview
|
|
4
|
-
dev component routes
|
|
3
|
+
Open a component demo and change the shareable query. The preview controls sit in the
|
|
4
|
+
navigation header on demo and dev component routes: dropdowns for theme, viewport, and
|
|
5
|
+
background, plus a settings button (sliders icon) that opens a modal for right-to-left,
|
|
6
|
+
reduced motion, locale, and the share query. In windows narrower than 768px every
|
|
7
|
+
preview control moves into that modal; the Dev/Demo mode link stays in the header. `?embed=1` applies the same query and hides
|
|
8
|
+
the controls.
|
|
5
9
|
|
|
6
10
|
```text
|
|
7
11
|
/demo/Button?theme=dark&viewport=375&background=inverse&locale=en-US&rtl=1&reducedMotion=1
|
|
@@ -16,4 +20,4 @@ dev component routes. `?embed=1` applies the same query and hides the bar.
|
|
|
16
20
|
| `rtl` | `1` sets document direction to `rtl` on web |
|
|
17
21
|
| `reducedMotion` | `1` sets `data-reduced-motion="1"` on the document element |
|
|
18
22
|
|
|
19
|
-
Reload keeps the query. The
|
|
23
|
+
Reload keeps the query. The settings modal shows the current query when any value is not the default. Choosing a control's default removes that key from the URL. Leaving the component route restores the app theme and, on web, document direction and reduced motion. Custom palettes stay on `/palette`.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Run the harness on multiple instances
|
|
2
|
+
|
|
3
|
+
Run durable harness tasks on every instance of a scaled-out backend (for example several
|
|
4
|
+
Cloud Run instances). Swap the default `InProcessRunner`, where one process drains every
|
|
5
|
+
task, for `JobsRunner`, which runs each phase as a `@terreno/jobs` job. API:
|
|
6
|
+
[AI harness reference → JobsRunner](../reference/ai-harness.md#jobsrunner). Why it is safe:
|
|
7
|
+
[Durable agent harness → Runners](../explanation/durable-agent-harness.md#runners).
|
|
8
|
+
|
|
9
|
+
You need a working harness (see [Build a durable workflow](build-a-durable-workflow.md)) and
|
|
10
|
+
a MongoDB replica set. Task data does not change, so you can switch runners without a
|
|
11
|
+
migration.
|
|
12
|
+
|
|
13
|
+
## 1. Install the jobs package (1 minute)
|
|
14
|
+
|
|
15
|
+
`@terreno/jobs` is an optional peer of `@terreno/ai`. Add it to the app:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add @terreno/jobs
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 2. Open the harness with a JobsRunner (5 minutes)
|
|
22
|
+
|
|
23
|
+
Create the `JobsApp` first, hand it to the runner, and register it on the app. Build the
|
|
24
|
+
app before the harness starts: `harness.start()` throws when no `JobsApp` is registered.
|
|
25
|
+
The runner defines the `terreno.harness.phase` job on it.
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import {Harness} from "@terreno/ai/harness";
|
|
29
|
+
import {JobsRunner} from "@terreno/ai/harness/jobsRunner";
|
|
30
|
+
import {TerrenoApp} from "@terreno/api";
|
|
31
|
+
import {JobsApp} from "@terreno/jobs";
|
|
32
|
+
|
|
33
|
+
const jobs = new JobsApp();
|
|
34
|
+
const harness = await Harness.open({
|
|
35
|
+
registry: [intakeSummary, summarizer],
|
|
36
|
+
runner: new JobsRunner({jobs}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const app = new TerrenoApp({userModel: User}).register(jobs);
|
|
40
|
+
// ...register HarnessApp and your routes, then build or start the server.
|
|
41
|
+
app.start();
|
|
42
|
+
|
|
43
|
+
await harness.start();
|
|
44
|
+
await jobs.startWorker();
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Shut down in the reverse order so phases in flight finish:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
await harness.stop(); // stops dispatching, waits for this instance's phases
|
|
51
|
+
await jobs.stopWorker();
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 3. Run the same code on every instance
|
|
55
|
+
|
|
56
|
+
Every instance runs the same startup. Each one:
|
|
57
|
+
|
|
58
|
+
- scans for runnable tasks and enqueues one job per runnable phase (duplicates collapse on
|
|
59
|
+
the job's idempotency key),
|
|
60
|
+
- runs phase jobs from the shared jobs queue, and
|
|
61
|
+
- sweeps task leases that expired because an instance died.
|
|
62
|
+
|
|
63
|
+
No instance is special, and none waits on standby.
|
|
64
|
+
|
|
65
|
+
## 4. Check it works (2 minutes)
|
|
66
|
+
|
|
67
|
+
1. Start two backend processes against the same database (different `PORT`s).
|
|
68
|
+
2. Create a task (for the example backend, run `startClinicalIntake`).
|
|
69
|
+
3. Open the admin **Jobs** screen. Each phase shows as its own `terreno.harness.phase` job
|
|
70
|
+
with payload `{taskId}`.
|
|
71
|
+
4. Kill one process mid-phase. Within one lease (`leaseDuration`, default 30 s) the other
|
|
72
|
+
process replays a `replay: "safe"` phase, or parks any other phase as `interrupted`.
|
|
73
|
+
|
|
74
|
+
## Tune it
|
|
75
|
+
|
|
76
|
+
| Want | Set |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| Faster pickup of new work | `new JobsRunner({jobs, pollInterval: {milliseconds: 200}})` and `new JobsApp({pollIntervalMs: 200})` |
|
|
79
|
+
| Faster crash recovery | A shorter `leaseDuration` with `heartbeatInterval` about a third of it |
|
|
80
|
+
| Phases longer than the jobs lock TTL | Nothing: the task lease, renewed by heartbeats, decides who commits |
|
|
81
|
+
|
|
82
|
+
## Troubleshoot
|
|
83
|
+
|
|
84
|
+
- **Jobs fail with `harness-runner-stopped`.** A jobs worker runs on an instance whose
|
|
85
|
+
harness is not started. Start the harness before `jobs.startWorker()`. The jobs worker
|
|
86
|
+
retries the job, so no task is lost.
|
|
87
|
+
- **`harness.start()` throws "JobsRunner needs a registered JobsApp".** The dispatcher
|
|
88
|
+
enqueues through the registered jobs service, and `TerrenoApp` registers plugins when it
|
|
89
|
+
builds. Call `app.start()` (or `build()`) before `harness.start()`.
|
|
90
|
+
- **A task stays `pending`.** Check that its `name@version` is in the registry of the
|
|
91
|
+
running instances. A runner only dispatches registered versions.
|
|
92
|
+
- **Pick one runner per database.** Mixing them is safe (every claim and commit is fenced),
|
|
93
|
+
but an `InProcessRunner` owner then claims tasks itself and runs them to a stop.
|
|
@@ -140,7 +140,7 @@ On resume, a task whose status is `running` with an expired lease was interrupte
|
|
|
140
140
|
| Runner | Ownership | Use |
|
|
141
141
|
|---|---|---|
|
|
142
142
|
| `InProcessRunner` (default) | One process holds the `HarnessOwner` lease (heartbeat). A second process gets `standby` and takes over on lease expiry. Polls runnable tasks; wakes on `runAt` and events. | Local agents, single-instance apps, tests |
|
|
143
|
-
| `JobsRunner` (`@terreno/ai/harness/jobsRunner`, optional peer `@terreno/jobs`) | Each runnable phase → job `terreno.harness.phase`, idempotency key `taskId:phase:attempt
|
|
143
|
+
| `JobsRunner` (`@terreno/ai/harness/jobsRunner`, optional peer `@terreno/jobs`) | Each runnable phase → job `terreno.harness.phase`, idempotency key `taskId:phase:attempt:claims` (`claims` counts claims, so a phase revisited by an event wake or crash replay gets a new job; jobs idempotency keys are permanent). Handler acquires the task lease (fenced, heartbeat) and runs one phase. The task lease, not the job lock, is the authority. | Multi-instance Cloud Run |
|
|
144
144
|
|
|
145
145
|
### Ownership tree
|
|
146
146
|
|
|
@@ -141,7 +141,7 @@ The `task` view passed to `run`: `{id, name, version, input, state, phase, attem
|
|
|
141
141
|
| --- | --- |
|
|
142
142
|
| `Harness.open({registry, runner?, models?, env?, priceMap?, streaming?, testHooks?})` | Checks requirements, rejects a duplicate `name@version` or agent name, ensures collections and indexes exist. `runner` defaults to `new InProcessRunner()`. See [open options](#open-options). |
|
|
143
143
|
| `start()` | Throws, claiming nothing, when any non-terminal task uses a `name@version` missing from the registry (see [Versioning](#versioning)). Then starts the runner. Throws when already started. The runner recovers expired tasks once it owns execution. |
|
|
144
|
-
| `stop()` | Stops claiming work, waits (without a time limit) for
|
|
144
|
+
| `stop()` | Stops claiming work, waits (without a time limit) for every phase in flight while still renewing the owner lease, then releases it. No-op when not started. |
|
|
145
145
|
| `createTask(definition, input, {requestId?, userId?})` | Inserts a `pending` task, its `ObsTrace`, and its root span in one transaction. Wakes the runner. The definition must be in the registry. |
|
|
146
146
|
| `resolveInterrupted(id, {action, reason, result?, userId?})` | Resolve an `interrupted` task. See [resolveInterrupted](#resolveinterrupted). |
|
|
147
147
|
| `abort(id, {reason, userId?})` | Abort a task and every non-terminal task it owns, bottom-up. See [abort](#abort). |
|
|
@@ -166,7 +166,7 @@ some), throws. A soft-deleted task still owns its `requestId`.
|
|
|
166
166
|
| `models` | `({provider, modelId}) => LanguageModel` | Resolves an agent's model refs to Vercel AI SDK models. Required when `registry` lists an agent. |
|
|
167
167
|
| `env` | `ExecutionEnv` | Handed to phases as `rt.env` and to tools as `api.env`. Optional. |
|
|
168
168
|
| `priceMap` | `Record<modelId, {inputPerMTok, outputPerMTok}>` | Prices LLM spans (`usage.costUsd`). Defaults to the registered `ObservabilityApp`'s `priceMap`, read at call time. |
|
|
169
|
-
| `runner` | `HarnessRunner` | Default `new InProcessRunner()`. |
|
|
169
|
+
| `runner` | `HarnessRunner` | Default `new InProcessRunner()`. For many instances, `JobsRunner` (see [JobsRunner](#jobsrunner)). |
|
|
170
170
|
| `streaming` | `{deltaFlushChars?, deltaFlushInterval?, deltaTtl?}` | How streamed model text becomes `delta` events. Defaults: 200 characters, `{milliseconds: 250}`, `{hours: 1}`. Non-positive values throw. See [Event stream (SSE)](#event-stream-sse). |
|
|
171
171
|
| `testHooks` | `HarnessTestHooks` | Test-only. See [Testing](#testing). |
|
|
172
172
|
|
|
@@ -183,7 +183,8 @@ const harness = await Harness.open({
|
|
|
183
183
|
|
|
184
184
|
| Option | Default | Description |
|
|
185
185
|
| --- | --- | --- |
|
|
186
|
-
| `
|
|
186
|
+
| `concurrency` | `8` | Most claimed tasks run at once. Must be a positive integer, or the constructor throws. `1` runs one task at a time. |
|
|
187
|
+
| `pollInterval` | `{milliseconds: 250}` | Idle sleep between claim attempts. `createTask` and a task that frees a slot wake the runner early. |
|
|
187
188
|
| `leaseDuration` | `{seconds: 30}` | Lifetime of the owner lease and of each task lease without a renewal. Luxon `DurationLike`. |
|
|
188
189
|
| `heartbeatInterval` | `{seconds: 10}` | How often both leases are renewed. Must be positive and shorter than `leaseDuration`, or the constructor throws. |
|
|
189
190
|
| `ownerId` | `hostname:pid:uuid` | Id written into every lease this runner holds. |
|
|
@@ -197,13 +198,101 @@ Only the owner claims work. It claims the oldest `pending` task whose `name@vers
|
|
|
197
198
|
registered, whose `runAt` is empty or past (by Luxon's `DateTime.now()`), and that has no
|
|
198
199
|
`abortRequested`. It sets the task `running` with a fresh task lease and runs phases one
|
|
199
200
|
after another until the task stops: terminal, `pending` for a retry, `waiting`, or
|
|
200
|
-
aborted.
|
|
201
|
-
|
|
201
|
+
aborted. An idle owner re-polls every `pollInterval`, so a retry starts within one poll of
|
|
202
|
+
its `runAt`.
|
|
203
|
+
|
|
204
|
+
Up to `concurrency` tasks run at once, each under its own task lease and heartbeat, so a
|
|
205
|
+
slow phase (a long model call, a phase blocked on I/O) does not delay other tasks. Ordering
|
|
206
|
+
guarantees:
|
|
207
|
+
|
|
208
|
+
- Tasks are **claimed** oldest first (`created`), one claim at a time, while a slot is free.
|
|
209
|
+
- With `concurrency` above 1, claimed tasks run in parallel, so a later task can finish
|
|
210
|
+
first. Use `concurrency: 1` for strict one-at-a-time order.
|
|
211
|
+
- Phases of one task always run in order, and a phase never commits twice: the claim is an
|
|
212
|
+
atomic update, and every commit is fenced on the task lease. (A runner that froze past
|
|
213
|
+
its lease may still be executing phase code while recovery replays it; only its commit
|
|
214
|
+
is rejected.)
|
|
215
|
+
- Turns of one conversation stay serial regardless of `concurrency`; turns of different
|
|
216
|
+
conversations, and sibling child tasks, run in parallel.
|
|
217
|
+
- `stop()` stops claiming and waits for every task in flight.
|
|
218
|
+
|
|
219
|
+
Before Phase 2 the runner ran one task at a time. Upgrading to the default of 8 means
|
|
220
|
+
parallel model calls (watch provider rate limits and cost) and more stream-counter retries
|
|
221
|
+
(see [Implementation notes](#implementation-notes)). Pass `concurrency: 1` to keep the old
|
|
222
|
+
behavior.
|
|
223
|
+
|
|
224
|
+
### JobsRunner
|
|
225
|
+
|
|
226
|
+
Runs phases as `@terreno/jobs` jobs so any number of instances share the work. Import it
|
|
227
|
+
from its own entry point; `@terreno/jobs` is an optional peer dependency, and importing
|
|
228
|
+
`@terreno/ai` or `@terreno/ai/harness` never loads it. How-to:
|
|
229
|
+
[Run the harness on multiple instances](../how-to/run-harness-on-multiple-instances.md).
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
import {JobsApp} from "@terreno/jobs";
|
|
233
|
+
import {Harness} from "@terreno/ai/harness";
|
|
234
|
+
import {JobsRunner} from "@terreno/ai/harness/jobsRunner";
|
|
235
|
+
|
|
236
|
+
const jobs = new JobsApp();
|
|
237
|
+
const harness = await Harness.open({registry, runner: new JobsRunner({jobs})});
|
|
238
|
+
app.register(jobs).build(); // the runner enqueues through the registered jobs service
|
|
239
|
+
await harness.start();
|
|
240
|
+
await jobs.startWorker();
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
| Option | Default | Description |
|
|
244
|
+
| --- | --- | --- |
|
|
245
|
+
| `jobs` | required | The `JobsApp`. The constructor defines `terreno.harness.phase` on it (`HARNESS_PHASE_JOB_NAME`). Register it and build the app before `harness.start()`; otherwise `start()` throws `harness-config-invalid`. |
|
|
246
|
+
| `pollInterval` | `{milliseconds: 500}` | Sleep between dispatch scans. `createTask`, `sendEvent`, and each finished phase wake the dispatcher early. |
|
|
247
|
+
| `leaseDuration` | `{seconds: 30}` | Lifetime of each task lease without a renewal. |
|
|
248
|
+
| `heartbeatInterval` | `{seconds: 10}` | How often a running phase renews its task lease, and how often this instance sweeps expired leases. Must be positive and shorter than `leaseDuration`, or the constructor throws. |
|
|
249
|
+
| `ownerId` | `hostname:pid:uuid` | Id written into every task lease this runner holds. |
|
|
250
|
+
| `dispatchBatchSize` | `100` | Most runnable tasks one scan enqueues. Must be a positive integer, or the constructor throws. |
|
|
251
|
+
|
|
252
|
+
Every instance runs a dispatcher and a jobs worker. There is no owner lease.
|
|
253
|
+
|
|
254
|
+
1. **Dispatch.** Each scan lists runnable tasks (the same rule `InProcessRunner` claims by)
|
|
255
|
+
and enqueues one `terreno.harness.phase` job per task with payload `{taskId}` and
|
|
256
|
+
idempotency key `taskId:phase:attempt:claims`. `claims` counts how often the task was
|
|
257
|
+
claimed, so each runnable visit of a phase (first run, retry, event wake, replay) gets
|
|
258
|
+
its own job, and repeated scans or instances enqueue it once. When the job with that key
|
|
259
|
+
already ended without claiming the task (for example it died after its retries, or ran
|
|
260
|
+
on an instance mid-deploy that did not register the task), the dispatcher follows or
|
|
261
|
+
extends the chain `<key>:<endedJobId>` until a job is in flight, so the task is not
|
|
262
|
+
stranded.
|
|
263
|
+
2. **Run one phase.** The job handler claims that task under a fresh fenced task lease,
|
|
264
|
+
runs **one** phase with heartbeats, and hands a task that is still `running` back as
|
|
265
|
+
`pending` (lease cleared) for the next job. It then wakes the dispatcher. A handler
|
|
266
|
+
whose task is no longer runnable (another job claimed it, or it is aborting) does
|
|
267
|
+
nothing and succeeds.
|
|
268
|
+
3. **Recover.** Each instance sweeps expired task leases once per `heartbeatInterval`, with
|
|
269
|
+
the same replay and interruption rules as `InProcessRunner`. A replayed task becomes
|
|
270
|
+
runnable with a new `claims` value, so it is dispatched again.
|
|
271
|
+
|
|
272
|
+
Load: with N instances, the recovery sweep (expired leases, settled child waits, stranded
|
|
273
|
+
queued conversations) runs N times per `heartbeatInterval`, where `InProcessRunner` runs it
|
|
274
|
+
once, and each scan does one job lookup per runnable task (up to `dispatchBatchSize`). The
|
|
275
|
+
sweeps are fenced, so this costs queries, not correctness. Raise `heartbeatInterval` and
|
|
276
|
+
`pollInterval` for large fleets.
|
|
277
|
+
|
|
278
|
+
The task lease, not the job lock, is the authority. Two jobs for one phase race on the
|
|
279
|
+
atomic claim, and only one runs it. A worker that froze past its lease cannot commit: its
|
|
280
|
+
`rt.commit` throws `HarnessCommitConflictError`. A phase that runs longer than the jobs
|
|
281
|
+
lock TTL is safe for the same reason. A job that runs on an instance whose harness is not
|
|
282
|
+
started (or is stopping) throws `harness-runner-stopped`, so the jobs worker retries it.
|
|
283
|
+
|
|
284
|
+
`stop()` stops dispatching and waits for every phase this instance is running. Stop the
|
|
285
|
+
jobs worker (`jobs.stopWorker()`) after `harness.stop()`.
|
|
286
|
+
|
|
287
|
+
### Custom runners
|
|
202
288
|
|
|
203
289
|
A custom runner implements `HarnessRunner` (`start(context)`, `stop()`, `wake()`). The
|
|
204
290
|
`context` provides `acquireOwnerLease(lease)`, `releaseOwnerLease(lease)`,
|
|
205
|
-
`recoverExpired()`, `claimNext(lease)`,
|
|
206
|
-
|
|
291
|
+
`recoverExpired()`, `claimNext(lease)`, `claimTask(taskId, lease)` (claim one task by id
|
|
292
|
+
when it is runnable now, else `null`), `listRunnable(limit)` (oldest runnable tasks as
|
|
293
|
+
`{taskId, phase, attempt, claims}`, unclaimed), and `runTask(task, lease, {maxPhases?})`,
|
|
294
|
+
where `lease` is `{owner, duration, heartbeat}` (Luxon `Duration`s). With `maxPhases`,
|
|
295
|
+
`runTask` hands a task that is still `running` after that many phases back as `pending`.
|
|
207
296
|
|
|
208
297
|
## Runtime (rt)
|
|
209
298
|
|
|
@@ -261,7 +350,9 @@ Assumptions:
|
|
|
261
350
|
recovery treats them as expired.
|
|
262
351
|
|
|
263
352
|
Recovery runs when a runner becomes owner (on `start()` or on takeover) and on every owner
|
|
264
|
-
heartbeat.
|
|
353
|
+
heartbeat. `JobsRunner` has no owner lease: every instance sweeps once per
|
|
354
|
+
`heartbeatInterval`, and concurrent sweeps are fenced, so each expired task is recovered
|
|
355
|
+
once. It scans up to 100 `running` tasks whose lease expired (or that have no lease),
|
|
265
356
|
oldest first, for registered `name@version`s only. A stopping owner skips the sweep. A task
|
|
266
357
|
whose interruption commit keeps failing for a non-conflict reason is logged and retried on
|
|
267
358
|
the next sweep.
|
|
@@ -1503,6 +1594,7 @@ empty objects are kept (`minimize: false`), so an initial state `{}` is stored a
|
|
|
1503
1594
|
| `outcome` | `{status, result, error}` | Set on terminal commit. |
|
|
1504
1595
|
| `attempt` | Number | Failed attempts of the current phase. Default 0. |
|
|
1505
1596
|
| `step` | Number | Phase commits so far; names the current phase visit for idempotent child creation. Default 0. |
|
|
1597
|
+
| `claims` | Number | Times a runner claimed the task. Numbers each runnable visit; `JobsRunner` puts it in the job idempotency key. Default 0; a missing value reads as 0. |
|
|
1506
1598
|
| `retry` | `{maxAttempts, backoffMs, maxBackoffMs}` | Copied from the definition. |
|
|
1507
1599
|
| `ownership` | `{kind: root \| task \| conversation, id}` | Default `root`. `rt.createTask` children are `{kind: "task", id: <parent>}`; agent turns are `{kind: "conversation", id}`, except subagent turns, which are `{kind: "task", id: <caller>}`. |
|
|
1508
1600
|
| `rootTaskId` | ObjectId | Top of the ownership tree; equals `_id` for root tasks. |
|
|
@@ -1686,12 +1778,13 @@ lists every kind:
|
|
|
1686
1778
|
| `code` | `status` | `title` | Kinds of failure |
|
|
1687
1779
|
| --- | --- | --- | --- |
|
|
1688
1780
|
| `harness-definition-invalid` | 500 | Invalid harness definition | `defineTask` / `defineTool` / `defineAgent` / `defineExtension` / `approvalGate` validation, and `rt.*` misuse inside a phase. Thrown as `HarnessDefinitionError`, which fails the task at once (no retry). |
|
|
1689
|
-
| `harness-config-invalid` | 500 | Invalid harness configuration | `Harness.open`, `HarnessApp`, `InProcessRunner`, `streaming`, and registry problems. |
|
|
1781
|
+
| `harness-config-invalid` | 500 | Invalid harness configuration | `Harness.open`, `HarnessApp`, `InProcessRunner` (lease timing, `concurrency`), `JobsRunner`, `streaming`, and registry problems. |
|
|
1690
1782
|
| `harness-replica-set-required` | 500 | MongoDB replica set required | `Harness.open` on a deployment without transactions. |
|
|
1691
|
-
| `harness-invalid-request` | 400 | Invalid harness request | A caller passed a bad argument (`abort`, `sendEvent`, `decideApproval`, `resolveInterrupted`, `submit` / `send`, `createConversation`, memos). |
|
|
1783
|
+
| `harness-invalid-request` | 400 | Invalid harness request | A caller passed a bad argument (`abort`, `sendEvent`, `decideApproval`, `resolveInterrupted`, `submit` / `send`, `createConversation`, memos, a `terreno.harness.phase` payload without `taskId`). |
|
|
1692
1784
|
| `harness-not-registered` | 404 | Not registered in this harness | A task definition, agent, or extension the registry lacks. |
|
|
1693
1785
|
| `harness-not-found` | 404 | Harness record not found | A conversation or child task disappeared. |
|
|
1694
|
-
| `harness-already-started` | 409 | Already started | `Harness.start` / `InProcessRunner.start` called twice. |
|
|
1786
|
+
| `harness-already-started` | 409 | Already started | `Harness.start` / `InProcessRunner.start` / `JobsRunner.start` called twice. |
|
|
1787
|
+
| `harness-runner-stopped` | 503 | Harness runner is not running | A `terreno.harness.phase` job ran on an instance whose harness is not started; the jobs worker retries it. |
|
|
1695
1788
|
| `harness-commit-conflict` | 409 | Harness commit lost its checkpoint fence | `HarnessCommitConflictError`. |
|
|
1696
1789
|
| `harness-approval-not-pending` | 409 | Approval can no longer be decided | `HarnessApprovalConflictError`. |
|
|
1697
1790
|
| `harness-conversation-busy` | 409 | Conversation is busy | `HarnessConversationBusyError`. |
|
|
@@ -1809,7 +1902,7 @@ Low-risk choices made in the first slice:
|
|
|
1809
1902
|
| The SSE tail starts at a cluster time read before any subscriber's replay query | No gap between replay and tail; overlap is removed by `seq`. |
|
|
1810
1903
|
| One shared change stream per `HarnessApp`, not one per connection | A change stream holds a pooled connection while it waits; per-connection streams let a few dozen viewers starve commits and lease renewals. |
|
|
1811
1904
|
| Queue recovery is a sweep by the execution owner, not part of the turn's terminal commit | Starting a turn is its own transaction; the sweep (fenced, idempotent) covers the crash window between the two. |
|
|
1812
|
-
| Writers to one stream conflict on its counter document | The price of commit-ordered `seq`s. `withTransaction` retries the loser. `InProcessRunner`
|
|
1905
|
+
| Writers to one stream conflict on its counter document | The price of commit-ordered `seq`s. `withTransaction` retries the loser. `InProcessRunner` (default `concurrency: 8`) and `JobsRunner` both run sibling tasks of one tree in parallel, so their writes to the shared root stream retry alongside HTTP submits, approvals, and deltas. `InProcessRunner({concurrency: 1})` avoids task-on-task contention. |
|
|
1813
1906
|
| Interruption spans start at `lease.acquiredAt` | Records when the cut-off phase began. |
|
|
1814
1907
|
| `resolveInterrupted({action: "abort"})` closes the trace as `error` | `ObsTrace.status` is `ok` or `error`; an abort is not a success. |
|
|
1815
1908
|
| `requestId` replays are scoped to the same `userId` | Stops one caller from reading another user's task through a shared key. |
|
|
@@ -41,7 +41,7 @@ REST API framework built on Express and Mongoose. Provides modelRouter (CRUD end
|
|
|
41
41
|
- Validation: `configureOpenApiValidator`, `validateRequestBody`, `validateQueryParams`, `createValidator`
|
|
42
42
|
- Middleware: `openApiEtagMiddleware`, `sentryAppVersionMiddleware`
|
|
43
43
|
- Extensibility: `TerrenoPlugin` interface
|
|
44
|
-
- Notifiers: `sendToSlack`, `formatSlackUserMention`, `lookupSlackUserIdByEmail`, `sendToGoogleChat`, `sendToZoom`
|
|
44
|
+
- Notifiers: `sendToSlack`, `formatSlackUserMention`, `lookupSlackUserIdByEmail`, `createSlackPrivateChannel`, `inviteSlackUsersToChannel`, `postSlackMessage`, `findSlackChannelByName`, `sendToGoogleChat`, `sendToZoom`
|
|
45
45
|
- HTTP client: `createAuthenticatedClient`, `withApiErrorHandling`, `normalizeApiError`, `markRetryUnsafe`
|
|
46
46
|
|
|
47
47
|
## Server Setup
|
|
@@ -224,6 +224,30 @@ export const todoRouter = modelRouter("/todos", Todo, {
|
|
|
224
224
|
Do not add `endpoints: (router) => { router.get(...) }` when an action fits.
|
|
225
225
|
Action config supports `allowAnonymous: true` as a per-action override; it does not change authentication for CRUD or sibling actions.
|
|
226
226
|
|
|
227
|
+
## modelRouter list queries
|
|
228
|
+
|
|
229
|
+
`GET /{path}` accepts filters on fields listed in `queryFields` (qs syntax, so operators work:
|
|
230
|
+
`?status[$in][0]=open&created[$gte]=2026-01-01`), plus the reserved `limit`, `page`, and `sort`.
|
|
231
|
+
`limit` is clamped to `maxLimit` (default 500); the response echoes the applied `limit` with
|
|
232
|
+
`{data, limit, more, page, total}`.
|
|
233
|
+
|
|
234
|
+
A filter on a field missing from `queryFields` is rejected before query validation runs, so
|
|
235
|
+
it can never be stripped and silently ignored:
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"status": 400,
|
|
240
|
+
"code": "query-param-not-allowed",
|
|
241
|
+
"title": "Query parameter not allowed",
|
|
242
|
+
"detail": "title is not allowed as a query param. Add \"title\" to queryFields on the Todo modelRouter to filter on it. Allowed: completed, created, ownerId.",
|
|
243
|
+
"source": {"parameter": "title"},
|
|
244
|
+
"meta": {"queryParam": "title", "model": "Todo", "allowedQueryFields": ["completed", "created", "ownerId"]}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`@terreno/syncdb` raises this as `QueryFieldNotAllowedError` (see
|
|
249
|
+
[Query windows](syncdb.md#query-windows)).
|
|
250
|
+
|
|
227
251
|
## modelRouter array operations
|
|
228
252
|
|
|
229
253
|
When a model has at least one array field, `modelRouter` provides subroutes for adding,
|
|
@@ -1581,6 +1605,44 @@ await sendToSlack(`${formatSlackUserMention("U012ABCDEF")} Deployment complete`,
|
|
|
1581
1605
|
});
|
|
1582
1606
|
``````
|
|
1583
1607
|
|
|
1608
|
+
Incoming webhooks are fixed to the channel chosen when the webhook was created.
|
|
1609
|
+
Private channels, invites, and messages to a channel id use the bot token for
|
|
1610
|
+
one Slack workspace (`SLACK_BOT_TOKEN`):
|
|
1611
|
+
|
|
1612
|
+
| Function | Slack method | Bot scopes |
|
|
1613
|
+
| --- | --- | --- |
|
|
1614
|
+
| `createSlackPrivateChannel` | `conversations.create`, `conversations.invite`, `chat.postMessage` | `groups:write`, `groups:write.invites`, `chat:write` |
|
|
1615
|
+
| `inviteSlackUsersToChannel` | `conversations.invite` | `groups:write.invites` for private channels, `channels:write.invites` for public |
|
|
1616
|
+
| `postSlackMessage` | `chat.postMessage` | `chat:write` |
|
|
1617
|
+
| `findSlackChannelByName` | `conversations.list` | `channels:read`, `groups:read` |
|
|
1618
|
+
| `lookupSlackUserIdByEmail` | `users.lookupByEmail` | `users:read.email` |
|
|
1619
|
+
|
|
1620
|
+
The bot can post and invite only in channels it belongs to. Creating a private
|
|
1621
|
+
channel makes the bot a member. An existing private channel must already
|
|
1622
|
+
include the app; a bot cannot join a private channel by itself. Store the
|
|
1623
|
+
returned channel id. Names are unique, mutable, and limited to lowercase
|
|
1624
|
+
letters, numbers, hyphens, and underscores.
|
|
1625
|
+
|
|
1626
|
+
``````typescript
|
|
1627
|
+
import {createSlackPrivateChannel, inviteSlackUsersToChannel} from "@terreno/api";
|
|
1628
|
+
|
|
1629
|
+
const channel = await createSlackPrivateChannel({
|
|
1630
|
+
name: "case-12345",
|
|
1631
|
+
userIds: ["U012ABCDEF"],
|
|
1632
|
+
initialMessage: "Case opened.",
|
|
1633
|
+
mentionUserIds: ["U012ABCDEF"],
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
await inviteSlackUsersToChannel({
|
|
1637
|
+
channelId: channel.channelId,
|
|
1638
|
+
userIds: ["U098765432"],
|
|
1639
|
+
});
|
|
1640
|
+
``````
|
|
1641
|
+
|
|
1642
|
+
A taken name throws status 409. `findSlackChannelByName({name})` returns a
|
|
1643
|
+
public channel, or a private channel the bot is already in. It returns
|
|
1644
|
+
`undefined` when that channel is not visible to the bot.
|
|
1645
|
+
|
|
1584
1646
|
### Google Chat Notifications
|
|
1585
1647
|
|
|
1586
1648
|
``````typescript
|
|
@@ -1913,7 +1975,7 @@ Complete reference of environment variables used by @terreno/api:
|
|
|
1913
1975
|
| Variable | Required | Default | Description |
|
|
1914
1976
|
|----------|----------|---------|-------------|
|
|
1915
1977
|
| `SLACK_WEBHOOKS` | No | — | JSON object mapping names to Slack webhook URLs: `{"default":"https://..."}` |
|
|
1916
|
-
| `SLACK_BOT_TOKEN` | No | — | Bot token (`xoxb-…`)
|
|
1978
|
+
| `SLACK_BOT_TOKEN` | No | — | Bot token (`xoxb-…`) for one workspace. Scopes: `users:read.email`, `groups:write`, `groups:write.invites`, `channels:write.invites`, `chat:write`, `channels:read`, `groups:read` |
|
|
1917
1979
|
| `GOOGLE_CHAT_WEBHOOKS` | No | — | JSON object mapping names to Google Chat webhook URLs |
|
|
1918
1980
|
| `ZOOM_CHAT_WEBHOOKS` | No | — | JSON object mapping names to Zoom webhook URLs |
|
|
1919
1981
|
| `WEBHOOK_SECRET` | No | — | Secret for validating incoming webhook signatures |
|
|
@@ -223,7 +223,7 @@ There is **no** `RATE_LIMIT_ENABLED` (or similar) read by `@terreno/api`. Apps t
|
|
|
223
223
|
|----------|---------|----------|---------|--------|-------|
|
|
224
224
|
| `SLACK_WEBHOOKS` | `@terreno/api` | ❌ | — | Yes | server |
|
|
225
225
|
| `SLACK_WEBHOOK_URL` | scripts | ❌ | — | Yes | server |
|
|
226
|
-
| `SLACK_BOT_TOKEN` | `@terreno/api` `lookupSlackUserIdByEmail` | ❌ | — | Yes | server |
|
|
226
|
+
| `SLACK_BOT_TOKEN` | `@terreno/api` Slack Web API (`lookupSlackUserIdByEmail`, private channels, invites, `chat.postMessage`) | ❌ | — | Yes | server |
|
|
227
227
|
| `GOOGLE_CHAT_WEBHOOKS` | `@terreno/api` | ❌ | — | Yes | server |
|
|
228
228
|
| `GOOGLE_CHAT_WEBHOOK_URL` | scripts | ❌ | — | Yes | server |
|
|
229
229
|
| `ZOOM_CHAT_WEBHOOKS` | `@terreno/api` | ❌ | — | Yes | server |
|
|
@@ -16,6 +16,17 @@ loops that invoke those stages; they are not stages and must not appear as `stag
|
|
|
16
16
|
| `terreno-4-brew` | Roast PASS for every in-scope task + branch/evidence | pushed head + PR + product-CI trigger check + review-bot wait + attached evidence | Taste |
|
|
17
17
|
| `terreno-5-taste` | PR + current state | one current-head reaction; before push: fetch latest `master` (merge only when needed), re-verify last-run failed tests locally, run root `prepush` when present (otherwise affected-package checks) in a no-context subagent, then watch CI; invoked directly, repeats bounded reactions until terminal | null or fresh Taste |
|
|
18
18
|
|
|
19
|
+
Pre-Grow skill (not a stage):
|
|
20
|
+
|
|
21
|
+
| Skill | Input | Primary output | Next |
|
|
22
|
+
| --- | --- | --- | --- |
|
|
23
|
+
| `terreno-prd` | app owner + conversation, prototype, repository | interview, then a published full product PRD (problem, why now, solution, user stories, success metrics and guardrails, constraints, rollout, out of scope); every metric measurable or `TBD` with its decider | Grow, with the PRD as its spec; metrics become acceptance criteria, `TBD`s become grilling questions, settled product decisions are not re-asked |
|
|
24
|
+
|
|
25
|
+
It is explicit-invoke only (`/terreno-prd`, `$terreno-prd`, `/terreno:prd`). It
|
|
26
|
+
interviews with Grow's grilling procedure over a product tree, is sized for one app owner
|
|
27
|
+
and one or two developers, and carries no implementation or testing decisions; those stay
|
|
28
|
+
in Grow.
|
|
29
|
+
|
|
19
30
|
Outer loops (not stages):
|
|
20
31
|
|
|
21
32
|
| Skill | What it walks | Default |
|