@awebai/oats 0.28.0 → 0.29.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/bin/oats.mjs +296 -106
- package/capabilities/oats-okf/bin/oats-okf.mjs +28 -8
- package/capabilities/oats-okf/injects/okf.md +33 -33
- package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
- package/capabilities/oats-okf/lib/config.mjs +2 -1
- package/capabilities/oats-okf/lib/consult.mjs +1 -5
- package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
- package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
- package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
- package/capabilities/oats-okf/lib/sources.mjs +28 -3
- package/capabilities/oats-okf/lib/stores.mjs +9 -4
- package/capabilities/oats-okf/lib/worker.mjs +82 -8
- package/capabilities/oats-okf/oats.json +14 -8
- package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +8 -6
- package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +1 -1
- package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
- package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
- package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
- package/capabilities/oats-okf-harvest/oats.json +26 -0
- package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
- package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
- package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -30
- package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
- package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
- package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
- package/capabilities/oats-okf-maintenance/oats.json +21 -0
- package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
- package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
- package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
- package/capabilities/oats-review/injects/review.md +3 -2
- package/capabilities/oats-review/oats.json +3 -4
- package/docs/capabilities.md +41 -9
- package/docs/capability-manifest.schema.json +0 -7
- package/docs/desktop-cli-api.md +257 -11
- package/docs/implementation.md +1 -1
- package/docs/knowledge-capability-authoring.md +8 -2
- package/docs/knowledge-reference/package-craft.md +8 -5
- package/docs/knowledge.md +101 -0
- package/docs/oats-local.schema.json +31 -1
- package/docs/official-catalog.md +7 -4
- package/docs/packages.md +11 -5
- package/docs/release-lane.md +1 -1
- package/docs/release-notes/v0.29.0.md +240 -0
- package/docs/schedules.md +133 -5
- package/docs/souls-and-instances.md +4 -6
- package/docs/workspaces.md +11 -2
- package/lib/automations.mjs +369 -0
- package/lib/core.mjs +65 -154
- package/lib/instance-inspect.mjs +12 -4
- package/lib/instance-resolution.mjs +31 -182
- package/lib/materialize.mjs +5 -7
- package/lib/operator-dispatch.mjs +1 -2
- package/lib/packages.mjs +17 -0
- package/lib/remote.mjs +21 -1
- package/lib/resolve.mjs +51 -7
- package/lib/schedule.mjs +211 -41
- package/lib/triggers.mjs +182 -49
- package/lib/workspace.mjs +1 -1
- package/package-catalog.json +6 -4
- package/package.json +1 -1
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -26
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
- package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
- package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
- /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
package/docs/schedules.md
CHANGED
|
@@ -39,7 +39,7 @@ see [Captured definitions](#captured-definitions-removed-in-026).
|
|
|
39
39
|
## Kinds
|
|
40
40
|
|
|
41
41
|
- **spawn** `{id, enabled, cron, tz, kind: "spawn", agent, agentsRoot?,
|
|
42
|
-
repo?, backend?, purpose?, task, harness?, model?, yolo?, wake?}` — every
|
|
42
|
+
repo?, backend?, purpose?, task, launchConfig?, harness?, model?, yolo?, wake?}` — every
|
|
43
43
|
due minute launches one disposable instance of `agent` with the same
|
|
44
44
|
options `oats spawn` takes. `agentsRoot` names the exact agents root that
|
|
45
45
|
holds the soul (it must lie inside the workspace and defaults to the
|
|
@@ -153,7 +153,10 @@ logged in with the keyring or its config file under your HOME works there. A
|
|
|
153
153
|
request's title and body are untrusted and never reach the task (a template
|
|
154
154
|
naming any other field is refused). `teams` becomes the messaging
|
|
155
155
|
capability's `join=` setting (as `--provider <messaging cap> join=<labels>`).
|
|
156
|
-
`
|
|
156
|
+
`launchConfig` (a launch configuration in the running host's
|
|
157
|
+
`oats-local.yaml` `launch-configs`), `harness`, `model`, `yolo`, `backend`
|
|
158
|
+
are as for schedules; a package template may expose any of them as a
|
|
159
|
+
parameter (`"path": "spawn.launchConfig"`).
|
|
157
160
|
- **The event reaches the instance** as `OATS_TRIGGER_EVENT_FILE`
|
|
158
161
|
(`<home>/.oats/trigger-event.json`: `{ trigger, source, repo, number, url,
|
|
159
162
|
event, headSha, labels, observedAt, key }`), given to the spawn hooks and the
|
|
@@ -169,7 +172,7 @@ oats trigger add --from oats.okf:harvest-review --set repo=github.com/acme/knowl
|
|
|
169
172
|
oats trigger list | show <id> | enable <id> | disable <id> | remove <id>
|
|
170
173
|
oats trigger test <id> # dry run: gh auth + credential source, repo + permissions (push/maintain/admin), the soul resolves,
|
|
171
174
|
# its messaging capability, the teams declared, what WOULD fire now; spawns nothing
|
|
172
|
-
oats trigger status [<id>] # last poll, pending, fired keys, live
|
|
175
|
+
oats trigger status [<id>] # last poll, next due, pending, fired keys (time, instance), live vs max, last error
|
|
173
176
|
```
|
|
174
177
|
|
|
175
178
|
All take `--dir` and `--json` (`triggerApi: 1`). `remove` leaves the instances
|
|
@@ -187,6 +190,119 @@ comma-separated); a required parameter without a value is `E_BAD_ARGS
|
|
|
187
190
|
{ missing }` naming it. The trigger records `template: { package, version,
|
|
188
191
|
commit, template }`.
|
|
189
192
|
|
|
193
|
+
## Workspace triggers and schedules
|
|
194
|
+
|
|
195
|
+
(OATS 0.29.0, feature `automations`.) A trigger or a schedule is defined at one of
|
|
196
|
+
two levels:
|
|
197
|
+
|
|
198
|
+
- **in the workspace**: a file committed in a confirmed member repository, shared
|
|
199
|
+
through Git and named `<member>/<id>`. This is the default for anything a team
|
|
200
|
+
relies on.
|
|
201
|
+
- **locally**: in the deployment's `oats-schedules.json` (`oats trigger add`,
|
|
202
|
+
`oats schedule add`), machine-private and named `local/<id>`.
|
|
203
|
+
|
|
204
|
+
The two kinds stay separate at every step. Each has its own folder, its own file
|
|
205
|
+
kind, its own ids, its own commands, its own list and its own opt-out.
|
|
206
|
+
|
|
207
|
+
| | trigger | schedule |
|
|
208
|
+
| --- | --- | --- |
|
|
209
|
+
| canonical folder (at the member's root) | `oats-triggers/` | `oats-schedules/` |
|
|
210
|
+
| file name anywhere in the member | `*.oats-trigger.yaml` | `*.oats-schedule.yaml` |
|
|
211
|
+
| `kind:` | `oats-trigger` | `oats-schedule` |
|
|
212
|
+
| body | `from:` + `set:` (a package template), or `on`, `spawn`, `concurrency` as above | `run: spawn \| command`, `cron`, `tz`, `agent`, `task`, `purpose`, `launchConfig`, `harness`, `model`, `yolo`, `backend`, `wake`, `argv`, `cwd` |
|
|
213
|
+
| commands | `oats trigger …` | `oats schedule …` |
|
|
214
|
+
| opt-out on this host | `triggers.disabled` | `schedules.disabled` |
|
|
215
|
+
|
|
216
|
+
Every `.yaml`/`.yml` under a canonical folder is a candidate, and so is a file with
|
|
217
|
+
the kind's suffix anywhere in the member (`.yml` works too), e.g.
|
|
218
|
+
`services/billing/nightly.oats-schedule.yaml` beside the code it concerns.
|
|
219
|
+
`oats-package/`, `.git/` and `node_modules/` are never scanned.
|
|
220
|
+
|
|
221
|
+
```yaml
|
|
222
|
+
# <member>/oats-triggers/okf-harvest-review.yaml
|
|
223
|
+
kind: oats-trigger
|
|
224
|
+
schemaVersion: 1
|
|
225
|
+
description: Review every harvest PR on the knowledge base
|
|
226
|
+
from: oats.okf:harvest-review # a package template at the locked commit, then its parameters
|
|
227
|
+
set: { repo: github.com/acme/knowledge }
|
|
228
|
+
runsOn: kb-bot-server # the host.name that runs it
|
|
229
|
+
owner: github.com/acme-kb-bot # the GitHub account it acts as
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```yaml
|
|
233
|
+
# <member>/services/billing/nightly.oats-schedule.yaml
|
|
234
|
+
kind: oats-schedule
|
|
235
|
+
schemaVersion: 1
|
|
236
|
+
run: spawn
|
|
237
|
+
cron: "0 7 * * *"
|
|
238
|
+
tz: Europe/Madrid
|
|
239
|
+
agent: digest-writer # resolved like `oats spawn <soul>` (member or package soul)
|
|
240
|
+
task: Write the nightly digest.
|
|
241
|
+
runsOn: ana-laptop
|
|
242
|
+
owner: github.com/ana
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
- **The file describes itself.** It carries `kind` and `schemaVersion: 1`. A
|
|
246
|
+
candidate of the wrong kind (a schedule in `oats-triggers/`) or without one is an
|
|
247
|
+
`E_AUTOMATION_SCHEMA` problem, never silently skipped.
|
|
248
|
+
- **The id** is `id:`, else the filename stem. The same id twice in one member for
|
|
249
|
+
one kind is `E_AUTOMATION_DUPLICATE`, naming both paths; the second file is not
|
|
250
|
+
listed. A trigger and a schedule may share an id: they are different things.
|
|
251
|
+
- **A workspace schedule is `run: spawn` or `run: command`.** A `command`'s `cwd` is
|
|
252
|
+
relative to the deployment. `wake` and `operation` target an instance home on one
|
|
253
|
+
machine, so they stay local.
|
|
254
|
+
|
|
255
|
+
**Who runs it.** A host runs a workspace trigger or schedule only when both of these
|
|
256
|
+
hold:
|
|
257
|
+
|
|
258
|
+
1. its `runsOn` is this host's `host.name` in `oats-local.yaml`;
|
|
259
|
+
2. the host's authenticated `gh` account (`gh api user`, asked once per tick) is its
|
|
260
|
+
`owner`.
|
|
261
|
+
|
|
262
|
+
Otherwise the item is listed with a reason:
|
|
263
|
+
|
|
264
|
+
- `assigned-elsewhere`: another host runs it;
|
|
265
|
+
- `owner-mismatch`: this host is named, but its `gh` is logged in as someone else or
|
|
266
|
+
not at all. Nothing runs, the tick reports it, and `oats trigger test` says so;
|
|
267
|
+
- `host-unnamed`: this host has no `host.name`.
|
|
268
|
+
|
|
269
|
+
So exactly one machine runs it, and consent is explicit: the machine's operator
|
|
270
|
+
named the host and is logged in as the account.
|
|
271
|
+
|
|
272
|
+
**Opting out** without a commit: `oats trigger disable <member>/<id>` writes
|
|
273
|
+
`triggers.disabled`, and `oats schedule disable <member>/<id>` writes
|
|
274
|
+
`schedules.disabled`, in `oats-local.yaml`. `enable` removes the entry. A
|
|
275
|
+
workspace definition is never edited or removed here (`update` and `remove` are
|
|
276
|
+
`E_AUTOMATION_WORKSPACE`): change the file in Git.
|
|
277
|
+
|
|
278
|
+
**Refresh.**
|
|
279
|
+
|
|
280
|
+
- `oats sync` discovers the workspace triggers and schedules of the confirmed
|
|
281
|
+
members into a snapshot, `.agents/automations/snapshot.json` in the deployment,
|
|
282
|
+
with one list per kind. It takes one tree listing per member commit.
|
|
283
|
+
- The host tick reads that snapshot. It refreshes it (`oats automations refresh`)
|
|
284
|
+
when the snapshot is more than ten minutes old, at most once per interval. When
|
|
285
|
+
the refresh fails, the last good snapshot keeps serving.
|
|
286
|
+
- A change in Git therefore reaches the named host within about ten minutes.
|
|
287
|
+
- The run state (dedup keys, last poll, last run) stays per host and local. A
|
|
288
|
+
workspace schedule's job lock and state are keyed `<member>~<id>`.
|
|
289
|
+
- A trigger template (`from:`) is instantiated when the snapshot is taken, at the
|
|
290
|
+
commit the host's lock pins.
|
|
291
|
+
|
|
292
|
+
**Writing one.** Add `--workspace <member> --runs-on <host> --owner <host>/<login>`
|
|
293
|
+
to `oats trigger add` or `oats schedule add`:
|
|
294
|
+
|
|
295
|
+
- Run inside a checkout of that member, it writes `oats-triggers/<id>.yaml` or
|
|
296
|
+
`oats-schedules/<id>.yaml` there, for you to commit and push.
|
|
297
|
+
- Anywhere else, it prints the file.
|
|
298
|
+
- Either way, the file is read back and validated first.
|
|
299
|
+
- `oats trigger test <member>/<id>` checks the placement, and everything else it
|
|
300
|
+
checked before, on this host.
|
|
301
|
+
|
|
302
|
+
Everything above still holds: the soul must resolve here, templates name only the
|
|
303
|
+
whitelisted fields, a PR's text is never interpolated, and no definition carries a
|
|
304
|
+
credential.
|
|
305
|
+
|
|
190
306
|
## Captured definitions (removed in 0.26)
|
|
191
307
|
|
|
192
308
|
0.24–0.25 could save captured command definitions: `definitionVersion`,
|
|
@@ -217,6 +333,7 @@ oats schedule add <id> --file spec.json --dir <workspace> --json
|
|
|
217
333
|
oats schedule update <id> --file spec.json
|
|
218
334
|
oats schedule list | show <id> | enable <id> | disable <id> | remove <id>
|
|
219
335
|
oats schedule run <id> # now, under the same lock and bound
|
|
336
|
+
oats schedule test <id> # dry run: where it runs, whether its soul resolves, when it is next due; spawns nothing
|
|
220
337
|
oats schedule tick --dry-run # what would run this minute, launching nothing
|
|
221
338
|
oats schedule reconcile <id> [--clear] # resolve an attempt whose result was never recorded
|
|
222
339
|
oats schedule host install # register this scope and install the ONE host timer (idempotent while active)
|
|
@@ -227,8 +344,9 @@ oats spawn <agent> ... --wake-every 15 --wake-message "Anything new?" # or --w
|
|
|
227
344
|
Every subcommand takes `--server <id>` instead of `--dir`: it then runs on
|
|
228
345
|
that host, in its registered workspace, because schedules are host-owned.
|
|
229
346
|
|
|
230
|
-
`list --json` answers `{schedules: [{id, ...definition,
|
|
231
|
-
running}], scheduler: {installed, active, lastTick, maxConcurrent, ...}}
|
|
347
|
+
`list --json` answers `{schedules: [{id, ...definition, nextDue, lastRun,
|
|
348
|
+
running}], scheduler: {installed, active, lastTick, maxConcurrent, ...}}`
|
|
349
|
+
(`oats trigger list --json` carries the same `scheduler`).
|
|
232
350
|
`active` is what the OS reports about the timer, not whether a file exists.
|
|
233
351
|
|
|
234
352
|
## What a run reports
|
|
@@ -292,6 +410,16 @@ the instance is neither hidden nor spawned again.
|
|
|
292
410
|
|
|
293
411
|
## OKF v2 source jobs
|
|
294
412
|
|
|
413
|
+
> **oats.okf 4.0.0:** a source and its job exist only where harvest is
|
|
414
|
+
> effectively on (`harvest: on|off`, default off), and the job spawns the
|
|
415
|
+
> harvester package soul. The review of its PR runs as a **trigger**, run by
|
|
416
|
+
> the same host tick: a workspace file (`oats-triggers/okf-harvest-review.yaml`
|
|
417
|
+
> in a member repo, `kind: oats-trigger`, with `runsOn` and `owner`), or a
|
|
418
|
+
> local one in this file. See
|
|
419
|
+
> [knowledge.md](knowledge.md#knowledge-operations), [Triggers](#triggers) and
|
|
420
|
+
> [Workspace triggers and schedules](#workspace-triggers-and-schedules); the
|
|
421
|
+
> trigger commands are `oats trigger …`.
|
|
422
|
+
|
|
295
423
|
The [prepared OKF v2 runtime](knowledge.md) registers **one command job per
|
|
296
424
|
source**, not a fleet sweep or a home-bound operation job. It runs from stable
|
|
297
425
|
deployment context with argv equivalent to:
|
|
@@ -516,14 +516,12 @@ Default layout:
|
|
|
516
516
|
docs-expert/ # a workspace soul, defined in a member repository's
|
|
517
517
|
souls/<commit12>/ # souls/docs-expert/ and copied here per commit
|
|
518
518
|
instances/
|
|
519
|
-
memory-harvest/ # a capability-defined agent: only instances/, no soul
|
|
520
|
-
instances/
|
|
521
519
|
```
|
|
522
520
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
521
|
+
Every agent is a soul — a member soul, or a package soul homed under
|
|
522
|
+
`agents/<package>--<soul>/`. A capability-defined agent (a manifest's
|
|
523
|
+
`agents:`) was removed in 0.29.0; a home an earlier kernel left for one (its
|
|
524
|
+
directory holds only `instances/`) is still listed and retirable.
|
|
527
525
|
|
|
528
526
|
There are no local souls. A soul is a member repository's `souls/<name>`
|
|
529
527
|
(`soul.yaml` + `AGENTS.md`); author it there and run `oats sync`. OATS 0.25
|
package/docs/workspaces.md
CHANGED
|
@@ -59,8 +59,8 @@ members: # repo refs, NO @revision (E_WORKSPAC
|
|
|
59
59
|
- git:github.com/acme/tools # a member that ALSO publishes a package (see below)
|
|
60
60
|
|
|
61
61
|
packages: # the ONLY versioned things
|
|
62
|
-
oats.framework: v1.
|
|
63
|
-
oats.okf:
|
|
62
|
+
oats.framework: v1.3.0 # bare version → resolves through the official catalog
|
|
63
|
+
oats.okf: v4.0.0
|
|
64
64
|
acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
|
|
65
65
|
|
|
66
66
|
teams: # labels, declared once so they cannot drift
|
|
@@ -169,8 +169,17 @@ settings: # host-owned values the manifests ask
|
|
|
169
169
|
state-dir: /Users/ana/.oats/okf
|
|
170
170
|
souls:
|
|
171
171
|
disabled: [data-analyst] # not run on this machine (E_SOUL_DISABLED); oats.okf/knowledge-harvester names a package soul
|
|
172
|
+
host:
|
|
173
|
+
name: ana-laptop # this machine's name: runs the workspace triggers/schedules whose runsOn names it
|
|
174
|
+
triggers:
|
|
175
|
+
disabled: [knowledge/okf-harvest-review] # workspace triggers this host does not run (oats trigger disable)
|
|
176
|
+
schedules:
|
|
177
|
+
disabled: [platform/nightly-digest] # workspace schedules this host does not run (oats schedule disable)
|
|
172
178
|
```
|
|
173
179
|
|
|
180
|
+
`host`, `triggers.disabled` and `schedules.disabled` (0.29.0) are machine facts:
|
|
181
|
+
see [schedules.md#workspace-triggers-and-schedules](schedules.md#workspace-triggers-and-schedules).
|
|
182
|
+
|
|
174
183
|
See [configuration.md](configuration.md). `oats-config.yaml` no longer exists.
|
|
175
184
|
|
|
176
185
|
### `oats-lock.json` — lock v3
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/** What workspace triggers and workspace schedules share (kernel 0.29.0; design
|
|
2
|
+
* docs/design/2026-09-26-okf-knowledge-operations.md §2.3a). Nothing here knows what a trigger
|
|
3
|
+
* or a schedule IS: each kind's module (lib/triggers.mjs, lib/schedule.mjs) owns its file body,
|
|
4
|
+
* its validation and its rows, and hands this module a KIND DESCRIPTOR:
|
|
5
|
+
*
|
|
6
|
+
* { kind: "trigger" | "schedule", folder, suffix, fileKind, bodyKeys,
|
|
7
|
+
* parseBody(body) → source | throws { message, field },
|
|
8
|
+
* expand(entry) → definition | throws (may be async) }
|
|
9
|
+
*
|
|
10
|
+
* Triggers and schedules are defined at one of two levels:
|
|
11
|
+
* - the WORKSPACE level: a file committed in a confirmed member repository, shared through Git,
|
|
12
|
+
* named `<member>/<id>`;
|
|
13
|
+
* - LOCALLY, in the deployment's oats-schedules.json (`oats trigger add`, `oats schedule add`):
|
|
14
|
+
* machine-private, named `local/<id>`.
|
|
15
|
+
*
|
|
16
|
+
* Shared rules:
|
|
17
|
+
* - WHERE: a kind's canonical folder at the member's root (every *.yaml / *.yml under it) or a
|
|
18
|
+
* file named `*.<suffix>.yaml` (.yml too) anywhere in the member; never under `oats-package/`,
|
|
19
|
+
* `.git/` or `node_modules/`. One recursive tree listing per member commit serves both kinds.
|
|
20
|
+
* - THE HEADER: `kind: <fileKind>` + `schemaVersion: 1` (a wrong or missing kind is
|
|
21
|
+
* E_AUTOMATION_SCHEMA), `id:` or the filename stem, `runsOn` (a host name), `owner` (a GitHub
|
|
22
|
+
* account, `<host>/<login>`), `description?`, `enabled?`. A duplicate id within one member and
|
|
23
|
+
* one kind is E_AUTOMATION_DUPLICATE.
|
|
24
|
+
* - PLACEMENT: a host runs one ONLY when `runsOn` is its `host.name` (oats-local.yaml) AND its
|
|
25
|
+
* authenticated `gh` account is `owner`; otherwise it is listed with the reason
|
|
26
|
+
* (`host-unnamed`, `assigned-elsewhere`, `owner-mismatch`). Each kind's own list in
|
|
27
|
+
* oats-local.yaml (`triggers.disabled`, `schedules.disabled`) stops a named host from running
|
|
28
|
+
* one.
|
|
29
|
+
* - THE SNAPSHOT: discovery reads the remotes (async), the host tick is synchronous; so
|
|
30
|
+
* discovery writes <deployment>/.agents/automations/snapshot.json (`oats sync`,
|
|
31
|
+
* `oats automations refresh`, which the tick runs as a child when the snapshot is ten minutes
|
|
32
|
+
* old), one list per kind, and the tick reads it. The run state stays local. */
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { dirname, join } from "node:path";
|
|
36
|
+
import YAML from "yaml";
|
|
37
|
+
import { parseConfigData } from "./config-data.mjs";
|
|
38
|
+
|
|
39
|
+
export const AUTOMATIONS_API = 1;
|
|
40
|
+
export const SNAPSHOT_MAX_AGE_MS = 10 * 60_000;
|
|
41
|
+
export const PLACEMENT_REASONS = Object.freeze(["host-unnamed", "assigned-elsewhere", "owner-mismatch"]);
|
|
42
|
+
/** The kinds, in the order the snapshot keeps them (`triggers`, `schedules`). */
|
|
43
|
+
export const KIND_NAMES = Object.freeze(["trigger", "schedule"]);
|
|
44
|
+
const NEVER_SCANNED = new Set(["oats-package", ".git", "node_modules"]);
|
|
45
|
+
export const AUTOMATION_ID_RE = /^[a-z0-9-]{1,40}$/;
|
|
46
|
+
export const HOST_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
47
|
+
const OWNER_RE = /^([a-z0-9-]+(?:\.[a-z0-9-]+)+)\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$/;
|
|
48
|
+
const HEADER_KEYS = ["kind", "schemaVersion", "id", "description", "runsOn", "owner", "enabled"];
|
|
49
|
+
const LOCAL = "local";
|
|
50
|
+
|
|
51
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
52
|
+
export function automationError(code, message, details) { return Object.assign(new Error(message), { code, ...(details ? { details } : {}) }); }
|
|
53
|
+
|
|
54
|
+
// ------------------------------------------------------------ names
|
|
55
|
+
|
|
56
|
+
/** `local/<id>` for a machine-private trigger or schedule, `<member>/<id>` for a workspace one. */
|
|
57
|
+
export const localId = (id) => `${LOCAL}/${id}`;
|
|
58
|
+
/** Split a qualified id; a bare id is a local one. → { scope: "local" | <member>, name } */
|
|
59
|
+
export function splitId(text) {
|
|
60
|
+
const s = String(text ?? "");
|
|
61
|
+
const i = s.indexOf("/");
|
|
62
|
+
return i < 0 ? { scope: LOCAL, name: s } : { scope: s.slice(0, i), name: s.slice(i + 1) };
|
|
63
|
+
}
|
|
64
|
+
/** The qualified form of an id argument (a bare id is a local one). */
|
|
65
|
+
export const qualifiedId = (text) => { const { scope, name } = splitId(text); return `${scope}/${name}`; };
|
|
66
|
+
/** `<host>/<login>` → { host, login } | null (a login compares case-insensitively). */
|
|
67
|
+
export function parseOwner(text) {
|
|
68
|
+
const m = typeof text === "string" ? OWNER_RE.exec(text.trim()) : null;
|
|
69
|
+
return m ? { host: m[1], login: m[2] } : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ------------------------------------------------------------ discovery
|
|
73
|
+
|
|
74
|
+
/** Which kind (of the given descriptors) a tree path would be. → { kind, stem } | null */
|
|
75
|
+
export function candidateOf(path, kinds) {
|
|
76
|
+
const segs = String(path).split("/");
|
|
77
|
+
if (segs.some((s) => NEVER_SCANNED.has(s))) return null;
|
|
78
|
+
const base = segs.at(-1);
|
|
79
|
+
for (const k of kinds) {
|
|
80
|
+
const suffix = `.${k.suffix}.`;
|
|
81
|
+
const at = base.lastIndexOf(suffix);
|
|
82
|
+
if (at > 0 && /^ya?ml$/.test(base.slice(at + suffix.length))) return { kind: k.kind, stem: base.slice(0, at) };
|
|
83
|
+
}
|
|
84
|
+
for (const k of kinds) if (segs[0] === k.folder && segs.length > 1 && /\.ya?ml$/.test(base)) return { kind: k.kind, stem: base.replace(/\.ya?ml$/, "") };
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Parse one candidate file: the shared header here, the body by the kind's descriptor. The
|
|
89
|
+
* definition is not expanded yet. Never throws: → { entry } | { problem } */
|
|
90
|
+
export function parseAutomationFile(desc, { stem, path, bytes, member, repoKey, commit }) {
|
|
91
|
+
const origin = { kind: "workspace", repoKey, path, commit };
|
|
92
|
+
const problem = (message, field) => ({ problem: { code: "E_AUTOMATION_SCHEMA", kind: desc.kind, repoKey, path: field ? `${path}#/${field}` : path, message } });
|
|
93
|
+
let doc;
|
|
94
|
+
try { doc = parseConfigData(bytes, { origin: { kind: desc.fileKind, repoKey, commit, path } }).value; }
|
|
95
|
+
catch (e) { return problem(`cannot decode: ${e.message}`); }
|
|
96
|
+
if (!isObject(doc)) return problem(`a ${desc.fileKind} file is a mapping with kind: ${desc.fileKind} and schemaVersion: 1`);
|
|
97
|
+
if (doc.kind !== desc.fileKind) return problem(`kind must be ${JSON.stringify(desc.fileKind)} (found ${JSON.stringify(doc.kind ?? null)}): a file ${path.split("/").at(-1).includes(`.${desc.suffix}.`) ? `named *.${desc.suffix}.yaml` : `in ${desc.folder}/`} follows the ${desc.fileKind} contract`, "kind");
|
|
98
|
+
if (doc.schemaVersion !== 1) return problem(`schemaVersion must be 1 (found ${JSON.stringify(doc.schemaVersion ?? null)})`, "schemaVersion");
|
|
99
|
+
const allowed = [...HEADER_KEYS, ...desc.bodyKeys];
|
|
100
|
+
const unknown = Object.keys(doc).filter((k) => !allowed.includes(k));
|
|
101
|
+
if (unknown.length) return problem(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} (a ${desc.fileKind} carries ${allowed.join(", ")})`, unknown[0]);
|
|
102
|
+
const name = doc.id === undefined ? stem : doc.id;
|
|
103
|
+
if (typeof name !== "string" || !AUTOMATION_ID_RE.test(name)) return problem(`the id (${doc.id === undefined ? "the filename stem" : "id:"} ${JSON.stringify(name)}) must be lowercase letters, digits and dashes, 1 to 40 characters`, "id");
|
|
104
|
+
if (typeof doc.runsOn !== "string" || !HOST_NAME_RE.test(doc.runsOn)) return problem("runsOn: the host name that runs it (oats-local.yaml host.name: lowercase letters, digits and dashes)", "runsOn");
|
|
105
|
+
const owner = parseOwner(doc.owner);
|
|
106
|
+
if (!owner) return problem("owner: the GitHub account it acts as, <host>/<login> (e.g. github.com/acme-kb-bot)", "owner");
|
|
107
|
+
if (doc.description !== undefined && typeof doc.description !== "string") return problem("description: text", "description");
|
|
108
|
+
if (doc.enabled !== undefined && typeof doc.enabled !== "boolean") return problem("enabled: boolean", "enabled");
|
|
109
|
+
let source;
|
|
110
|
+
try { source = desc.parseBody(Object.fromEntries(Object.entries(doc).filter(([k]) => !HEADER_KEYS.includes(k)))); }
|
|
111
|
+
catch (e) { return problem(e.message, e.field); }
|
|
112
|
+
return { entry: { kind: desc.kind, id: `${member}/${name}`, name, member, description: doc.description ?? null, runsOn: doc.runsOn, owner: `${owner.host}/${owner.login}`, enabled: doc.enabled !== false, origin, source } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Discover the workspace triggers and schedules of the confirmed members: one tree listing per
|
|
116
|
+
* member commit serves every kind (a member whose commit did not change reuses the previous
|
|
117
|
+
* snapshot's listing). Each kind's descriptor parses its body and expands it into a validated
|
|
118
|
+
* kernel definition; a failure lists the entry as invalid and is reported.
|
|
119
|
+
* → { byKind: { trigger: [...], schedule: [...] }, problems, members } */
|
|
120
|
+
export async function discoverAutomations(discovery, { remote, memberName, kinds, previous = null } = {}) {
|
|
121
|
+
const byKind = Object.fromEntries(kinds.map((k) => [k.kind, []]));
|
|
122
|
+
const problems = [], members = {};
|
|
123
|
+
const names = new Map();
|
|
124
|
+
for (const m of (discovery?.members || []).filter((x) => x.confirmed && x.commit)) {
|
|
125
|
+
const name = memberName(m.key);
|
|
126
|
+
if (names.has(name)) { problems.push({ code: "E_AUTOMATION_DUPLICATE", repoKey: m.key, path: "", message: `member name ${JSON.stringify(name)} is also ${names.get(name)}'s: triggers and schedules are named <member>/<id>, so ${m.key}'s are not listed` }); continue; }
|
|
127
|
+
names.set(name, m.key);
|
|
128
|
+
let candidates;
|
|
129
|
+
const cached = previous?.members?.[m.key];
|
|
130
|
+
if (cached && cached.commit === m.commit && Array.isArray(cached.candidates)) candidates = cached.candidates;
|
|
131
|
+
else {
|
|
132
|
+
let tree;
|
|
133
|
+
try { tree = await remote.listRemoteTree(m.ref ?? m.key, m.commit, "", { depth: 64 }); }
|
|
134
|
+
catch (e) { problems.push({ code: e?.code || "E_REMOTE_UNREADABLE", repoKey: m.key, path: "", message: `triggers and schedules cannot be listed: ${e.message}` }); continue; }
|
|
135
|
+
candidates = tree.filter((e) => e.type === "blob" && candidateOf(e.path, kinds)).map((e) => e.path);
|
|
136
|
+
}
|
|
137
|
+
members[m.key] = { name, commit: m.commit, candidates };
|
|
138
|
+
const seen = Object.fromEntries(kinds.map((k) => [k.kind, new Map()]));
|
|
139
|
+
for (const path of [...candidates].sort()) {
|
|
140
|
+
const c = candidateOf(path, kinds);
|
|
141
|
+
if (!c) continue;
|
|
142
|
+
const desc = kinds.find((k) => k.kind === c.kind);
|
|
143
|
+
let bytes;
|
|
144
|
+
try { ({ bytes } = await remote.readRemoteFile(m.ref ?? m.key, m.commit, path)); }
|
|
145
|
+
catch (e) { problems.push({ code: e?.code || "E_REMOTE_UNREADABLE", kind: c.kind, repoKey: m.key, path, message: e.message }); continue; }
|
|
146
|
+
const parsed = parseAutomationFile(desc, { stem: c.stem, path, bytes, member: name, repoKey: m.key, commit: m.commit });
|
|
147
|
+
if (parsed.problem) { problems.push(parsed.problem); continue; }
|
|
148
|
+
const a = parsed.entry;
|
|
149
|
+
const first = seen[a.kind].get(a.name);
|
|
150
|
+
if (first) { problems.push({ code: "E_AUTOMATION_DUPLICATE", kind: a.kind, repoKey: m.key, path, message: `${a.kind} id ${JSON.stringify(a.name)} is declared by both ${first} and ${path}; ${path} is not listed` }); continue; }
|
|
151
|
+
seen[a.kind].set(a.name, path);
|
|
152
|
+
try { a.definition = await desc.expand(a); }
|
|
153
|
+
catch (e) {
|
|
154
|
+
a.definition = null;
|
|
155
|
+
a.invalid = { code: e.code || "E_AUTOMATION_SCHEMA", message: e.message, ...(e.field ? { field: e.field } : e.details?.field ? { field: e.details.field } : {}) };
|
|
156
|
+
problems.push({ code: a.invalid.code, kind: a.kind, repoKey: m.key, path, message: `${a.kind} ${a.id}: ${e.message}` });
|
|
157
|
+
}
|
|
158
|
+
byKind[a.kind].push(a);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const list of Object.values(byKind)) list.sort((x, y) => x.id.localeCompare(y.id));
|
|
162
|
+
return { byKind, problems, members };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The soul index a snapshot keeps, so list rows can say where a soul comes from. */
|
|
166
|
+
export function soulIndexOf(discovery, memberName) {
|
|
167
|
+
const byName = {}, byQualified = {};
|
|
168
|
+
const add = (name, qualified, origin) => { (byName[name] ||= []).push(origin); byQualified[qualified] = origin; };
|
|
169
|
+
for (const m of discovery?.members || []) for (const s of m.souls || []) add(s.name, `${memberName(m.key)}/${s.name}`, { kind: "member", repoKey: m.key, member: memberName(m.key) });
|
|
170
|
+
for (const s of discovery?.packageSouls || []) add(s.name, s.qualifiedName, { kind: "package", package: s.package, version: s.version });
|
|
171
|
+
for (const e of discovery?.external || []) add(e.soul.name, `${memberName(e.key)}/${e.soul.name}`, { kind: "external", repoKey: e.key, source: e.source });
|
|
172
|
+
return { byName, byQualified };
|
|
173
|
+
}
|
|
174
|
+
/** Where a soul named by a trigger or schedule comes from, per the snapshot. → origin | null */
|
|
175
|
+
export function soulOriginOf(index, soul) {
|
|
176
|
+
if (!index || typeof soul !== "string") return null;
|
|
177
|
+
if (soul.includes("/")) return index.byQualified?.[soul] ?? null;
|
|
178
|
+
const hits = index.byName?.[soul] ?? [];
|
|
179
|
+
if (hits.length === 1) return hits[0];
|
|
180
|
+
return hits.length ? { kind: "ambiguous", candidates: hits.length } : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ------------------------------------------------------------ snapshot
|
|
184
|
+
|
|
185
|
+
export const snapshotPath = (dep) => join(dep, ".agents", "automations", "snapshot.json");
|
|
186
|
+
export function readSnapshot(dep) {
|
|
187
|
+
try {
|
|
188
|
+
const doc = JSON.parse(readFileSync(snapshotPath(dep), "utf8"));
|
|
189
|
+
return isObject(doc) && KIND_NAMES.every((k) => Array.isArray(doc[`${k}s`])) ? doc : null;
|
|
190
|
+
} catch { return null; }
|
|
191
|
+
}
|
|
192
|
+
export function writeSnapshot(dep, snap) {
|
|
193
|
+
const file = snapshotPath(dep);
|
|
194
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
195
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
196
|
+
writeFileSync(tmp, JSON.stringify(snap, null, 2) + "\n");
|
|
197
|
+
renameSync(tmp, file);
|
|
198
|
+
return file;
|
|
199
|
+
}
|
|
200
|
+
/** The snapshot document of one discovery: one list per kind (`triggers`, `schedules`). */
|
|
201
|
+
export function snapshotOf(discovery, found, { souls = null, now = new Date() } = {}) {
|
|
202
|
+
return {
|
|
203
|
+
automationsApi: AUTOMATIONS_API, takenAt: now.toISOString(),
|
|
204
|
+
workspace: discovery?.standalone === true ? null : { key: discovery?.key ?? null, commit: discovery?.commit ?? null },
|
|
205
|
+
members: found.members,
|
|
206
|
+
...Object.fromEntries(KIND_NAMES.map((k) => [`${k}s`, found.byKind[k] ?? []])),
|
|
207
|
+
problems: found.problems, souls,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const attemptPath = (dep) => join(dirname(snapshotPath(dep)), "last-refresh-attempt");
|
|
211
|
+
function sinceAttempt(dep, now) { try { return now.getTime() - Date.parse(readFileSync(attemptPath(dep), "utf8").trim()); } catch { return Infinity; } }
|
|
212
|
+
const snapshotAge = (snap, now) => (snap?.takenAt ? now.getTime() - Date.parse(snap.takenAt) : Infinity);
|
|
213
|
+
|
|
214
|
+
// ------------------------------------------------------------ this host
|
|
215
|
+
|
|
216
|
+
/** The host's own facts from oats-local.yaml (never in Git): its name and, per kind, the
|
|
217
|
+
* workspace ids it does not run (`triggers.disabled`, `schedules.disabled`). */
|
|
218
|
+
export function hostOf(local) {
|
|
219
|
+
const name = isObject(local?.host) && typeof local.host.name === "string" ? local.host.name : null;
|
|
220
|
+
const disabled = Object.fromEntries(KIND_NAMES.map((k) => [k, new Set(Array.isArray(local?.[`${k}s`]?.disabled) ? local[`${k}s`].disabled : [])]));
|
|
221
|
+
return { name, disabled };
|
|
222
|
+
}
|
|
223
|
+
/** The account the host's `gh` is logged in as on one GitHub host (`gh api user`), memoized in
|
|
224
|
+
* `cache` (one per tick). `io.gh(args)` is the seam. → { ok, login } | { ok: false, error } */
|
|
225
|
+
export function ghLogin(ghHost, { io, cache } = {}) {
|
|
226
|
+
if (cache?.has(ghHost)) return cache.get(ghHost);
|
|
227
|
+
const argv = ["api", "user", "--jq", ".login", ...(ghHost === "github.com" ? [] : ["--hostname", ghHost])];
|
|
228
|
+
let r;
|
|
229
|
+
if (io?.gh) r = io.gh(argv);
|
|
230
|
+
else {
|
|
231
|
+
const p = spawnSync("gh", argv, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, env: { ...process.env, GH_PROMPT_DISABLED: "1" } });
|
|
232
|
+
r = p.error ? { status: 127, stdout: "", stderr: p.error.code === "ENOENT" ? "gh is not installed on this host" : p.error.message } : p;
|
|
233
|
+
}
|
|
234
|
+
const login = String(r.stdout || "").trim();
|
|
235
|
+
const out = r.status === 0 && login ? { ok: true, login } : { ok: false, error: String(r.stderr || r.stdout).split("\n").map((l) => l.trim()).find(Boolean) || `gh exited ${r.status}` };
|
|
236
|
+
cache?.set(ghHost, out);
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
/** Where a workspace trigger or schedule runs, from this host's point of view.
|
|
240
|
+
* → { runsHere, reason: null | host-unnamed | assigned-elsewhere | owner-mismatch, detail?, enabledHere } */
|
|
241
|
+
export function placementOf(a, { host, io, cache }) {
|
|
242
|
+
const enabledHere = a.enabled !== false && !host.disabled[a.kind]?.has(a.id);
|
|
243
|
+
let reason = null, detail;
|
|
244
|
+
if (!host.name) { reason = "host-unnamed"; detail = "this host has no host.name in oats-local.yaml"; }
|
|
245
|
+
else if (a.runsOn !== host.name) { reason = "assigned-elsewhere"; detail = `runs on ${a.runsOn}; this host is ${host.name}`; }
|
|
246
|
+
else {
|
|
247
|
+
const owner = parseOwner(a.owner);
|
|
248
|
+
const who = owner ? ghLogin(owner.host, { io, cache }) : { ok: false, error: "owner is not <host>/<login>" };
|
|
249
|
+
if (!who.ok) { reason = "owner-mismatch"; detail = `acts as ${a.owner}, but this host's gh is not logged in on ${owner?.host ?? "?"}: ${who.error}`; }
|
|
250
|
+
else if (who.login.toLowerCase() !== owner.login.toLowerCase()) { reason = "owner-mismatch"; detail = `acts as ${a.owner}; this host's gh is logged in as ${owner.host}/${who.login}`; }
|
|
251
|
+
}
|
|
252
|
+
return { runsHere: reason === null && enabledHere && !a.invalid && !!a.definition, reason, ...(detail ? { detail } : {}), enabledHere };
|
|
253
|
+
}
|
|
254
|
+
/** A local trigger or schedule as a context entry: this host, implicitly; no runsOn/owner. */
|
|
255
|
+
export function localEntry(kind, def, { invalid = null, dep = null } = {}) {
|
|
256
|
+
const enabled = def.enabled !== false;
|
|
257
|
+
return { kind, id: localId(def.id), name: def.id, member: LOCAL, description: null, runsOn: null, owner: null, enabled, origin: { kind: "local", path: "oats-schedules.json", url: null, localPath: dep ? join(dep, "oats-schedules.json") : null }, definition: def, ...(invalid ? { invalid } : {}), placement: { runsHere: enabled && !invalid, reason: null, enabledHere: enabled } };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** One deployment's context for a tick or a listing: the snapshot (refreshed first when `refresh`
|
|
261
|
+
* and it is ten minutes old — at most one attempt per interval) and each workspace trigger and
|
|
262
|
+
* schedule placed on this host, kept per kind.
|
|
263
|
+
* → { snapshot, host, triggers: [entry + placement], schedules: [...], refresh: null | { ok, error? } } */
|
|
264
|
+
export function automationContext(dep, { local, io, now = new Date(), refresh = false, cache = new Map(), clonePathOf = null } = {}) {
|
|
265
|
+
let snapshot = readSnapshot(dep);
|
|
266
|
+
let refreshed = null;
|
|
267
|
+
const realizesWorkspace = typeof local?.workspace === "string" && local?.standalone === undefined;
|
|
268
|
+
if (refresh && realizesWorkspace && snapshotAge(snapshot, now) >= SNAPSHOT_MAX_AGE_MS && sinceAttempt(dep, now) >= SNAPSHOT_MAX_AGE_MS) {
|
|
269
|
+
// One attempt per interval, whether it works or not: a remote that is down is not asked
|
|
270
|
+
// again every minute (the last good snapshot keeps serving).
|
|
271
|
+
try { mkdirSync(dirname(snapshotPath(dep)), { recursive: true }); writeFileSync(attemptPath(dep), now.toISOString() + "\n"); } catch { /* the refresh itself reports */ }
|
|
272
|
+
refreshed = refreshViaCli(dep, io);
|
|
273
|
+
if (refreshed.ok) snapshot = readSnapshot(dep);
|
|
274
|
+
}
|
|
275
|
+
const host = hostOf(local);
|
|
276
|
+
const place = (list) => (list || []).map((a) => ({ ...a, origin: { ...a.origin, ...originLinks(a.origin, clonePathOf) }, placement: placementOf(a, { host, io, cache }) }));
|
|
277
|
+
const ctx = { snapshot, host, ...Object.fromEntries(KIND_NAMES.map((k) => [`${k}s`, place(snapshot?.[`${k}s`])])), refresh: refreshed };
|
|
278
|
+
/** The login this host's gh is authenticated as on each GitHub host the listed items (and
|
|
279
|
+
* `extraHosts`) name: { <host>: <login> | null }. Asked once per host (the tick's cache). */
|
|
280
|
+
ctx.ghUsers = (extraHosts = []) => {
|
|
281
|
+
const hosts = new Set(extraHosts);
|
|
282
|
+
for (const k of KIND_NAMES) for (const a of ctx[`${k}s`]) { const o = parseOwner(a.owner); if (o) hosts.add(o.host); }
|
|
283
|
+
return Object.fromEntries([...hosts].sort().map((h) => { const who = ghLogin(h, { io, cache }); return [h, who.ok ? who.login : null]; }));
|
|
284
|
+
};
|
|
285
|
+
return ctx;
|
|
286
|
+
}
|
|
287
|
+
/** Where a workspace file can be opened: its web URL at the commit (GitHub-style, for a
|
|
288
|
+
* github.com repository; null otherwise) and its path in this machine's clone of the member
|
|
289
|
+
* (null when the member is not cloned here). → { url, localPath } */
|
|
290
|
+
export function originLinks(origin, clonePathOf) {
|
|
291
|
+
if (origin?.kind !== "workspace") return {};
|
|
292
|
+
const m = /^github\.com\/([^/]+)\/([^/]+)$/.exec(String(origin.repoKey));
|
|
293
|
+
const url = m && origin.commit ? `https://github.com/${m[1]}/${m[2].replace(/\.git$/, "")}/blob/${origin.commit}/${origin.path}` : null;
|
|
294
|
+
let clone = null;
|
|
295
|
+
try { clone = clonePathOf ? clonePathOf(origin.repoKey) : null; } catch { clone = null; }
|
|
296
|
+
return { url, localPath: clone ? join(clone, origin.path) : null };
|
|
297
|
+
}
|
|
298
|
+
/** `oats automations refresh --json` as a child (the tick is synchronous; discovery is not). */
|
|
299
|
+
function refreshViaCli(dep, io) {
|
|
300
|
+
if (io?.refresh) return io.refresh(dep);
|
|
301
|
+
const bin = io?.oatsBin || new URL("../bin/oats.mjs", import.meta.url).pathname;
|
|
302
|
+
const r = spawnSync(process.execPath, [bin, "automations", "refresh", "--dir", dep, "--json"], { cwd: dep, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.refreshTimeoutMs || 180_000, env: io?.childEnv ? io.childEnv() : process.env });
|
|
303
|
+
let env = null;
|
|
304
|
+
try { env = JSON.parse(String(r.stdout || "").trim().split("\n").pop()); } catch { env = null; }
|
|
305
|
+
if (env?.ok === true) return { ok: true };
|
|
306
|
+
return { ok: false, error: env?.error?.message || (r.error ? r.error.message : `automations refresh exited ${r.status}`) };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ------------------------------------------------------------ opting out here
|
|
310
|
+
|
|
311
|
+
/** Add or remove `<member>/<id>` in oats-local.yaml `<kind>s.disabled` (`triggers.disabled` or
|
|
312
|
+
* `schedules.disabled`), keeping the rest of the file (comments included) as written.
|
|
313
|
+
* `validate(value)` → problems, checked before any write. */
|
|
314
|
+
export function setDisabledHere(localPath, kind, qid, disabled, { validate } = {}) {
|
|
315
|
+
const section = `${kind}s`;
|
|
316
|
+
const text = readFileSync(localPath, "utf8");
|
|
317
|
+
const doc = YAML.parseDocument(text, { keepSourceTokens: true });
|
|
318
|
+
if (doc.errors?.length) throw automationError("E_WORKSPACE_SCHEMA", `${localPath}: ${doc.errors[0].message}`);
|
|
319
|
+
const current = doc.getIn([section, "disabled"]);
|
|
320
|
+
const list = current && typeof current.toJSON === "function" ? current.toJSON() : Array.isArray(current) ? current : [];
|
|
321
|
+
const next = disabled ? [...new Set([...list, qid])].sort() : list.filter((x) => x !== qid);
|
|
322
|
+
if (JSON.stringify(next) === JSON.stringify(list)) return { changed: false, disabled: list };
|
|
323
|
+
if (next.length) doc.setIn([section, "disabled"], next);
|
|
324
|
+
else if (doc.hasIn([section, "disabled"])) {
|
|
325
|
+
doc.deleteIn([section, "disabled"]);
|
|
326
|
+
const rest = doc.get(section);
|
|
327
|
+
if (rest && typeof rest.toJSON === "function" && !Object.keys(rest.toJSON() || {}).length) doc.delete(section);
|
|
328
|
+
}
|
|
329
|
+
const out = doc.toString();
|
|
330
|
+
const value = parseConfigData(out, { origin: { kind: "local", path: localPath } }).value;
|
|
331
|
+
const problems = validate ? validate(value) : [];
|
|
332
|
+
if (problems.length) throw automationError("E_WORKSPACE_SCHEMA", `the rewritten ${localPath} would be invalid (${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}); nothing was written`);
|
|
333
|
+
writeFileSync(localPath, out);
|
|
334
|
+
return { changed: true, disabled: next };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ------------------------------------------------------------ writing a workspace file
|
|
338
|
+
|
|
339
|
+
/** The YAML text of a workspace trigger or schedule file: the shared header, then the kind's body. */
|
|
340
|
+
export function automationFileText(desc, { id, description, runsOn, owner, body }) {
|
|
341
|
+
return YAML.stringify({ kind: desc.fileKind, schemaVersion: 1, id, ...(description ? { description } : {}), runsOn, owner, ...body }, { lineWidth: 0 });
|
|
342
|
+
}
|
|
343
|
+
/** Where `oats trigger|schedule add --workspace <member>` writes: the git checkout containing
|
|
344
|
+
* `dir` when its origin remote IS that member's repository (`sameRepo(url)` decides), else null
|
|
345
|
+
* (the caller prints the file instead). → { root, file, rel } | null */
|
|
346
|
+
export function memberCheckoutFor(dir, desc, id, { sameRepo }) {
|
|
347
|
+
const top = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 });
|
|
348
|
+
if (top.status !== 0) return null;
|
|
349
|
+
const root = top.stdout.trim();
|
|
350
|
+
const url = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 });
|
|
351
|
+
if (url.status !== 0 || !sameRepo(url.stdout.trim())) return null;
|
|
352
|
+
const rel = `${desc.folder}/${id}.yaml`;
|
|
353
|
+
return { root, file: join(root, rel), rel };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ------------------------------------------------------------ list rows
|
|
357
|
+
|
|
358
|
+
/** The fields every trigger row and every schedule row share (docs/desktop-cli-api.md): identity,
|
|
359
|
+
* origin, who and where. Each kind's module adds its own (`kind`, the soul, the task, the event
|
|
360
|
+
* or the cron, the last and next run). */
|
|
361
|
+
export function baseRow(a) {
|
|
362
|
+
return {
|
|
363
|
+
id: a.id, name: a.name, origin: a.origin, description: a.description ?? null,
|
|
364
|
+
owner: a.owner ?? null, runsOn: a.runsOn ?? null,
|
|
365
|
+
runsHere: a.placement.runsHere, reason: a.placement.reason, ...(a.placement.detail ? { reasonDetail: a.placement.detail } : {}),
|
|
366
|
+
enabledHere: a.placement.enabledHere,
|
|
367
|
+
...(a.invalid ? { invalid: a.invalid } : {}),
|
|
368
|
+
};
|
|
369
|
+
}
|