@awebai/oats 0.29.3 → 0.29.4
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 +9 -4
- package/capabilities/oats-okf/bin/oats-okf.mjs +9 -6
- package/capabilities/oats-okf/lib/config.mjs +2 -1
- package/capabilities/oats-okf/lib/consult.mjs +26 -4
- package/capabilities/oats-okf/lib/harvest-switch.mjs +16 -3
- package/capabilities/oats-okf/lib/io.mjs +9 -1
- package/capabilities/oats-okf/lib/sources.mjs +18 -1
- package/capabilities/oats-okf/lib/stores.mjs +8 -6
- package/capabilities/oats-okf/lib/worker.mjs +2 -1
- package/capabilities/oats-okf/oats.json +1 -1
- package/capabilities/oats-okf-harvest/oats.json +1 -1
- package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +35 -14
- package/capabilities/oats-okf-maintenance/lib/provenance.mjs +6 -1
- package/capabilities/oats-okf-maintenance/oats.json +1 -1
- package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +16 -1
- package/docs/capability-manifest.schema.json +3 -1
- package/docs/design/2026-09-27-team-model-v2.md +136 -0
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +4 -4
- package/docs/release-notes/v0.29.4.md +90 -0
- package/docs/schedules.md +18 -4
- package/docs/workspaces.md +2 -2
- package/lib/automations.mjs +7 -0
- package/lib/packages.mjs +2 -5
- package/lib/schedule.mjs +31 -17
- package/lib/triggers.mjs +51 -15
- package/package-catalog.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Team model v2: the workspace defines teams and the default; souls declare which they may join
|
|
2
|
+
|
|
3
|
+
Status: **PROPOSED 2026-09-27** (the lead drafts; the messaging co-lead shapes it; the human confirms the open questions). It supersedes teams-contract §3 amendment K's default-team rule and the "primary" label. The rest of `2026-09-25-teams-contract.md` stands: explicit join, only eligible labels, live reconciliation, and the provider's join/leave verbs.
|
|
4
|
+
|
|
5
|
+
## The human's direction (2026-09-27, verbatim)
|
|
6
|
+
|
|
7
|
+
> "El workspace define los equipos que hay, y a que equipo se instancian los souls por default. Y luego en el soul.yaml defines a que equipos se puede unir ese soul, y un default si quieres override el default de el workspace."
|
|
8
|
+
>
|
|
9
|
+
> "we do not need aweb primitives for this, we already have teams. we need to be able to add and remove souls to teams."
|
|
10
|
+
>
|
|
11
|
+
> "only pepe and i are using this for now, just clean up and do the right thing. no backwards comp required. setup may require creating accounts and teams, we need to support onboarding."
|
|
12
|
+
|
|
13
|
+
## Why: today's model has four overlapping ideas
|
|
14
|
+
|
|
15
|
+
1. The workspace's `teams:` labels + `messaging.byTeam.<label>` map labels to provider teams. This part is right.
|
|
16
|
+
2. **The default team is not the workspace's.** After amendment K it's the provider setting `team`, else the provider's own default. For oats.aweb that's the messaging root's active team: host state, invisible in config.
|
|
17
|
+
3. **"Primary"** (the first of a soul's `team:` labels) sets `OATS_TEAM_LABEL` and ordering but isn't the default team.
|
|
18
|
+
4. **`oats-membership.yaml` `team`** is a repository-level default label layered under the soul's.
|
|
19
|
+
|
|
20
|
+
On top of that, a soul can override the default only by writing a provider team *id* in its messaging slot, not a workspace label. And adding or removing a soul's teams means hand-editing YAML.
|
|
21
|
+
|
|
22
|
+
## The model
|
|
23
|
+
|
|
24
|
+
### The workspace (`oats-workspace.yaml`)
|
|
25
|
+
```yaml
|
|
26
|
+
teams:
|
|
27
|
+
dev: { description: … }
|
|
28
|
+
platform: { description: … }
|
|
29
|
+
defaultTeam: dev # NEW: required when `teams` is non-empty; a declared label
|
|
30
|
+
messaging:
|
|
31
|
+
oats.aweb: { from: package }
|
|
32
|
+
byTeam:
|
|
33
|
+
dev: { team: <provider team id> }
|
|
34
|
+
platform: { team: <provider team id> }
|
|
35
|
+
```
|
|
36
|
+
- **`teams:`** declares the teams that exist (labels), as today.
|
|
37
|
+
- **`defaultTeam:`** is the team a soul's instances go to by default. It's a label, never a provider id.
|
|
38
|
+
- **`messaging.byTeam.<label>`** maps each label to its provider team, as today. It's the ONLY place a provider team id is written.
|
|
39
|
+
- **Validation:** `defaultTeam` must be a declared label (`E_TEAM_UNKNOWN`). Once messaging is active, the default team's label must be mapped (`E_TEAM_UNMAPPED`, a refusal, not a warning: an instance must have somewhere to live).
|
|
40
|
+
|
|
41
|
+
### The soul (`soul.yaml`)
|
|
42
|
+
```yaml
|
|
43
|
+
teams: [dev, platform] # RENAMED from `team`: the teams this soul MAY join
|
|
44
|
+
defaultTeam: platform # NEW, optional: overrides the workspace's; must be in `teams`
|
|
45
|
+
```
|
|
46
|
+
- **`teams:`** is the eligible labels. Each must be declared by the workspace (`E_TEAM_UNKNOWN`), as today. The list is unordered: **"primary" goes away.**
|
|
47
|
+
- **`defaultTeam:`** is optional. It must be one of the soul's `teams`, else `E_TEAM_NOT_ELIGIBLE`. When omitted, the workspace's `defaultTeam` applies, and the soul is eligible for it implicitly.
|
|
48
|
+
- **Removed:**
|
|
49
|
+
- a soul's messaging-slot provider team id override;
|
|
50
|
+
- `oats-membership.yaml` `team` (two layers only: the workspace, then the soul);
|
|
51
|
+
- the old `team:` key.
|
|
52
|
+
|
|
53
|
+
No aliases (the human: no backwards compatibility).
|
|
54
|
+
|
|
55
|
+
### Resolution (the kernel)
|
|
56
|
+
- `effectiveDefault = soul.defaultTeam ?? workspace.defaultTeam`.
|
|
57
|
+
- The provider receives the default team's mapped payload as its default. It gets **no root-active-team fallback.**
|
|
58
|
+
- Plus the eligible set, `{label → payload}` for every label in `soul.teams ∪ {effectiveDefault}` that the workspace maps.
|
|
59
|
+
- **The env names** (renamed, no aliases):
|
|
60
|
+
- `OATS_DEFAULT_TEAM` (the label) and `OATS_DEFAULT_TEAM_ID` (its mapped provider id);
|
|
61
|
+
- `OATS_TEAMS` (the JSON of the eligible set).
|
|
62
|
+
- `OATS_TEAM_LABEL`, `OATS_TEAM_LABELS` and `OATS_TEAM_ID` go.
|
|
63
|
+
- **A standalone deployment** (no workspace) sets `teams` / `defaultTeam` / `messaging.byTeam` in its local config, with the same rules.
|
|
64
|
+
|
|
65
|
+
### At spawn, and live
|
|
66
|
+
- An instance is **always in its effective default team** (it can't leave it: `E_TEAM_DEFAULT`).
|
|
67
|
+
- It joins other eligible teams explicitly: the spawn choice `join=…`, or the provider's join/leave on a live instance. This is unchanged from the teams contract.
|
|
68
|
+
- **When a soul loses a label**, or the workspace unmaps it, a joined instance leaves it on the next live read. This is unchanged: teams contract §7 decision 6.
|
|
69
|
+
- **When the effective default changes** (the workspace or soul `defaultTeam` edited), running instances keep their team until respawn. Readiness warns (`default-team-changed`) and names the new default.
|
|
70
|
+
|
|
71
|
+
### Verbs: add and remove a soul's teams, set its default
|
|
72
|
+
The CLI edits `soul.yaml` for a soul the deployment can edit (a member soul in a clone on this computer, or a local soul):
|
|
73
|
+
```
|
|
74
|
+
oats soul teams <soul> # eligible, default (and where it comes from)
|
|
75
|
+
oats soul teams <soul> --add <label>[,<label>] | --remove <label>[,<label>]
|
|
76
|
+
oats soul teams <soul> --default <label> | --clear-default
|
|
77
|
+
```
|
|
78
|
+
- Each edit validates against the workspace (known label; the default ∈ teams) and writes the file.
|
|
79
|
+
- **For a member soul it edits the clone's working tree and says so:** the change travels by commit/PR, as every config change does (oats.setup). It never pushes.
|
|
80
|
+
- **Package souls are read-only:** their teams are the package's. A workspace adds a package soul to a team through the workspace instead, `teams.<label>.souls: [<pkg>/<soul>]`, which extends that soul's eligible set. Proposed; see Open question 3.
|
|
81
|
+
- The Desktop gets the same controls on a soul's page (add/remove/default), and the spawn dialog shows the default + eligible teams.
|
|
82
|
+
|
|
83
|
+
### Onboarding (setup creates accounts and teams)
|
|
84
|
+
- **`oats aweb setup`** (the provider's setup verb, a setup-time human act):
|
|
85
|
+
- creates the account (`aw init --new-account --username …`) when none exists;
|
|
86
|
+
- creates every declared team that the workspace doesn't map yet;
|
|
87
|
+
- writes the resulting ids into `messaging.byTeam` **as a proposed diff** for the human to commit (oats.setup: config changes by PR).
|
|
88
|
+
- It never runs at spawn, mint, retire or wake.
|
|
89
|
+
- **What aweb allows today** (the messaging lane, from aweb's lead, 2026-09-27):
|
|
90
|
+
- **The first account and its default team:** fully automatable (`aw init --new-account --username …`).
|
|
91
|
+
- **An additional team on a BYOD domain:** automatable headlessly with the namespace controller key:
|
|
92
|
+
1. `aw id team create --name <t> --namespace <domain>`;
|
|
93
|
+
2. the team key signs `aw id team invite`;
|
|
94
|
+
3. the root runs `accept-invite --local`.
|
|
95
|
+
|
|
96
|
+
`aw id team register` hosts it on aweb.ai. No human login is needed.
|
|
97
|
+
- **An additional team on a hosted account (`<u>.aweb.ai`):** NO CLI path. Only a logged-in human creates it, in the dashboard. aweb's lead proposes a generic `aw team create <name>` under the logged-in account.
|
|
98
|
+
- **Update (2026-09-27, the human's decision via aweb's lead): the hosted gap closes headlessly** (aweb `aweb-abkh`, pending a Cloud + CLI release).
|
|
99
|
+
- A member of an org-owned hosted team creates a sibling team in the same account with `aw id team create --name <t>`, which returns the new `team_id` + a **single-use invite token**. The caller doesn't auto-join.
|
|
100
|
+
- The home that should hold the new team's member runs `aw --identity-home <root> id team accept-invite <token> --name <alias> --local`.
|
|
101
|
+
- No TTY, no `aw auth`; bounded by the account plan's team limit.
|
|
102
|
+
- **So setup is designed FULLY HEADLESS:**
|
|
103
|
+
1. The account + the workspace's default team: `aw init --new-account --username …`.
|
|
104
|
+
2. Every further declared team (hosted or BYOD): `aw id team create --name <label>` + `accept-invite --local` into the deployment's root.
|
|
105
|
+
3. Setup writes each new id into `messaging.byTeam` as a proposed diff for the human to commit.
|
|
106
|
+
- **The only gate is the aw/Cloud version floor** that ships `aweb-abkh`. Below it, a hosted extra team is refused with the remedy "upgrade aw" (the provider's readiness names the floor), not a guided dashboard step. The model doesn't change.
|
|
107
|
+
- Setup needs no human login at all on this path.
|
|
108
|
+
- Setup never runs at spawn/mint/retire/wake, and OATS holds no human login (the provider consumes the resulting root).
|
|
109
|
+
- The oats.setup skills (`oats-teams`, `oats-onboarding`, `oats-workspace-config`) teach the model and the verbs.
|
|
110
|
+
|
|
111
|
+
## Open questions (for the human)
|
|
112
|
+
1. **At spawn:** is an instance in ONLY its default team (others joined explicitly), as proposed? Or does it join every team its soul lists?
|
|
113
|
+
2. **When a soul's team is removed:** do running instances leave on the next live read (proposed, as today), or only when told to?
|
|
114
|
+
3. **Package souls:** is a workspace-side `teams.<label>.souls: [...]` the right way to add a package soul to a team? The alternative is that package souls have only their package's teams.
|
|
115
|
+
|
|
116
|
+
## Sequencing (proposed)
|
|
117
|
+
1. **Now, small (the messaging lane):** an oats.aweb release with the setup `--new-account` fix + the dead `helperInjection` key.
|
|
118
|
+
2. **This design:** the co-lead shapes it → the human answers 1–3 → **Decided**.
|
|
119
|
+
3. **Kernel 0.30.0 (breaking):**
|
|
120
|
+
- the schemas;
|
|
121
|
+
- resolution + env;
|
|
122
|
+
- the `oats soul teams` verb;
|
|
123
|
+
- readiness;
|
|
124
|
+
- the oats.setup skills;
|
|
125
|
+
- docs.
|
|
126
|
+
4. **oats.aweb 1.17:** the default from the kernel (no root-active fallback); `oats aweb setup` onboarding.
|
|
127
|
+
5. **Desktop:**
|
|
128
|
+
- the server passes the new fields (the engineer);
|
|
129
|
+
- the soul-page team controls + the spawn dialog (the ux-designer).
|
|
130
|
+
6. **The KB + migration** of the two existing deployments (one-shot, by the humans with the setup-admin soul).
|
|
131
|
+
|
|
132
|
+
**Owners (proposed):**
|
|
133
|
+
- the kernel: a cli-dev;
|
|
134
|
+
- oats.aweb + onboarding: the messaging co-lead's developer;
|
|
135
|
+
- the Desktop: the engineer + the ux-designer;
|
|
136
|
+
- this doc, the review and the release: the lead.
|
package/docs/official-catalog.md
CHANGED
|
@@ -68,8 +68,8 @@ this policy does not invent new catalog or manifest fields.
|
|
|
68
68
|
- Listed capabilities: `oats.okf`, `oats.okf-harvest`, `oats.okf-maintenance`,
|
|
69
69
|
`oats.aweb`, `oats.authoring`, `oats.jira`, `oats.linear`, `oats.dev`,
|
|
70
70
|
`oats.knowledge-theory`, `oats.core` and `oats.setup`. `oats.okf-harvest` and
|
|
71
|
-
`oats.okf-maintenance` select the `oats.okf` package (4.0.
|
|
72
|
-
- **`oats.framework` 1.3.
|
|
71
|
+
`oats.okf-maintenance` select the `oats.okf` package (4.0.1).
|
|
72
|
+
- **`oats.framework` 1.3.2** is listed at tag `oats-framework/v1.3.2` in
|
|
73
73
|
`awebai/oats`, payload root `oats-package`. The `oats.core`, `oats.setup` and
|
|
74
74
|
`oats.knowledge-theory` aliases select that distribution; package identity is
|
|
75
75
|
distinct from capability identity. Core supplies operation/soul guidance;
|
package/docs/packages.md
CHANGED
|
@@ -74,8 +74,8 @@ members:
|
|
|
74
74
|
- git:github.com/acme/agents
|
|
75
75
|
- git:github.com/acme/platform
|
|
76
76
|
packages:
|
|
77
|
-
oats.framework: v1.3.
|
|
78
|
-
oats.okf: v4.0.
|
|
77
|
+
oats.framework: v1.3.2
|
|
78
|
+
oats.okf: v4.0.1
|
|
79
79
|
oats.aweb: v1.16.0
|
|
80
80
|
teams:
|
|
81
81
|
global: { description: Org-wide }
|
|
@@ -344,8 +344,8 @@ A soul that names one of the package's capabilities with
|
|
|
344
344
|
}
|
|
345
345
|
```
|
|
346
346
|
|
|
347
|
-
`ref` carries the tag convention: a workspace's `oats.framework: v1.3.
|
|
348
|
-
resolves to tag `oats-framework/v1.3.
|
|
347
|
+
`ref` carries the tag convention: a workspace's `oats.framework: v1.3.2`
|
|
348
|
+
resolves to tag `oats-framework/v1.3.2`. Resolving through the catalog never
|
|
349
349
|
advances a lock by itself — `oats sync` does, and
|
|
350
350
|
says so.
|
|
351
351
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# OATS 0.29.4
|
|
2
|
+
|
|
3
|
+
## Security
|
|
4
|
+
|
|
5
|
+
- **An automation's `model` could inject spawn options** (#255). A trigger's
|
|
6
|
+
`spawn.model` or a schedule's `model` such as `--task-file=<path>` was passed
|
|
7
|
+
to the child `oats spawn` as a flag of its own. It could make any readable
|
|
8
|
+
file the instance's `TASK.md`, or add `--work` or `--allow-child-spawns`,
|
|
9
|
+
past the automation schema. Three changes close it:
|
|
10
|
+
- `model` must be a model id; a schedule's `agent` and `repo` may not start
|
|
11
|
+
with `-`;
|
|
12
|
+
- every value reaches the child as one `--flag=value` token;
|
|
13
|
+
- the CLI refuses an inline value that is itself an option (`--model=--yolo`
|
|
14
|
+
is `E_BAD_ARGS`).
|
|
15
|
+
- **oats.okf 4.0.1: a crafted Git base could write files anywhere the user
|
|
16
|
+
can write** (okf 3.0.0 and 4.0.0). Every tree entry of a base's accepted
|
|
17
|
+
commit is now contained, and a bad entry refuses the whole base with
|
|
18
|
+
`E_PATH`. Upgrade the pin; details under oats.okf 4.0.1 below.
|
|
19
|
+
|
|
20
|
+
## Fixed
|
|
21
|
+
|
|
22
|
+
- **A member repository named `local`** collided with this host's own
|
|
23
|
+
triggers and schedules (`local/<id>`): their state, live counts and CLI
|
|
24
|
+
edits. Such a member is now `E_AUTOMATION_DUPLICATE`, and its automations
|
|
25
|
+
are not listed (#256).
|
|
26
|
+
- **A workspace command's `cwd` may no longer leave the deployment**, through
|
|
27
|
+
`..` or a symlink (#256).
|
|
28
|
+
- **A newer push supersedes a pending `synchronize`** for an older head of the
|
|
29
|
+
same pull request (#256).
|
|
30
|
+
- **A workspace trigger's `owner` and `on.repo` must be on the same GitHub
|
|
31
|
+
host** (#256).
|
|
32
|
+
- **Upgrading from 0.28 re-fired every open pull request.** Trigger state kept
|
|
33
|
+
under a local trigger's bare id (0.28) is now carried to `local/<id>`, so
|
|
34
|
+
nothing re-fires. Homes that 0.28 spawned still count toward the trigger's
|
|
35
|
+
concurrency (#256).
|
|
36
|
+
- **A package soul's command could get another soul's `OATS_SOUL`.** A command
|
|
37
|
+
run from the deployment with `--soul <package>/<soul>` read the soul's copy
|
|
38
|
+
under the bare name's agent directory. It ran without `OATS_SOUL`, or with a
|
|
39
|
+
same-named member soul's copy. It now reads `agents/<package>--<soul>/`
|
|
40
|
+
(#256).
|
|
41
|
+
|
|
42
|
+
## Changed
|
|
43
|
+
|
|
44
|
+
- **`helperInjection` is marked deprecated in the capability manifest
|
|
45
|
+
schema** (#257). It has been ignored since 0.26 and is still accepted, so
|
|
46
|
+
old manifests validate. oats.core 2.1.2 and oats.setup 2.1.3 no longer
|
|
47
|
+
declare it; the catalog and this repo's workspace pin `oats.framework` to
|
|
48
|
+
`oats-framework/v1.3.2`, which ships them.
|
|
49
|
+
- **`triggersMaxConcurrent`** in the host registry
|
|
50
|
+
(`~/.oats/schedules/registry.json`) caps trigger-spawned live instances
|
|
51
|
+
across the host's scopes (#256). It is absent by default, which means no
|
|
52
|
+
host cap: each trigger's own `concurrency.max` applies. It is separate from
|
|
53
|
+
the schedules' `maxConcurrent`: neither counts the other's instances.
|
|
54
|
+
|
|
55
|
+
## oats.okf 4.0.1 (catalog pin and bundled mirror)
|
|
56
|
+
|
|
57
|
+
The catalog and this repo's workspace pin `oats.okf` to `v4.0.1`. The three
|
|
58
|
+
bundled capabilities (`oats.okf`, `oats.okf-harvest`, `oats.okf-maintenance`)
|
|
59
|
+
are the tag's trees. The kernel floor stays `>=0.29.0`.
|
|
60
|
+
|
|
61
|
+
- **Security: a crafted Git base could write files anywhere the user can
|
|
62
|
+
write.** This affects okf 3.0.0 and 4.0.0: upgrade.
|
|
63
|
+
- Validating a Git base's accepted commit copied every tree entry into a
|
|
64
|
+
scratch directory with no containment check. Git accepts literal `..`
|
|
65
|
+
entries.
|
|
66
|
+
- The first `oats okf bases`, or a spawn against a crafted accepted commit,
|
|
67
|
+
could create any file that did not yet exist, with attacker content.
|
|
68
|
+
- Every entry is now checked against the canonical path rules, and its
|
|
69
|
+
target must stay inside the scratch. All entries are checked before
|
|
70
|
+
anything is written, and the first bad entry refuses the whole base with
|
|
71
|
+
`E_PATH`. The staging materializer asserts the same.
|
|
72
|
+
- GitHub probably blocks such a push; local and self-hosted bases do not.
|
|
73
|
+
- **Credentials are never shown to agents.** A repository URL with a user or
|
|
74
|
+
token in it is refused at binding: use a Git credential helper or an SSH
|
|
75
|
+
key. Every repository in `bases` output and in errors is redacted,
|
|
76
|
+
including Git's own failure text.
|
|
77
|
+
- **Knowledge review trusts only the accepted base.**
|
|
78
|
+
`review-context --checkout` takes node ownership from the base's accepted
|
|
79
|
+
`okf-base.json`, never from the PR. It lists the nodes a PR claims but the
|
|
80
|
+
source does not own, and flags any change to `okf-base.json`.
|
|
81
|
+
- **A merge is tied to the reviewed head**
|
|
82
|
+
(`gh pr merge --match-head-commit`). **`okf-needs-human` is a hard stop**
|
|
83
|
+
for every PR event, until a human removes the label.
|
|
84
|
+
- **Turning harvest off takes effect at once.** `run-source` and `retire`
|
|
85
|
+
re-read the harvest switch, including the soul's. A soul or deployment that
|
|
86
|
+
turns harvest off after spawn stops capture at the next run. Retire then
|
|
87
|
+
takes no final capture; inputs already in custody stay.
|
|
88
|
+
- **`OATS_SETTINGS_ORIGINS` is an extra signal:** a harvest value that comes
|
|
89
|
+
from the soul never switches harvest on. `soul.yaml` stays the authority for
|
|
90
|
+
the absolute opt-out.
|
package/docs/schedules.md
CHANGED
|
@@ -27,7 +27,11 @@ and no queue.
|
|
|
27
27
|
- `<workspace>/.agents/schedules/state.json` — last attempted minute and
|
|
28
28
|
last run per job (gitignored), plus one lock directory per running job.
|
|
29
29
|
- `~/.oats/schedules/registry.json` — the host registry: which scopes the
|
|
30
|
-
host ticks, `maxConcurrent` (default 1)
|
|
30
|
+
host ticks, `maxConcurrent` (default 1: running scheduled jobs), the tick
|
|
31
|
+
interval, and `triggersMaxConcurrent` (absent by default: no host cap; a
|
|
32
|
+
positive integer caps trigger-spawned live instances across the host's
|
|
33
|
+
scopes). The two caps are separate: a running scheduled job never holds a
|
|
34
|
+
trigger, and a trigger's instances never hold a schedule. One host
|
|
31
35
|
lock serializes ticks, run-now, reconcile and remove; it is never reclaimed
|
|
32
36
|
by another process: a lock whose owner is unreadable or gone is reported
|
|
33
37
|
with the directory to remove, and the holder removes its own lock on exit
|
|
@@ -41,7 +45,11 @@ see [Captured definitions](#captured-definitions-removed-in-026).
|
|
|
41
45
|
- **spawn** `{id, enabled, cron, tz, kind: "spawn", agent, agentsRoot?,
|
|
42
46
|
repo?, backend?, purpose?, task, launchConfig?, harness?, model?, yolo?, wake?}` — every
|
|
43
47
|
due minute launches one disposable instance of `agent` with the same
|
|
44
|
-
options `oats spawn` takes. `
|
|
48
|
+
options `oats spawn` takes. `model` is a model id (a letter or digit, then
|
|
49
|
+
letters, digits and `. _ : / @ + - [ ]`, at most 128 characters, or
|
|
50
|
+
`@native-default`), and `agent` and `repo` never start with `-`: an
|
|
51
|
+
automation's values reach the child `oats spawn` as single `--flag=value`
|
|
52
|
+
tokens and can never be read as options of their own. `agentsRoot` names the exact agents root that
|
|
45
53
|
holds the soul (it must lie inside the workspace and defaults to the
|
|
46
54
|
workspace's own root); it is what tells same-named souls in different
|
|
47
55
|
member repositories apart. `repo` is the work repository, as `--repo`.
|
|
@@ -144,8 +152,14 @@ logged in with the keyring or its config file under your HOME works there. A
|
|
|
144
152
|
finds its own earlier review, for example).
|
|
145
153
|
- **Concurrency.** `max` (default 1) bounds the live instances of the trigger,
|
|
146
154
|
`perKey` (default 1) those of one PR; both are counted from the homes'
|
|
147
|
-
`instance.json.trigger` records, so a retired instance frees its slot.
|
|
148
|
-
|
|
155
|
+
`instance.json.trigger` records, so a retired instance frees its slot. The
|
|
156
|
+
host registry's `triggersMaxConcurrent`, when set, bounds every trigger's
|
|
157
|
+
live instances together. An event over a bound stays pending (`held`). A
|
|
158
|
+
newer push supersedes a pending `synchronize` for an older head of the same
|
|
159
|
+
PR, so only the newest head is reviewed.
|
|
160
|
+
- **The owner acts on the repository's host.** A workspace trigger's `owner`
|
|
161
|
+
and `on.repo` must be on the same GitHub host (`E_TRIGGER_INVALID`, field
|
|
162
|
+
`owner`): the owner check and the poll ask gh on that one host.
|
|
149
163
|
- **The spawn** is `oats spawn` (the same path as a scheduled spawn). `soul` is
|
|
150
164
|
bare or qualified (`<package>/<soul>`). `purpose` (default
|
|
151
165
|
`{trigger}-{number}`, must render to a slug) and `task` are templated from
|
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.3.
|
|
63
|
-
oats.okf: v4.0.
|
|
62
|
+
oats.framework: v1.3.2 # bare version → resolves through the official catalog
|
|
63
|
+
oats.okf: v4.0.1
|
|
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
|
package/lib/automations.mjs
CHANGED
|
@@ -43,6 +43,10 @@ export const PLACEMENT_REASONS = Object.freeze(["host-unnamed", "assigned-elsewh
|
|
|
43
43
|
export const KIND_NAMES = Object.freeze(["trigger", "schedule"]);
|
|
44
44
|
const NEVER_SCANNED = new Set(["oats-package", ".git", "node_modules"]);
|
|
45
45
|
export const AUTOMATION_ID_RE = /^[a-z0-9-]{1,40}$/;
|
|
46
|
+
/** A model id an automation may pass to `oats spawn --model`: a provider/model id, never an option.
|
|
47
|
+
* The first character is alphanumeric (or the spawn CLI's own `@native-default`), so no value can
|
|
48
|
+
* be read as a flag (re-review B #1). `[` `]` admit a harness's context-size alias (`opus[1m]`). */
|
|
49
|
+
export const MODEL_RE = /^(?:@native-default|[A-Za-z0-9][A-Za-z0-9._:/@+\[\]-]{0,127})$/;
|
|
46
50
|
export const HOST_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
47
51
|
const OWNER_RE = /^([a-z0-9-]+(?:\.[a-z0-9-]+)+)\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$/;
|
|
48
52
|
const HEADER_KEYS = ["kind", "schemaVersion", "id", "description", "runsOn", "owner", "enabled"];
|
|
@@ -123,6 +127,9 @@ export async function discoverAutomations(discovery, { remote, memberName, kinds
|
|
|
123
127
|
const names = new Map();
|
|
124
128
|
for (const m of (discovery?.members || []).filter((x) => x.confirmed && x.commit)) {
|
|
125
129
|
const name = memberName(m.key);
|
|
130
|
+
// `local/<id>` names this host's own automations (and their state, live counts and CLI edits):
|
|
131
|
+
// a member labelled `local` would collide with them (re-review B #3).
|
|
132
|
+
if (name === LOCAL) { problems.push({ code: "E_AUTOMATION_DUPLICATE", repoKey: m.key, path: "", message: `member name ${JSON.stringify(name)} is reserved for this host's own triggers and schedules (local/<id>), so ${m.key}'s are not listed — rename the repository to list them` }); continue; }
|
|
126
133
|
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
134
|
names.set(name, m.key);
|
|
128
135
|
let candidates;
|
package/lib/packages.mjs
CHANGED
|
@@ -372,11 +372,8 @@ export async function packageSoulDigests(remote, remoteRef, commit, souls, detai
|
|
|
372
372
|
if (e?.code === "E_REMOTE_PATH_MISSING") throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} is missing at ${String(commit).slice(0, 12)}`, { ...details, soul: soul.name, path: soul.dir });
|
|
373
373
|
throw e;
|
|
374
374
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
for (const file of ["soul.yaml", "AGENTS.md"]) {
|
|
378
|
-
if (!existsSync(join(dest, file))) throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} lacks ${file} at ${String(commit).slice(0, 12)} — a soul directory carries soul.yaml and AGENTS.md`, { ...details, soul: soul.name, path: soul.dir, missing: file });
|
|
379
|
-
}
|
|
375
|
+
for (const file of ["soul.yaml", "AGENTS.md"]) {
|
|
376
|
+
if (!existsSync(join(dest, file))) throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} lacks ${file} at ${String(commit).slice(0, 12)} — a soul directory carries soul.yaml and AGENTS.md`, { ...details, soul: soul.name, path: soul.dir, missing: file });
|
|
380
377
|
}
|
|
381
378
|
out.push({ name: soul.name, path: soul.path, digest: assertDigest(`package soul ${soul.path} digest`, digest, details) });
|
|
382
379
|
}
|
package/lib/schedule.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* succeeded. A launch whose side effects cannot be confirmed stays
|
|
22
22
|
* `unknown` with its slot held until `reconcile` proves what happened. */
|
|
23
23
|
import { spawnSync } from "node:child_process";
|
|
24
|
-
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { createHash } from "node:crypto";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
@@ -31,7 +31,7 @@ import { loadLocal } from "./workspace.mjs";
|
|
|
31
31
|
import { noteRuntimeName } from "./deprecation.mjs";
|
|
32
32
|
import { tickTriggers } from "./triggers.mjs";
|
|
33
33
|
import { resolveMemberClone } from "./instance-resolution.mjs";
|
|
34
|
-
import { AUTOMATION_ID_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
|
|
34
|
+
import { AUTOMATION_ID_RE, MODEL_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
|
|
35
35
|
import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
36
36
|
|
|
37
37
|
export const SCHEDULE_FILE = "oats-schedules.json";
|
|
@@ -208,6 +208,12 @@ export function readRegistry() {
|
|
|
208
208
|
if (!Array.isArray(reg.workspaces)) reg.workspaces = [];
|
|
209
209
|
if (!Number.isInteger(reg.maxConcurrent) || reg.maxConcurrent < 1) reg.maxConcurrent = 1;
|
|
210
210
|
if (!Number.isInteger(reg.tickIntervalSec) || reg.tickIntervalSec < 60) reg.tickIntervalSec = 60;
|
|
211
|
+
// The host cap on trigger-spawned live instances (re-review B #5): absent = unbounded (each
|
|
212
|
+
// trigger's own concurrency.max still applies). Separate from maxConcurrent: a running scheduled
|
|
213
|
+
// job never holds a trigger, and trigger homes never hold a schedule. Hand-edited, so refused loudly.
|
|
214
|
+
if (reg.triggersMaxConcurrent !== undefined && !(Number.isSafeInteger(reg.triggersMaxConcurrent) && reg.triggersMaxConcurrent >= 1)) {
|
|
215
|
+
throw scheduleError("E_SCHEDULE_INVALID", `${join(hostScheduleDir(), "registry.json")}: triggersMaxConcurrent must be a positive integer, or absent for no host cap (got ${JSON.stringify(reg.triggersMaxConcurrent)})`, { field: "triggersMaxConcurrent" });
|
|
216
|
+
}
|
|
211
217
|
return reg;
|
|
212
218
|
}
|
|
213
219
|
export function writeRegistry(reg) { writeJson(join(hostScheduleDir(), "registry.json"), reg); }
|
|
@@ -300,14 +306,14 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
300
306
|
validateCron(def.cron, def.tz);
|
|
301
307
|
const out = { id, enabled, cron: def.cron.trim(), tz: def.tz.trim(), kind: def.kind };
|
|
302
308
|
if (def.kind === "spawn") {
|
|
303
|
-
if (typeof def.agent !== "string" || !def.agent.trim()) throw scheduleError("E_SCHEDULE_INVALID", "agent: soul name required", { field: "agent" });
|
|
309
|
+
if (typeof def.agent !== "string" || !def.agent.trim() || def.agent.trim().startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", "agent: soul name required (not an option)", { field: "agent" });
|
|
304
310
|
out.agent = def.agent.trim();
|
|
305
311
|
if (def.agentsRoot !== undefined) {
|
|
306
312
|
const known = scopeRoots(ws);
|
|
307
313
|
if (typeof def.agentsRoot !== "string" || !isAbsolute(def.agentsRoot) || !inside(ws, def.agentsRoot) || !known.includes(resolve(def.agentsRoot))) throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: must be one of this scope's agents roots (${known.join(", ") || "none"})`, { field: "agentsRoot" });
|
|
308
314
|
out.agentsRoot = resolve(def.agentsRoot);
|
|
309
315
|
}
|
|
310
|
-
if (def.repo !== undefined) { if (typeof def.repo !== "string" || !def.repo.trim()) throw scheduleError("E_SCHEDULE_INVALID", "repo: the work repository path, as oats spawn --repo", { field: "repo" }); out.repo = def.repo; }
|
|
316
|
+
if (def.repo !== undefined) { if (typeof def.repo !== "string" || !def.repo.trim() || def.repo.startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", "repo: the work repository path, as oats spawn --repo", { field: "repo" }); out.repo = def.repo; }
|
|
311
317
|
if (def.backend !== undefined) { if (!BACKENDS.has(def.backend)) throw scheduleError("E_SCHEDULE_INVALID", "backend: tmux or herdr", { field: "backend" }); out.backend = def.backend; }
|
|
312
318
|
if (def.purpose !== undefined) { if (typeof def.purpose !== "string" || !/^[a-z0-9-]{1,40}$/.test(def.purpose)) throw scheduleError("E_SCHEDULE_INVALID", "purpose: lowercase letters, digits and dashes", { field: "purpose" }); out.purpose = def.purpose; }
|
|
313
319
|
// A run spawns <agent>-<purpose|id>-<YYYYMMDDHHMM>; instance names are at most
|
|
@@ -323,7 +329,7 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
323
329
|
}
|
|
324
330
|
if (def.launchConfig !== undefined) { if (typeof def.launchConfig !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(def.launchConfig)) throw scheduleError("E_SCHEDULE_INVALID", "launchConfig: the name of a launch configuration on the running host (oats-local.yaml launch-configs)", { field: "launchConfig" }); out.launchConfig = def.launchConfig; }
|
|
325
331
|
if (def.harness !== undefined) { if (!HARNESSES.has(def.harness)) throw scheduleError("E_SCHEDULE_INVALID", "harness: pi, claude or codex", { field: "harness" }); out.harness = def.harness; }
|
|
326
|
-
if (def.model !== undefined) { if (typeof def.model !== "string") throw scheduleError("E_SCHEDULE_INVALID", "model:
|
|
332
|
+
if (def.model !== undefined) { if (typeof def.model !== "string" || !MODEL_RE.test(def.model)) throw scheduleError("E_SCHEDULE_INVALID", "model: a model id: a letter or digit, then letters, digits and . _ : / @ + - [ ] (at most 128 characters)", { field: "model" }); out.model = def.model; }
|
|
327
333
|
if (def.yolo !== undefined) { if (typeof def.yolo !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "yolo: boolean", { field: "yolo" }); out.yolo = def.yolo; }
|
|
328
334
|
if (def.wake !== undefined) {
|
|
329
335
|
const w = def.wake;
|
|
@@ -402,6 +408,10 @@ export function validateWorkspaceSchedule(dep, a) {
|
|
|
402
408
|
if (spec.kind === "command") {
|
|
403
409
|
if (d.cwd !== undefined && (typeof d.cwd !== "string" || isAbsolute(d.cwd))) throw scheduleError("E_SCHEDULE_INVALID", "cwd: a directory relative to the deployment (a workspace schedule never names a machine path)", { field: "cwd" });
|
|
404
410
|
spec.cwd = resolve(dep, d.cwd ?? ".");
|
|
411
|
+
// Inside the deployment, `..` and symlinks included (re-review B #4): Git content never picks a
|
|
412
|
+
// directory elsewhere on the host.
|
|
413
|
+
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
414
|
+
if (!inside(real(dep), real(spec.cwd))) throw scheduleError("E_SCHEDULE_INVALID", `cwd: ${JSON.stringify(d.cwd)} leaves the deployment; a workspace command's cwd is a directory inside it`, { field: "cwd" });
|
|
405
415
|
}
|
|
406
416
|
const out = validateDefinition(dep, spec, { checkAgent: false });
|
|
407
417
|
delete out.enabled;
|
|
@@ -521,19 +531,22 @@ export function workspaceSpawnContext(ws) {
|
|
|
521
531
|
* `io.oatsBin`, `io.commandTimeoutMs` and `io.noLaunch` are the test seams. */
|
|
522
532
|
function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
523
533
|
if (context.error) throw scheduleError(context.error.code, `this deployment realizes a workspace (${context.dir}) but its oats-local.yaml cannot be read: ${context.error.message}`);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if (
|
|
527
|
-
|
|
528
|
-
if (opts.
|
|
529
|
-
if (opts.
|
|
534
|
+
// Every value travels as ONE `--flag=value` token, so no value can be read as a flag of its own;
|
|
535
|
+
// the soul is the one positional, and an option-shaped one is refused.
|
|
536
|
+
if (String(agent.name).startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", `agent: ${JSON.stringify(agent.name)} is not a soul name`, { field: "agent" });
|
|
537
|
+
const argv = ["spawn", agent.name, `--dir=${context.dir}`, ...(opts.discover ? [] : [`--agents-root=${root}`]), `--purpose=${opts.purpose}`, "--json"];
|
|
538
|
+
if (opts.launchConfig) argv.push(`--launch-config=${opts.launchConfig}`);
|
|
539
|
+
if (opts.harness) argv.push(`--harness=${opts.harness}`);
|
|
540
|
+
if (opts.model) argv.push(`--model=${opts.model}`);
|
|
541
|
+
if (opts.backend) argv.push(`--backend=${opts.backend}`);
|
|
542
|
+
if (opts.repo) argv.push(`--repo=${opts.repo}`);
|
|
530
543
|
if (opts.yolo === true) argv.push("--yolo"); else if (opts.yolo === false) argv.push("--no-yolo");
|
|
531
544
|
if (opts.launch === false) argv.push("--no-launch");
|
|
532
545
|
// The task is multi-line text of arbitrary size: it travels as a private file, never in argv.
|
|
533
546
|
mkdirSync(join(stateDir(ws), "tasks"), { recursive: true });
|
|
534
547
|
const taskFile = join(stateDir(ws), "tasks", `${opts.purpose}-${process.pid}.md`);
|
|
535
548
|
writeFileSync(taskFile, opts.task, { mode: 0o600 });
|
|
536
|
-
argv.push(
|
|
549
|
+
argv.push(`--task-file=${taskFile}`);
|
|
537
550
|
let r;
|
|
538
551
|
try { r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() }); }
|
|
539
552
|
finally { try { rmSync(taskFile, { force: true }); } catch { /* best effort */ } }
|
|
@@ -866,7 +879,7 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
|
|
|
866
879
|
}
|
|
867
880
|
// Triggers: each scope's event-driven spawns, polled at their own interval (lib/triggers.mjs).
|
|
868
881
|
for (const ws of wsList) {
|
|
869
|
-
try { considered.push(...tickTriggers(ws, { now, io, dryRun, ctx: contexts.get(ws) })); }
|
|
882
|
+
try { considered.push(...tickTriggers(ws, { now, io, dryRun, ctx: contexts.get(ws), reg, wsList })); }
|
|
870
883
|
catch (e) { considered.push({ workspace: ws, action: "error", error: `triggers: ${e.message}` }); }
|
|
871
884
|
}
|
|
872
885
|
if (!dryRun) writeHostState({ ...readHostState(), lastTick: now.toISOString(), minute: minuteKey(minuteStart(now)) });
|
|
@@ -978,10 +991,11 @@ export function testSchedule(ws, qid, io, { now = new Date(), ctx = null } = {})
|
|
|
978
991
|
const { root, discover } = resolveScheduledAgent(ws, def);
|
|
979
992
|
const context = workspaceSpawnContext(ws);
|
|
980
993
|
if (context.error) throw scheduleError(context.error.code, context.error.message);
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
if (def.
|
|
984
|
-
if (def.
|
|
994
|
+
if (String(def.agent).startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", `agent: ${JSON.stringify(def.agent)} is not a soul name`, { field: "agent" });
|
|
995
|
+
const argv = ["spawn", def.agent, `--dir=${context.dir}`, ...(discover ? [] : [`--agents-root=${root}`]), "--preview", "--json"];
|
|
996
|
+
if (def.launchConfig) argv.push(`--launch-config=${def.launchConfig}`);
|
|
997
|
+
if (def.harness) argv.push(`--harness=${def.harness}`);
|
|
998
|
+
if (def.model) argv.push(`--model=${def.model}`);
|
|
985
999
|
const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() });
|
|
986
1000
|
const envelope = parseEnvelopeText(r.stdout);
|
|
987
1001
|
if (!envelope || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_FAILED", `the spawn preview answered no valid envelope (exit ${r.status ?? r.signal})`);
|