@repogo/plugins 0.1.34 → 0.1.37
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/README.md +380 -26
- package/dist/cache.d.ts +27 -0
- package/dist/collections.d.ts +11 -1
- package/dist/context.d.ts +117 -1
- package/dist/cron.d.ts +21 -0
- package/dist/define.d.ts +36 -11
- package/dist/generated.d.ts +16 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +100 -5
- package/dist/logs.d.ts +55 -0
- package/dist/runtime.d.ts +173 -0
- package/dist/self.d.ts +1 -0
- package/dist/testing.d.ts +53 -0
- package/dist/testing.js +3492 -0
- package/dist/tracking.d.ts +5 -0
- package/dist/uploads.d.ts +24 -0
- package/dist/workflow/attempts.d.ts +25 -0
- package/dist/workflow/availability.d.ts +8 -0
- package/dist/workflow/catalog.d.ts +21 -0
- package/dist/workflow/clock.d.ts +6 -0
- package/dist/workflow/contracts.d.ts +121 -0
- package/dist/workflow/definition.d.ts +35 -0
- package/dist/workflow/errors.d.ts +19 -0
- package/dist/workflow/events.d.ts +132 -0
- package/dist/workflow/interrupted.d.ts +3 -0
- package/dist/workflow/journal.d.ts +28 -0
- package/dist/workflow/lease.d.ts +2 -0
- package/dist/workflow/lifetime.d.ts +27 -0
- package/dist/workflow/policy.d.ts +36 -0
- package/dist/workflow/register.d.ts +11 -0
- package/dist/workflow/retry.d.ts +14 -0
- package/dist/workflow/runner.d.ts +29 -0
- package/dist/workflow/scope.d.ts +4 -0
- package/dist/workflow/service.d.ts +48 -0
- package/dist/workflow/sleep.d.ts +6 -0
- package/dist/workflow/state.d.ts +41 -0
- package/dist/workflow/storage.d.ts +22 -0
- package/dist/workflow/summary.d.ts +80 -0
- package/dist/workflow/testing.d.ts +12 -0
- package/dist/workflow-executor.js +22917 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -7,13 +7,16 @@ phone draw its data live and run its methods when you tap.
|
|
|
7
7
|
|
|
8
8
|
- [Quick start](#quick-start)
|
|
9
9
|
- [Where plugins live](#where-plugins-live)
|
|
10
|
-
- [Methods](#methods): queries, mutations, streams
|
|
11
|
-
- [Data](#data): namespaces, collections, kv, streams, buckets
|
|
10
|
+
- [Methods](#methods): queries, mutations, streams, cached queries
|
|
11
|
+
- [Data](#data): namespaces, collections, kv, streams, buckets, uploads
|
|
12
|
+
- [Workflows](#workflows)
|
|
12
13
|
- [Crons](#crons)
|
|
14
|
+
- [Logging](#logging)
|
|
13
15
|
- [The context](#the-context)
|
|
14
16
|
- [Events](#events)
|
|
15
17
|
- [How cards and agents reach a plugin](#how-cards-and-agents-reach-a-plugin)
|
|
16
18
|
- [Errors and confirmations](#errors-and-confirmations)
|
|
19
|
+
- [Testing](#testing)
|
|
17
20
|
|
|
18
21
|
## Quick start
|
|
19
22
|
|
|
@@ -62,9 +65,9 @@ project plugin hides an environment one of the same name. Each folder's plugins
|
|
|
62
65
|
run together in one Worker, so a plugin's blocking code stalls the others in its
|
|
63
66
|
folder: use async APIs.
|
|
64
67
|
|
|
65
|
-
Pages beside an environment plugin (`pages/*.md`) open from
|
|
66
|
-
`~/.repogo/plugins/<name>/pages/<page>.
|
|
67
|
-
|
|
68
|
+
Pages beside an environment plugin (`pages/*.ui`, or `pages/*.md`) open from
|
|
69
|
+
any project as `~/.repogo/plugins/<name>/pages/<page>.ui`. A
|
|
70
|
+
`.ui` page is card markup with no fence; a `.md` page is writing with cards.
|
|
68
71
|
|
|
69
72
|
## Methods
|
|
70
73
|
|
|
@@ -88,6 +91,18 @@ show it and keep it current. Give each a `description`; agents read it to choose
|
|
|
88
91
|
a method. `output`, a zod schema of the result, tells agents and cards its
|
|
89
92
|
field names.
|
|
90
93
|
|
|
94
|
+
A query that returns a list takes `limit`, with a default and a maximum in its
|
|
95
|
+
schema, and returns only what a card or the voice agent shows of each item:
|
|
96
|
+
ids, titles, status. A `get` returns one in full, and long text (a body, a log)
|
|
97
|
+
is cut short in the list. Answers stay small, so cards draw fast and an agent
|
|
98
|
+
or the voice agent reads the whole of one.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
open: method(z.object({ limit: z.number().int().min(1).max(100).default(20) }), async ({ limit }, ctx) =>
|
|
102
|
+
(await ctx.collections.notes.list({ where: { done: false }, limit })).map(({ id, text }) => ({ id, text: text.slice(0, 200) })),
|
|
103
|
+
{ kind: "query", description: "Notes not done yet, newest first" }),
|
|
104
|
+
```
|
|
105
|
+
|
|
91
106
|
A **stream** sends each `yield` to the card as it happens, for as long as the
|
|
92
107
|
card shows it. `ctx.signal` aborts when the card goes, so hand it to what you
|
|
93
108
|
start. Give items an `id`. An agent calling a stream gets what arrives within
|
|
@@ -105,6 +120,27 @@ tail: stream(z.object({ file: z.string() }), async function* ({ file }, ctx) {
|
|
|
105
120
|
}),
|
|
106
121
|
```
|
|
107
122
|
|
|
123
|
+
A **cached** query is for answers that are slow to get, such as a CLI's list
|
|
124
|
+
or another service's API. It answers at once with what it saved last time.
|
|
125
|
+
When that answer is older than `fresh`, `run` goes again in the background,
|
|
126
|
+
and a card showing it redraws only if the answer changed. With nothing saved
|
|
127
|
+
yet, the first call waits for `run`, and its error is the card's.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
projects: cached(z.object({ limit: z.number().int().min(1).max(200).default(100) }),
|
|
131
|
+
async ({ limit }) => listProjects(limit),
|
|
132
|
+
{ fresh: "1m", shared: true, description: "Every project the login can see" }),
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- Answers are kept per method, input and project (`ctx.root`). `shared: true`
|
|
136
|
+
keeps one for every project, for answers that don't depend on it.
|
|
137
|
+
- `run` is read-only, like a query's, and has `timeout` (a minute unless it says).
|
|
138
|
+
- A background check that fails keeps the saved answer, goes to the plugin's
|
|
139
|
+
logs, and is tried again after another `fresh`.
|
|
140
|
+
- Answers live in the namespace's `data.db`, so a plugin with a cached method
|
|
141
|
+
needs a `namespace`. An answer over 1 MB is returned but not saved, and one
|
|
142
|
+
nobody has asked for in 30 days is deleted.
|
|
143
|
+
|
|
108
144
|
## Data
|
|
109
145
|
|
|
110
146
|
A plugin declares its data in four kinds, each with a schema, and gets typed
|
|
@@ -125,8 +161,9 @@ plugin, another plugin, or another project.
|
|
|
125
161
|
|
|
126
162
|
`namespace` names where a plugin's data lives: the folder
|
|
127
163
|
`~/.repogo/plugin-data/<namespace>/`, holding `data.db` and its streams' and
|
|
128
|
-
buckets' files.
|
|
129
|
-
|
|
164
|
+
buckets' files. A plugin that keeps data (any collection, kv, stream or bucket)
|
|
165
|
+
must name one, or it fails to load. Plugins naming the same namespace share its
|
|
166
|
+
data, in any project, so they must declare the same schemas.
|
|
130
167
|
|
|
131
168
|
By convention a project's own data is `<project>.<name>` (`myproject.notes`),
|
|
132
169
|
and data meant for every project a bare name (`todos`). That's only a
|
|
@@ -145,10 +182,14 @@ await ctx.collections.tasks.get(task.id); // or n
|
|
|
145
182
|
await ctx.collections.tasks.update(task.id, { status: "done" }); // only the fields given change
|
|
146
183
|
await ctx.collections.tasks.list({ where: { status: "open" }, orderBy: "updatedAt", order: "desc", limit: 50 });
|
|
147
184
|
await ctx.collections.tasks.delete(task.id);
|
|
185
|
+
await ctx.collections.tasks.deleteMany({ where: { status: "done" }, offset: 100, limit: 500 }); // done ones past the newest 100, 500 at a time
|
|
148
186
|
```
|
|
149
187
|
|
|
150
188
|
`list` is newest first by default, at most 100 unless `limit` says (up to 500).
|
|
151
189
|
`where` matches top-level fields exactly; `null` matches a missing one.
|
|
190
|
+
`orderBy` is `id`, `createdAt`, `updatedAt` or one of the record's own fields, and
|
|
191
|
+
`offset` passes over that many first. `deleteMany` takes the same options and
|
|
192
|
+
deletes exactly what `list` would return, in one transaction, returning how many.
|
|
152
193
|
|
|
153
194
|
### Kv
|
|
154
195
|
|
|
@@ -248,6 +289,37 @@ checks it against the schema; with `revision`, it fails if the file changed
|
|
|
248
289
|
since you read it. Writes through `ctx` are checked; files written by hand are
|
|
249
290
|
indexed with their `status` instead.
|
|
250
291
|
|
|
292
|
+
### Uploads
|
|
293
|
+
|
|
294
|
+
A card's `<Upload>` lets the user take a photo, record a video, or pick
|
|
295
|
+
photos or files on their phone, and hands them to one of your methods as
|
|
296
|
+
`files`. Declare the field with `upload()`:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
import { definePlugin, method, upload, z } from "@repogo/plugins";
|
|
300
|
+
|
|
301
|
+
ingest: method(z.object({ files: z.array(upload()), month: z.string() }), async ({ files, month }, ctx) => {
|
|
302
|
+
for (const file of files) {
|
|
303
|
+
await ctx.buckets.receipts.put({ body: Bun.file(file.path), name: file.name, folder: month });
|
|
304
|
+
}
|
|
305
|
+
return { added: files.length };
|
|
306
|
+
}),
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Each file is `{ id, name, type, size, path }`. `type` is its MIME type, and
|
|
310
|
+
`path` is where the host staged it on the environment. The phone sends the
|
|
311
|
+
bytes before the call, encrypted like every call, and the call starts once
|
|
312
|
+
they've all arrived. A staged file is deleted a day after its last use, so
|
|
313
|
+
copy what you keep: into a bucket, as above, or wherever you like. `files` is
|
|
314
|
+
always an array, even for one file.
|
|
315
|
+
|
|
316
|
+
`upload()` accepts only files the host attached to the running call. Input
|
|
317
|
+
written by hand, such as an agent's `plugin_call` with a made-up `path`, fails
|
|
318
|
+
validation, and so does a call that sends its own `files` alongside the
|
|
319
|
+
upload's. A method the upload's method calls with `ctx.call` may pass the
|
|
320
|
+
files on. In tests, `app.call("receipts.ingest", { month: "may" }, { files })`
|
|
321
|
+
stands in for the host.
|
|
322
|
+
|
|
251
323
|
### Generated methods
|
|
252
324
|
|
|
253
325
|
Every collection, kv, stream and bucket gets methods named
|
|
@@ -265,28 +337,198 @@ A bucket's `put` takes `text`, or `from`, an absolute path on the environment to
|
|
|
265
337
|
copy: bytes never travel in a call. A method of your own with the same name as a
|
|
266
338
|
generated one fails the plugin's load.
|
|
267
339
|
|
|
268
|
-
|
|
340
|
+
Data named in `private` gets none of these, so cards and agents reach it only
|
|
341
|
+
through the plugin's own methods: `private: ["actions", "lines"]` for an audit
|
|
342
|
+
log no tap can edit. It hides methods; it isn't a lock. Another plugin naming
|
|
343
|
+
the same namespace and store can still read and write it.
|
|
344
|
+
|
|
345
|
+
## Workflows
|
|
346
|
+
|
|
347
|
+
> **Experimental.** Workflows are new and may change in ways that need edits
|
|
348
|
+
> to your plugins: their methods, statuses and options aren't settled yet.
|
|
349
|
+
|
|
350
|
+
A workflow keeps progress across host restarts. Define it beside `methods`;
|
|
351
|
+
`workflow(input, run, options)` follows the same shape as `method`:
|
|
269
352
|
|
|
270
353
|
```ts
|
|
354
|
+
import { definePlugin, workflow, z } from "@repogo/plugins";
|
|
355
|
+
|
|
271
356
|
export default definePlugin({
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
{
|
|
275
|
-
|
|
276
|
-
|
|
357
|
+
namespace: "myproject.reports",
|
|
358
|
+
workflows: {
|
|
359
|
+
report: workflow(z.object({ url: z.url() }), async ({ url }, ctx) => {
|
|
360
|
+
const report = await ctx.step.run("fetch", async ({ signal }) => {
|
|
361
|
+
const response = await fetch(url, { signal });
|
|
362
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
363
|
+
return { title: await response.text() };
|
|
364
|
+
}, { kind: "query", attempts: 3, timeout: "30s" });
|
|
365
|
+
|
|
366
|
+
await ctx.sleep("5m");
|
|
367
|
+
|
|
368
|
+
return await ctx.step.run("save", async () => {
|
|
369
|
+
await ctx.kv.reports.set("latest", report);
|
|
370
|
+
return report;
|
|
371
|
+
});
|
|
372
|
+
}, { timeout: "30d", maxOffline: "14d", retention: "30d" }),
|
|
373
|
+
},
|
|
374
|
+
kv: { reports: z.object({ title: z.string() }) },
|
|
277
375
|
});
|
|
278
376
|
```
|
|
279
377
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
378
|
+
For a plugin folder named `reports`, the host generates:
|
|
379
|
+
|
|
380
|
+
- `reports.report.list({ limit?, status?, cursor? })`: bounded summaries and a next cursor.
|
|
381
|
+
- `reports.report.get({ id })`: one summary, input, result, error and the latest 20 operations (steps and sleeps).
|
|
382
|
+
- `reports.report.start({ input, key? })`: persist and queue a run; returns its summary immediately.
|
|
383
|
+
- `reports.report.cancel({ id })`: persist cancellation intent and abort active work.
|
|
384
|
+
- `reports.report.cancelAll({ status? })`: cancel every unfinished run in the project, or only those in one status; returns `{ cancelled }`.
|
|
385
|
+
- `reports.report.retry({ id })`: retry a safely failed query step.
|
|
386
|
+
- `reports.report.restart({ id })`: start a run that needs attention over from the beginning, on the current code.
|
|
387
|
+
|
|
388
|
+
These use the existing plugin methods, `ctx.call`, cards and subscriptions.
|
|
389
|
+
The namespace is required. `confirm` on the workflow confirms its start;
|
|
390
|
+
it does not preapprove later host actions. An unattended workflow refuses
|
|
391
|
+
confirmation-gated plugin methods. The calling project and caller
|
|
392
|
+
are preserved. A start `key` deduplicates within that project, plugin and
|
|
393
|
+
workflow while its run is retained; reusing it with different input fails.
|
|
394
|
+
|
|
395
|
+
Await each step and use a unique, stable name. Keep effects inside step callbacks:
|
|
396
|
+
the handler runs from the beginning after recovery, and completed callbacks
|
|
397
|
+
return their recorded JSON instead of running again. Steps are sequential;
|
|
398
|
+
nesting, overlap and reusing a name fail the run. Return JSON, including an
|
|
399
|
+
explicit `null` for a step with no result. Large payloads belong in buckets;
|
|
400
|
+
a journal record is limited to 1 MiB, a run journal to 64 MiB. `get` omits
|
|
401
|
+
input or final results above 16 KiB and sets their truncation flags.
|
|
402
|
+
|
|
403
|
+
Use `await ctx.sleep("5m")` between steps for a durable wait. No ID is needed:
|
|
404
|
+
the runner identifies each sleep by its position among the workflow's operations.
|
|
405
|
+
It saves an absolute wake time, releases the executor, and resumes without
|
|
406
|
+
repeating completed steps. Loops and consecutive sleeps each get a separate
|
|
407
|
+
position. The first recorded duration wins on replay, even when the expression
|
|
408
|
+
that supplied it later changes. Durations follow the SDK convention (strings
|
|
409
|
+
like `"5m"` or milliseconds), must be positive, and cannot exceed 365 days.
|
|
410
|
+
|
|
411
|
+
Always await sleep at the workflow level, outside step callbacks; do not catch
|
|
412
|
+
its internal waiting signal or detach background workflow operations. Branch
|
|
413
|
+
using recorded step results so replay follows the same operation order.
|
|
414
|
+
The original run timeout and `maxOffline` still apply during sleep. A sleep
|
|
415
|
+
past the run deadline expires at that deadline; an unavailable environment
|
|
416
|
+
resumes only when available and within those limits. `list`/`get` show
|
|
417
|
+
`waiting` with `nextAttemptAt` as the wake time, and detail rows identify sleeps
|
|
418
|
+
with `kind: "sleep"`. Terminal runs have no pending wake time.
|
|
419
|
+
|
|
420
|
+
`attempts` counts the first invocation and defaults to 1. Only steps declared
|
|
421
|
+
`kind: "query"` may automatically retry; they cannot write SDK data or call
|
|
422
|
+
mutations. Within query steps, use `ctx.mcp` for read-aware host access;
|
|
423
|
+
generic `ctx.host`, notifications and agent starts are refused. Query backoff defaults to full jitter with initial `2s`, factor 2
|
|
424
|
+
and maximum `1m`. Import `NonRetryable` and throw it for a permanent failure.
|
|
425
|
+
Step `timeout` defaults to `5m`. The callback receives `signal`, `attempt`,
|
|
426
|
+
`attempts`, `runId`, `stepId` and an `idempotencyKey` stable across its attempts.
|
|
427
|
+
Pass its signal to fetch or subprocess APIs. A timeout aborts cooperatively;
|
|
428
|
+
a callback must settle before another attempt can begin.
|
|
429
|
+
|
|
430
|
+
Mutation steps get one attempt. An interrupted or timed-out mutation becomes
|
|
431
|
+
`unknown`: its external effect may have happened, and it is never automatically
|
|
432
|
+
repeated. Reconcile that effect before starting new work. Cancellation cannot
|
|
433
|
+
roll back remote effects. Manual retry only extends the budget of a failed
|
|
434
|
+
query, keeps its completed prefix and original lifetime, and rejects cancelled,
|
|
435
|
+
expired, unknown, permanent-failure and invalid-result runs.
|
|
436
|
+
|
|
437
|
+
### When you edit a workflow
|
|
438
|
+
|
|
439
|
+
A run always executes the plugin's current code, loaded from its folder as a
|
|
440
|
+
method call is; nothing is frozen at start. When a run resumes after an edit,
|
|
441
|
+
its finished steps still return their recorded results, so:
|
|
442
|
+
|
|
443
|
+
- An edit that keeps the recorded steps in the same order, such as a fix
|
|
444
|
+
inside a step or a new step at the end, carries on with the new code.
|
|
445
|
+
- An edit the recorded steps can't follow, such as a renamed, removed or
|
|
446
|
+
reordered step, stops the run in `attention`: not finished, and not run
|
|
447
|
+
again on its own. Its `error` says which step it expected. `restart` starts
|
|
448
|
+
it over on the current code, running finished steps again, with the same id,
|
|
449
|
+
key and input and a fresh `timeout`. `cancel` or
|
|
450
|
+
`cancelAll({ status: "attention" })` drops it.
|
|
451
|
+
|
|
452
|
+
Nested workflow starts from inside a run are not supported.
|
|
453
|
+
|
|
454
|
+
The host resumes work without an open phone or page. `timeout` is a wall-clock
|
|
455
|
+
run lifetime, including waits. `maxOffline` expires work when the environment
|
|
456
|
+
or workflow service has been unavailable for that long. A persistent network
|
|
457
|
+
outage is also tracked when the environment has no nonlocal network address;
|
|
458
|
+
this passive check does not probe external services or prove internet access.
|
|
459
|
+
Individual API failures use the step's retry budget. The host records expiry on its next wake.
|
|
460
|
+
Shutdown suspends work without recording user cancellation. A finished run's
|
|
461
|
+
journal stays until its `retention` passes. Admission refuses new runs above
|
|
462
|
+
its storage budget rather than evicting active history. Missing or corrupt
|
|
463
|
+
journals block execution. The default environment-wide budget is 512 MiB.
|
|
464
|
+
Each unfinished, blocked or attention run reserves 130 MiB for its journal and
|
|
465
|
+
one snapshot of its state, including while sleeping. This admits at most three
|
|
466
|
+
unfinished runs, fewer when retained history consumes the remaining space;
|
|
467
|
+
admission reports storage full. Checkpoint replacement temporarily keeps both
|
|
468
|
+
snapshots, so peak disk use can exceed that reservation. The admission budget
|
|
469
|
+
is not a free-disk guarantee.
|
|
470
|
+
|
|
471
|
+
For integration tests, import `testWorkflowRuntime` from
|
|
472
|
+
`@repogo/plugins/testing` and pass the fixture's plugins directory. It uses real
|
|
473
|
+
temporary storage and a subprocess per run, as the host does. Call the same lifecycle methods,
|
|
474
|
+
use `await app.wait("reports.report", id)` to await a terminal/blocked run, and
|
|
475
|
+
`await app.close()` afterward. The in-memory `testRuntime({ plugin })` helper
|
|
476
|
+
does not execute durable workflows.
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
## Crons
|
|
480
|
+
|
|
481
|
+
What runs with no one looking is listed in one file the user owns,
|
|
482
|
+
`~/.repogo/crons.json`, not in a plugin's code: a repo you clone can't schedule
|
|
483
|
+
itself. Each line names a plugin, one of its methods and a schedule:
|
|
484
|
+
|
|
485
|
+
```json
|
|
486
|
+
[
|
|
487
|
+
{ "plugin": "incidents", "method": "watch", "schedule": "*/5 * * * *" },
|
|
488
|
+
{ "project": "~/code/shop", "plugin": "backup", "method": "run", "schedule": "0 3 * * *", "timezone": "America/New_York" },
|
|
489
|
+
{ "plugin": "digest", "method": "send", "schedule": "0 9 * * 1-5", "enabled": false }
|
|
490
|
+
]
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
| Field | What |
|
|
494
|
+
| --- | --- |
|
|
495
|
+
| `plugin`, `method` | The plugin's folder name and one of its methods, called with `{}` as a tap would |
|
|
496
|
+
| `project` | The project whose `.repogo/plugins` holds it, an absolute path or one starting `~/`; left out, `~/.repogo/plugins` |
|
|
497
|
+
| `schedule` | A cron: five fields (minute hour day-of-month month day-of-week), or six with seconds first (`*/5 * * * * *`); each `*`, a number, a range, a list or a step |
|
|
498
|
+
| `timezone` | Defaults to the environment's |
|
|
499
|
+
| `enabled` | `false` keeps the line without running it |
|
|
500
|
+
|
|
501
|
+
The host reads the file every minute, and again when the agent's `plugins` tool
|
|
502
|
+
lists what's there, so an edit shows at once. A folder opened only for its
|
|
503
|
+
schedules imports just the plugins they name; the others beside them don't run.
|
|
504
|
+
A line removed or disabled is never started again; a run already going finishes.
|
|
284
505
|
|
|
285
506
|
A job still running isn't started again, a time the environment slept through
|
|
286
|
-
is skipped, and nothing is retried. A
|
|
287
|
-
method that needs input or asks first,
|
|
288
|
-
|
|
289
|
-
|
|
507
|
+
is skipped, and nothing is retried. A line naming something missing, a stream, a
|
|
508
|
+
method that needs input or asks first, a bad schedule, or the same method twice
|
|
509
|
+
says why in `plugins` (each line's `error`), and the others run. A file that
|
|
510
|
+
isn't valid JSON pauses every line until it's fixed. Each line also shows
|
|
511
|
+
`nextRunAt`, `lastRunAt`, `lastError`, `lastDurationMs` and `runs`; `ctx.plugins()`
|
|
512
|
+
has the same for a plugin's own, and a query reading them re-runs around each run.
|
|
513
|
+
|
|
514
|
+
Schedule something only when the user asks for it. A cron's `ctx.root` is its
|
|
515
|
+
project, or the home folder for a plugin in `~/.repogo/plugins`. Plugins in one
|
|
516
|
+
folder share a thread: code that blocks it holds up every other cron there, and
|
|
517
|
+
the host's log names the crons that were running when it happened.
|
|
518
|
+
|
|
519
|
+
## Logging
|
|
520
|
+
|
|
521
|
+
Write `console.log` (or `warn`, `error`, `debug`); nothing else is needed. The
|
|
522
|
+
host keeps every line with the plugin, the method and the call that wrote it,
|
|
523
|
+
who called (`card`, `agent`, `cron`), and the chat for an agent's call, plus
|
|
524
|
+
the arguments as JSON. An error a method throws, or one nothing caught, is
|
|
525
|
+
kept too, with its stack. Read them with the agent's `plugin_logs` tool, or on
|
|
526
|
+
a card with `<Data method="repogo:plugins.logs">`, which reloads as lines land:
|
|
527
|
+
`plugin`, `level` (that level and worse), `method`, `since`, `until`, `query`
|
|
528
|
+
(a substring) and `limit` narrow it. The `plugins` tool shows each plugin's
|
|
529
|
+
`errors` in the last hour. Lines older than 7 days go, and the oldest hours of
|
|
530
|
+
the noisiest plugin once the log passes 2 GB. A line is cut at 8 KB: a payload
|
|
531
|
+
belongs in the plugin's own data, not its log.
|
|
290
532
|
|
|
291
533
|
## The context
|
|
292
534
|
|
|
@@ -295,18 +537,104 @@ Every method, handler and cron gets `ctx`:
|
|
|
295
537
|
| Field | What |
|
|
296
538
|
| --- | --- |
|
|
297
539
|
| `plugin` | The plugin's name |
|
|
540
|
+
| `caller` | Who started the call: `card`, `agent`, `cron`, or `code` |
|
|
298
541
|
| `root` | The calling project's checkout |
|
|
299
542
|
| `collections`, `kv`, `streams`, `buckets` | The plugin's data |
|
|
300
543
|
| `call(method, input)` | Runs a method of a plugin in the same folder; `"open"` is this plugin's own |
|
|
301
544
|
| `plugins()` | The plugins in the same folder, with their methods |
|
|
302
545
|
| `host(method, params)` | Runs one of the host's methods that agents and cards may call (`git.status`, `chats.list`, `actions.run`, …); others fail with `FORBIDDEN` |
|
|
303
|
-
| `
|
|
546
|
+
| `mcp(server, tool, input)` | Runs a tool of an MCP server the user connected in RepoGo and switched on for the calling project; see below |
|
|
547
|
+
| `notify({ title, body, page?, url?, collapse? })` | Alerts every paired phone; a tap opens `url` (an `https` or `itms-services` link) if set, else `page`, and alerts sharing `collapse` replace each other |
|
|
548
|
+
| `activity({ id, title, body?, page?, symbol?, tint?, label?, compactLabel?, progress?, timer?, status? })` | Shows or updates a Live Activity on every paired phone; see below |
|
|
549
|
+
| `agent(prompt, { agent, project?, title?, timeout? })` | Starts a chat with `prompt` and waits for the agent's whole answer: `{ chatId, text }`; see below |
|
|
304
550
|
| `emit(event, payload)` | Sends `<plugin>.<event>` to handlers |
|
|
305
551
|
| `signal` | Aborts when a stream's card goes |
|
|
306
|
-
| `log(...)` |
|
|
552
|
+
| `log(...)` | The same as `console.log` |
|
|
307
553
|
|
|
308
554
|
A query doesn't re-run when what a `ctx.host` method read changes.
|
|
309
555
|
|
|
556
|
+
### An agent's answer
|
|
557
|
+
|
|
558
|
+
`ctx.agent` hands a question to Claude or Codex and answers with what it said,
|
|
559
|
+
so a cron can watch, investigate and notify with the finding:
|
|
560
|
+
|
|
561
|
+
```ts
|
|
562
|
+
watch: method(z.object({}), async (_, ctx) => {
|
|
563
|
+
const down = await failing(ctx);
|
|
564
|
+
if (!down.length) return null;
|
|
565
|
+
const { text, chatId } = await ctx.agent(`Say in two sentences why ${down[0]} is down.`, { agent: "claude" });
|
|
566
|
+
await ctx.notify({ title: `${down[0]} is down`, body: text, page: `chat:${chatId}` });
|
|
567
|
+
return text;
|
|
568
|
+
}),
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
The chat runs in `project` (the calling project unless said), under the
|
|
572
|
+
calling chat when an agent called the plugin. It waits up to `timeout`
|
|
573
|
+
(10 minutes unless said) and then throws, naming the chat, which keeps
|
|
574
|
+
running. The same two host methods are there by hand: `ctx.host("chats.start", …)`
|
|
575
|
+
and `ctx.host("chats.wait", …)`, for a chat you want to link to rather than wait for.
|
|
576
|
+
|
|
577
|
+
### Live Activities
|
|
578
|
+
|
|
579
|
+
`ctx.activity` puts a card on the Lock Screen and in the Dynamic Island.
|
|
580
|
+
Its `title`, `body` and `page` are a notice's, and the plugin sets
|
|
581
|
+
everything else too. Send the whole state each time: the same `id` updates
|
|
582
|
+
it, a field left out takes its default, and `status: "done"` or `"failed"`
|
|
583
|
+
ends it with that state, left up for 15 minutes:
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
deploy: method(z.object({ project: z.string() }), async ({ project }, ctx) => {
|
|
587
|
+
const steps = ["Build", "Test", "Deploy", "Verify"];
|
|
588
|
+
const page = `.repogo/tabs/deploys.ui?project=${project}`;
|
|
589
|
+
const show = (current: number, body: string) =>
|
|
590
|
+
ctx.activity({ id: `deploy:${project}`, title: `Deploying ${project}`, body, page,
|
|
591
|
+
symbol: "shippingbox", progress: { kind: "steps", steps, current } });
|
|
592
|
+
await show(0, "Building");
|
|
593
|
+
// …
|
|
594
|
+
await ctx.activity({ id: `deploy:${project}`, title: `${project} is live`, body: "web.app", page,
|
|
595
|
+
status: "done", symbol: "globe", tint: "blue", label: "Live",
|
|
596
|
+
progress: { kind: "steps", steps, current: steps.length } });
|
|
597
|
+
}),
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
| Field | Unless said |
|
|
601
|
+
| --- | --- |
|
|
602
|
+
| `body` | No line under the title |
|
|
603
|
+
| `page` | A tap opens the app |
|
|
604
|
+
| `symbol` | `circle.dotted`; a check once done, a cross once failed |
|
|
605
|
+
| `tint` | White; green once done, red once failed |
|
|
606
|
+
| `label` | "2 of 4", "40%", "Done" or "Failed"; `""` hides it |
|
|
607
|
+
| `compactLabel` | The `label`, else "2/4" or "40%" |
|
|
608
|
+
| `progress` | None. `{ kind: "steps", steps, current }` draws a segment per step, the ones before `current` complete; `{ kind: "bar", value }` a bar from 0 to 1 |
|
|
609
|
+
| `timer` | `{ kind: "elapsed" }` from the first start. `{ kind: "countdown", endsAt }` counts down to a Unix ms time and ends nothing; `{ kind: "none" }` hides it |
|
|
610
|
+
|
|
611
|
+
- The phones get the latest state at most every 5 seconds; a start and an end go at once.
|
|
612
|
+
- A state that comes to more than Apple's 4 KB as a push is refused.
|
|
613
|
+
- A plugin shows at most two at once; one left without an update for an hour stops counting.
|
|
614
|
+
- A query can't call it, as it can't write.
|
|
615
|
+
- The text reaches Apple and RepoGo's relay as is, as a `notify` alert's does: keep secrets out of it.
|
|
616
|
+
|
|
617
|
+
### MCP servers
|
|
618
|
+
|
|
619
|
+
`ctx.mcp` reaches the MCP servers the user connected in RepoGo, by the key
|
|
620
|
+
cards name them with, using the user's own sign-in, so a plugin needs no key
|
|
621
|
+
or CLI of its own. It answers the tool's structured content, else its text
|
|
622
|
+
(parsed when it's JSON):
|
|
623
|
+
|
|
624
|
+
```ts
|
|
625
|
+
customers: method(z.object({}), async (_, ctx) => {
|
|
626
|
+
const list = await ctx.mcp("stripe", "stripe_api_read", { stripe_api_operation_id: "GetCustomers", /* … */ });
|
|
627
|
+
return list.data.map((c) => ({ id: c.id, email: c.email }));
|
|
628
|
+
}, { kind: "query" }),
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
A query may run only the server's read-only tools, as a card's `Data` may. A
|
|
632
|
+
tool marked destructive runs only from a method with a `confirm`, or one
|
|
633
|
+
called from it with `ctx.call`: the user's answer to that question is its
|
|
634
|
+
yes. A refusal throws `FORBIDDEN`; a tool's own error throws `INTERNAL` with
|
|
635
|
+
its message. Only servers switched on for `ctx.root` answer, so a cron of an
|
|
636
|
+
environment plugin, whose root is the home folder, reaches none.
|
|
637
|
+
|
|
310
638
|
## Events
|
|
311
639
|
|
|
312
640
|
Handlers in `on` run after the events they name:
|
|
@@ -330,8 +658,12 @@ Events run in memory: none is retried, and a handler that throws is logged.
|
|
|
330
658
|
|
|
331
659
|
## How cards and agents reach a plugin
|
|
332
660
|
|
|
333
|
-
**Agents** use two tools
|
|
334
|
-
|
|
661
|
+
**Agents** use two tools. `plugins` lists every plugin with a line per method
|
|
662
|
+
(its kind, whether it asks first, and its input's fields, optional ones ending
|
|
663
|
+
in `?`), and every line of `~/.repogo/crons.json` with how it's going.
|
|
664
|
+
`plugins` with `plugin` set answers that plugin in full: each method's input
|
|
665
|
+
and output JSON Schema and confirm, its `storage` (each bucket's and stream's
|
|
666
|
+
folder), the pages that show its methods, and the cron lines naming it.
|
|
335
667
|
`plugin_call` runs one. Call a new method once before a card names it.
|
|
336
668
|
|
|
337
669
|
**Cards** name methods as `plugin:<plugin>.<method>`. A `Data` element runs a
|
|
@@ -359,4 +691,26 @@ schema answers `VALIDATION` with a message per field. Anything else thrown is
|
|
|
359
691
|
A method's `confirm` is asked before a card's tap runs it. An agent's
|
|
360
692
|
`plugin_call` gets it back as a `CONFIRM` error until it calls again with
|
|
361
693
|
`confirmed: true`, once the user agrees. Only `ctx.call` from plugin code skips
|
|
362
|
-
it. Give one to anything that can't be undone.
|
|
694
|
+
it. Give one to anything that can't be undone. `{{field}}` in the question is
|
|
695
|
+
filled from the call's input, so it can say what it acts on:
|
|
696
|
+
`confirm: "Restart {{machine}}?"`.
|
|
697
|
+
|
|
698
|
+
## Testing
|
|
699
|
+
|
|
700
|
+
`@repogo/plugins/testing` runs plugins as the host does, with real data in a
|
|
701
|
+
temporary folder; `host` and `mcp` stand in for the host's methods and the
|
|
702
|
+
user's MCP servers:
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
import { testRuntime } from "@repogo/plugins/testing";
|
|
706
|
+
import notes from "./app";
|
|
707
|
+
|
|
708
|
+
const app = await testRuntime({ notes });
|
|
709
|
+
await app.call("notes.add", { text: "hi" });
|
|
710
|
+
await app.call("notes.clear", {}, { confirmed: false }); // throws CONFIRM, as an unanswered tap
|
|
711
|
+
app.setCrons([{ plugin: "notes", method: "tidy", schedule: "* * * * *" }]); // as a crons.json line would
|
|
712
|
+
await app.tickCrons(Date.now() + 60_000); // runs whatever is due by then
|
|
713
|
+
app.notices; // every ctx.notify so far
|
|
714
|
+
app.logs; // every console line so far, with its method and caller
|
|
715
|
+
await app.close();
|
|
716
|
+
```
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Database } from "./database";
|
|
2
|
+
/** How a `cached` method keeps its answers: fresh for this long, shared by every project or not. */
|
|
3
|
+
export type CacheOptions = {
|
|
4
|
+
fresh: number;
|
|
5
|
+
shared: boolean;
|
|
6
|
+
timeout: number;
|
|
7
|
+
};
|
|
8
|
+
export declare class AnswerCache {
|
|
9
|
+
private database;
|
|
10
|
+
private namespace;
|
|
11
|
+
private announce;
|
|
12
|
+
/** One fetch per key at a time; a card and a background check share it. */
|
|
13
|
+
private inflight;
|
|
14
|
+
constructor(database: Database, namespace: string, announce: (tags: string[]) => void);
|
|
15
|
+
setup(): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* The saved answer at once, checked again in the background when it's
|
|
18
|
+
* older than `fresh`. With none saved, the caller waits for the fetch.
|
|
19
|
+
*/
|
|
20
|
+
read(key: string, options: CacheOptions, fetch: () => unknown): Promise<unknown>;
|
|
21
|
+
/** Settles once no fetch is out. */
|
|
22
|
+
idle(): Promise<void>;
|
|
23
|
+
private refresh;
|
|
24
|
+
private save;
|
|
25
|
+
}
|
|
26
|
+
/** A key for one answer: the method, the project unless it's shared, and the input with its keys sorted. */
|
|
27
|
+
export declare function cacheKey(method: string, root: string | null, input: unknown): string;
|
package/dist/collections.d.ts
CHANGED
|
@@ -8,9 +8,12 @@ export type Record_<T> = T & {
|
|
|
8
8
|
};
|
|
9
9
|
export type ListOptions = {
|
|
10
10
|
where?: Record<string, string | number | boolean | null>;
|
|
11
|
-
|
|
11
|
+
/** `id`, `createdAt`, `updatedAt`, or one of the record's own top-level fields. */
|
|
12
|
+
orderBy?: "createdAt" | "updatedAt" | (string & {});
|
|
12
13
|
order?: "asc" | "desc";
|
|
13
14
|
limit?: number;
|
|
15
|
+
/** Records to pass over first, in the same order: `offset: 300` is everything past the newest 300. */
|
|
16
|
+
offset?: number;
|
|
14
17
|
};
|
|
15
18
|
export declare class Collection<S extends z.ZodObject> {
|
|
16
19
|
private database;
|
|
@@ -28,6 +31,13 @@ export declare class Collection<S extends z.ZodObject> {
|
|
|
28
31
|
update(id: string, patch: Partial<z.input<S>>): Promise<Record_<z.infer<S>>>;
|
|
29
32
|
delete(id: string): Promise<void>;
|
|
30
33
|
list(options?: ListOptions): Promise<Record_<z.infer<S>>[]>;
|
|
34
|
+
/**
|
|
35
|
+
* Deletes the records `list(options)` would return, with its same defaults,
|
|
36
|
+
* in one transaction, and returns how many. Each emits its `deleted` event.
|
|
37
|
+
*/
|
|
38
|
+
deleteMany(options?: ListOptions): Promise<number>;
|
|
39
|
+
/** The query `list` and `deleteMany` share, so both pick out the same records. */
|
|
40
|
+
private select;
|
|
31
41
|
private eventName;
|
|
32
42
|
private toRecord;
|
|
33
43
|
}
|