@awebai/oats 0.29.2 → 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-aweb/bin/oats-aweb.mjs +19 -18
- package/capabilities/oats-aweb/injects/aweb.md +4 -4
- package/capabilities/oats-aweb/lib/binding-wire.mjs +1 -3
- package/capabilities/oats-aweb/lib/wake-receive.mjs +1 -1
- package/capabilities/oats-aweb/oats.json +3 -3
- package/capabilities/oats-aweb/skills/VENDORED.md +1 -1
- package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +9 -9
- package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +1 -1
- package/capabilities/oats-aweb/skills/oats-aweb/SKILL.md +13 -14
- 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/capabilities.md +3 -4
- package/docs/capability-manifest.schema.json +3 -1
- package/docs/design/2026-09-24-desktop-phase-f-boundary.md +2 -0
- package/docs/design/2026-09-24-phase-d-plan.md +2 -0
- package/docs/design/2026-09-25-teams-contract.md +36 -4
- package/docs/design/2026-09-26-desktop-design-brief-architecture.md +4 -4
- package/docs/design/2026-09-27-team-model-v2.md +136 -0
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +5 -5
- package/docs/release-notes/v0.29.3.md +53 -0
- package/docs/release-notes/v0.29.4.md +90 -0
- package/docs/schedules.md +18 -4
- package/docs/workspaces.md +4 -4
- package/lib/automations.mjs +7 -0
- package/lib/core.mjs +3 -3
- package/lib/packages.mjs +2 -5
- package/lib/resolve.mjs +2 -2
- package/lib/schedule.mjs +31 -17
- package/lib/triggers.mjs +51 -15
- package/lib/workspace.mjs +3 -3
- package/package-catalog.json +3 -3
- package/package.json +1 -1
- package/capabilities/oats-aweb/lib/personal-team.mjs +0 -19
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# OATS 0.29.3
|
|
2
|
+
|
|
3
|
+
## Changed
|
|
4
|
+
|
|
5
|
+
- **There is no "personal team"; a workspace has its default team.** The
|
|
6
|
+
vocabulary follows the teams contract's amendment of 2026-09-26: a workspace
|
|
7
|
+
has its **default team** (the team for the workspace key in its owner's
|
|
8
|
+
namespace), and agents may join other teams. The model is unchanged:
|
|
9
|
+
- an instance is in the workspace's default team only, by default;
|
|
10
|
+
- joining an eligible team is an explicit act;
|
|
11
|
+
- `OATS_TEAMS` is `[]` for a soul with no label.
|
|
12
|
+
|
|
13
|
+
The kernel's docs, comments and the `unmapped-team-label` warning use the
|
|
14
|
+
new words. The warning now reads "… fall back to the workspace's default team
|
|
15
|
+
for it". The oats.framework skills (oats.core 2.1.1, oats.setup 2.1.2,
|
|
16
|
+
oats.framework 1.3.1) teach the same model. The kernel carries no wire name
|
|
17
|
+
with "personal", and there are no compatibility aliases.
|
|
18
|
+
- **The messaging provider uses the same words** (oats.aweb 1.16.0, below).
|
|
19
|
+
|
|
20
|
+
## oats.aweb 1.16.0 (catalog pin and bundled mirror)
|
|
21
|
+
|
|
22
|
+
- **The catalog and this repo's workspace pin `oats.aweb` to `v1.16.0`, and
|
|
23
|
+
the bundled `capabilities/oats-aweb` is the tag's tree.** It renames the
|
|
24
|
+
provider's wire names to the default-team vocabulary:
|
|
25
|
+
- the teams operation's `personal` field becomes `defaultTeam`
|
|
26
|
+
(`{ team, source }`, where `source` is `setting` or `root`);
|
|
27
|
+
- `E_TEAM_PERSONAL` becomes `E_TEAM_DEFAULT`;
|
|
28
|
+
- the broker receive label `personal` becomes `default`;
|
|
29
|
+
- `settings.oats.aweb.roots.personal` is removed: `roots` keys are team ids
|
|
30
|
+
only, and the `personal-root-deferred` warning is gone.
|
|
31
|
+
|
|
32
|
+
There are no aliases: a reader of the old names must move to the new ones,
|
|
33
|
+
as the Desktop does in this release. A home spawned under 1.15 still resolves
|
|
34
|
+
its default team from its recorded team. Per-workspace default-team
|
|
35
|
+
enrollment is still deferred.
|
|
36
|
+
|
|
37
|
+
## oats.framework 1.3.1 (catalog pin)
|
|
38
|
+
|
|
39
|
+
- **The catalog and this repo's workspace pin `oats.framework` to
|
|
40
|
+
`oats-framework/v1.3.1`.** Its only change is the default-team vocabulary
|
|
41
|
+
above, in `oats.core` 2.1.1 and `oats.setup` 2.1.2. A deployment on
|
|
42
|
+
`oats.framework: v1.3.0` keeps working, and moving the pin to `v1.3.1` picks
|
|
43
|
+
up the new skill text.
|
|
44
|
+
|
|
45
|
+
## Desktop
|
|
46
|
+
|
|
47
|
+
- **"Default team" everywhere** (#252). The teams panel reads oats.aweb 1.16's
|
|
48
|
+
`defaultTeam` and says where the team comes from: the workspace or host
|
|
49
|
+
setting, or the messaging root's active team. The spawn dialog, the soul
|
|
50
|
+
inspector and the teams panel say "Default team" in place of "Personal".
|
|
51
|
+
A 1.15 `personal` document no longer decodes, so this Desktop needs the
|
|
52
|
+
oats.aweb 1.16.0 pin.
|
|
53
|
+
- The Desktop still accepts OATS CLIs `>=0.25.8 <0.30.0`.
|
|
@@ -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
|
|
@@ -399,7 +399,7 @@ keys, with `byTeam` stripped) ⊕ soul slot payload ⊕ `local.settings[cap]`
|
|
|
399
399
|
**No `byTeam[<label>]` is merged into it, the primary's included** (teams
|
|
400
400
|
amendment K): each label's `base ⊕ byTeam[label]` reaches the provider only as
|
|
401
401
|
that label's entry in `OATS_TEAMS` (the preview's `teams`). So `settings.team`
|
|
402
|
-
(and `OATS_TEAM_ID`) is the
|
|
402
|
+
(and `OATS_TEAM_ID`) is the workspace's default team if the host, the soul or the spawn
|
|
403
403
|
set one; empty means the provider's own default. The provider's own `binding` contract
|
|
404
404
|
(`normalize → bind → check`) runs over the merged payload exactly as before.
|
|
405
405
|
Two teams, two messaging identities, one workspace:
|
|
@@ -420,7 +420,7 @@ one. A label under `byTeam` that is not declared in `teams:` is
|
|
|
420
420
|
act.** `spawn --preview` shows the merged `settings.<cap>` and the `teams`, so
|
|
421
421
|
the delivery is verifiable, and `instance.json` records both. With oats.aweb
|
|
422
422
|
1.13.1 (which reads `team` from its settings and ignores `OATS_TEAMS`) the
|
|
423
|
-
primary identity therefore mints into the
|
|
423
|
+
primary identity therefore mints into the workspace's default team: the `.aw` root's
|
|
424
424
|
active team, or the one the host set. What the payload does not change is
|
|
425
425
|
**where the `.aw` root is found**: the hook still searches
|
|
426
426
|
bounded candidates, first hit wins — the instance home, the Git repository
|
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/core.mjs
CHANGED
|
@@ -1485,17 +1485,17 @@ export function instanceSoulDir(home, meta = undefined) {
|
|
|
1485
1485
|
/** The team facts a lifecycle hook receives (workspace model only, lead decision
|
|
1486
1486
|
* c3-7): the team SCOPE is the deployment directory, the team ID is the messaging
|
|
1487
1487
|
* slot's merged payload `team` (workspace base ⊕ soul ⊕ host ⊕ spawn; no byTeam entry
|
|
1488
|
-
* is merged since teams amendment K): the
|
|
1488
|
+
* is merged since teams amendment K): the workspace's default team if one is set; empty = the
|
|
1489
1489
|
* provider's default (for oats.aweb, the root's active team). The provider also gets
|
|
1490
1490
|
* the soul's primary team LABEL and the workspace's name and canonical key, so it can
|
|
1491
|
-
* derive
|
|
1491
|
+
* derive the workspace's default team; each label's mapped payload is in
|
|
1492
1492
|
* OATS_TEAMS.
|
|
1493
1493
|
* OATS_TEAM_NAME (the 0.25 `team:` block's name) is always empty, so an ambient
|
|
1494
1494
|
* value never reaches a hook. Human decision 2026-09-24 (messaging default).
|
|
1495
1495
|
*
|
|
1496
1496
|
* Teams contract 2026-09-25 (decision 4): `resolved.teams` is the ELIGIBLE teams,
|
|
1497
1497
|
* `[{ label, team, mapped, payload }]` in soul order. OATS_TEAM_LABELS is their
|
|
1498
|
-
* labels comma-joined and OATS_TEAMS their JSON (`[]` = no label:
|
|
1498
|
+
* labels comma-joined and OATS_TEAMS their JSON (`[]` = no label: the workspace's default team only).
|
|
1499
1499
|
* Both are empty when the teams are not known (a home spawned before 0.26.0 whose
|
|
1500
1500
|
* workspace cannot be read now) — empty means "unknown", never "none".
|
|
1501
1501
|
* OATS_TEAMS_SOURCE says where the list came from: `live` (the workspace read now,
|
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/resolve.mjs
CHANGED
|
@@ -346,7 +346,7 @@ export function teamLabelsOf(soulEntry) {
|
|
|
346
346
|
* `{ label, team, mapped, payload }`. `payload` is workspace.messaging's base ⊕ byTeam[label] when the
|
|
347
347
|
* workspace maps the label, else the base alone with `mapped: false`; `team` is the payload's team id
|
|
348
348
|
* when mapped (null otherwise). Kernel-owned and delivered BESIDE a provider's settings, never inside
|
|
349
|
-
* them; joining any of them is the messaging provider's explicit act. No label → [] (
|
|
349
|
+
* them; joining any of them is the messaging provider's explicit act. No label → [] (the workspace's default team only).
|
|
350
350
|
* Pure: resolveSoul validates every carried label's entry (reserved byTeam, hostOnly keys) first.
|
|
351
351
|
*/
|
|
352
352
|
export function teamsOf(workspace, labels) {
|
|
@@ -731,7 +731,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
|
|
|
731
731
|
// Decision 23 as amended by K (teams contract, co-lead ruling): the provider's settings are
|
|
732
732
|
// base ⊕ soul ⊕ host ⊕ spawn. No byTeam[<label>] is merged, the primary's included: each
|
|
733
733
|
// label's base ⊕ byTeam[label] is delivered only in its `teams` entry (teamsOf → OATS_TEAMS),
|
|
734
|
-
// so settings.team is the
|
|
734
|
+
// so settings.team is the default team a host, soul or spawn set. `byTeam` never reaches the
|
|
735
735
|
// provider, and the primary team's own payload may still not nest one.
|
|
736
736
|
const { byTeam, ...base } = workspace.messaging;
|
|
737
737
|
committed(base, "/messaging", { kind: "workspace", at: "oats-workspace.yaml#/messaging" });
|
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})`);
|
package/lib/triggers.mjs
CHANGED
|
@@ -28,8 +28,8 @@ import { spawnSync } from "node:child_process";
|
|
|
28
28
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
29
29
|
import { dirname, join } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
|
-
import { readDefinitions, writeDefinitions, stateDir, withScopeLock, childEnv, parseEnvelopeText, schedulerStatus } from "./schedule.mjs";
|
|
32
|
-
import { automationError, baseRow, localEntry, localId, qualifiedId, soulOriginOf } from "./automations.mjs";
|
|
31
|
+
import { readDefinitions, writeDefinitions, stateDir, withScopeLock, childEnv, parseEnvelopeText, schedulerStatus, readRegistry } from "./schedule.mjs";
|
|
32
|
+
import { MODEL_RE, automationError, baseRow, localEntry, localId, parseOwner, qualifiedId, soulOriginOf } from "./automations.mjs";
|
|
33
33
|
import { lockedPackageRef } from "./packages.mjs";
|
|
34
34
|
|
|
35
35
|
export const TRIGGER_API = 1;
|
|
@@ -121,7 +121,7 @@ export function validateTrigger(def) {
|
|
|
121
121
|
if (!Array.isArray(teams) || teams.some((t) => typeof t !== "string" || !LABEL_RE.test(t)) || new Set(teams).size !== teams.length) throw invalid("spawn.teams", "a list of distinct team labels");
|
|
122
122
|
if (sp.launchConfig !== undefined && (typeof sp.launchConfig !== "string" || !LAUNCH_CONFIG_RE.test(sp.launchConfig))) throw invalid("spawn.launchConfig", "the name of a launch configuration on the running host (oats-local.yaml launch-configs)");
|
|
123
123
|
if (sp.harness !== undefined && !HARNESSES.has(sp.harness)) throw invalid("spawn.harness", "pi, claude or codex");
|
|
124
|
-
if (sp.model !== undefined && (typeof sp.model !== "string" || !sp.model
|
|
124
|
+
if (sp.model !== undefined && (typeof sp.model !== "string" || !MODEL_RE.test(sp.model))) throw invalid("spawn.model", "a model id: a letter or digit, then letters, digits and . _ : / @ + - [ ] (at most 128 characters)");
|
|
125
125
|
if (sp.yolo !== undefined && typeof sp.yolo !== "boolean") throw invalid("spawn.yolo", "boolean");
|
|
126
126
|
if (sp.backend !== undefined && !BACKENDS.has(sp.backend)) throw invalid("spawn.backend", "tmux or herdr");
|
|
127
127
|
const cc = def.concurrency === undefined ? {} : def.concurrency;
|
|
@@ -207,7 +207,12 @@ export function triggerKind({ lock = null, remote = null } = {}) {
|
|
|
207
207
|
parseBody: parseTriggerBody,
|
|
208
208
|
expand: async (a) => {
|
|
209
209
|
const raw = a.source.from ? await packageTriggerTemplate(lock, a.source.from, a.source.set, { id: a.name, remote }) : { ...a.source.definition };
|
|
210
|
-
|
|
210
|
+
const def = validateTrigger({ ...raw, id: a.name, kind: "trigger", enabled: true });
|
|
211
|
+
// The owner check asks gh on the OWNER's host, and the poll runs on the REPO's host: they must be
|
|
212
|
+
// one GitHub host, or polling would act as whoever is logged in there (re-review B #7).
|
|
213
|
+
const owner = parseOwner(a.owner), repo = parseRepo(def.on.repo);
|
|
214
|
+
if (owner && repo && owner.host.toLowerCase() !== repo.host) throw Object.assign(triggerError("E_TRIGGER_INVALID", `owner ${a.owner} is on ${owner.host}, but on.repo ${repo.key} is on ${repo.host}: a trigger acts as its owner on the repository's own GitHub host`), { field: "owner" });
|
|
215
|
+
return def;
|
|
211
216
|
},
|
|
212
217
|
};
|
|
213
218
|
}
|
|
@@ -265,8 +270,28 @@ export function readTriggerState(ws) {
|
|
|
265
270
|
let doc;
|
|
266
271
|
try { doc = JSON.parse(readFileSync(file, "utf8")); } catch { doc = null; }
|
|
267
272
|
if (!isObject(doc) || !isObject(doc.triggers)) return { version: 1, triggers: {} };
|
|
273
|
+
return carryUnqualifiedState(doc);
|
|
274
|
+
}
|
|
275
|
+
/** 0.28 kept a local trigger's state under its bare id, with event keys `<id>:…`; 0.29 qualifies
|
|
276
|
+
* both as `local/<id>`. Carried on read (re-review B #2): without it the first 0.29 tick saw no
|
|
277
|
+
* PR state, so every open matching PR emitted `opened` and spawned again. A bare id already
|
|
278
|
+
* present in qualified form keeps the qualified state. */
|
|
279
|
+
function carryUnqualifiedState(doc) {
|
|
280
|
+
for (const id of Object.keys(doc.triggers)) {
|
|
281
|
+
if (id.includes("/")) continue;
|
|
282
|
+
const qid = localId(id), old = doc.triggers[id];
|
|
283
|
+
delete doc.triggers[id];
|
|
284
|
+
if (doc.triggers[qid] || !isObject(old)) continue;
|
|
285
|
+
const rekey = (map) => Object.fromEntries(Object.entries(isObject(map) ? map : {}).map(([k, v]) => {
|
|
286
|
+
const key = k.startsWith(`${id}:`) ? `${qid}${k.slice(id.length)}` : k;
|
|
287
|
+
return [key, isObject(v) && "key" in v ? { ...v, key, ...(v.trigger === id ? { trigger: qid } : {}) } : v];
|
|
288
|
+
}));
|
|
289
|
+
doc.triggers[qid] = { ...old, prs: isObject(old.prs) ? old.prs : {}, fired: rekey(old.fired), pending: rekey(old.pending) };
|
|
290
|
+
}
|
|
268
291
|
return doc;
|
|
269
292
|
}
|
|
293
|
+
/** Whether an instance's recorded trigger id is `id`: a 0.28 home records a local trigger's bare id. */
|
|
294
|
+
const sameTrigger = (recorded, id) => recorded === id || (typeof recorded === "string" && !recorded.includes("/") && id === localId(recorded));
|
|
270
295
|
function writeTriggerState(ws, st) {
|
|
271
296
|
const file = statePath(ws);
|
|
272
297
|
mkdirSync(dirname(file), { recursive: true });
|
|
@@ -290,7 +315,7 @@ export function liveTriggerInstances(ws, id) {
|
|
|
290
315
|
if (!e.isDirectory()) continue;
|
|
291
316
|
const home = join(instances, e.name);
|
|
292
317
|
let meta; try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { continue; }
|
|
293
|
-
if (isObject(meta?.trigger) && meta.trigger.id
|
|
318
|
+
if (isObject(meta?.trigger) && (id === null || sameTrigger(meta.trigger.id, id))) out.push({ instance: meta.instance ?? e.name, home, repo: meta.trigger.repo, number: meta.trigger.number, event: meta.trigger.event, key: meta.trigger.key ?? null });
|
|
294
319
|
}
|
|
295
320
|
}
|
|
296
321
|
return out;
|
|
@@ -359,6 +384,9 @@ export function foldPoll(def, ts, poll, now) {
|
|
|
359
384
|
for (const e of eventsFor(def, ts.prs[pr.number], pr)) {
|
|
360
385
|
const key = eventKey(def, pr, e);
|
|
361
386
|
if (ts.fired[key] || ts.pending[key]) continue;
|
|
387
|
+
// A newer push supersedes a pending `synchronize` for an older head of the same PR: only
|
|
388
|
+
// the newest head is reviewed (re-review B #6).
|
|
389
|
+
if (e.event === "synchronize") for (const [k, p] of Object.entries(ts.pending)) if (p.number === pr.number && p.event === "synchronize") delete ts.pending[k];
|
|
362
390
|
ts.pending[key] = { trigger: def.qid ?? def.id, source: def.on.source, repo: def.on.repo, number: pr.number, url: pr.url, event: e.event, headSha: pr.headSha, labels: pr.labels, observedAt: now.toISOString(), key };
|
|
363
391
|
added.push(key);
|
|
364
392
|
}
|
|
@@ -384,7 +412,7 @@ function runOats(ws, argv, io) {
|
|
|
384
412
|
/** What `oats spawn <soul> --preview` says about the soul: its qualified resolution and the
|
|
385
413
|
* capability filling its messaging slot. → { ok, result | error } */
|
|
386
414
|
export function previewSoul(ws, def, io) {
|
|
387
|
-
const r = runOats(ws, ["spawn", def.spawn.soul,
|
|
415
|
+
const r = runOats(ws, ["spawn", def.spawn.soul, `--dir=${ws}`, "--preview", "--json"], io);
|
|
388
416
|
if (!r.envelope || typeof r.envelope.ok !== "boolean") return { ok: false, error: { code: "E_SPAWN_FAILED", message: r.timedOut ? "spawn --preview timed out" : `spawn --preview answered no envelope${r.stderr ? `: ${r.stderr.trim().split("\n").pop()}` : ""}` } };
|
|
389
417
|
if (!r.envelope.ok) return { ok: false, error: { code: r.envelope.error?.code ?? "E_SPAWN_FAILED", message: r.envelope.error?.message ?? "spawn --preview failed" } };
|
|
390
418
|
const modules = Array.isArray(r.envelope.result?.modules) ? r.envelope.result.modules : [];
|
|
@@ -396,17 +424,18 @@ export function previewSoul(ws, def, io) {
|
|
|
396
424
|
export function spawnForEvent(ws, def, ev, io) {
|
|
397
425
|
const fields = { repo: ev.repo, number: ev.number, url: ev.url, event: ev.event, headSha: ev.headSha, trigger: def.id };
|
|
398
426
|
const purpose = renderTemplate(def.spawn.purpose, fields);
|
|
399
|
-
|
|
427
|
+
// Every value travels as ONE `--flag=value` token, so no value can be read as a flag of its own.
|
|
428
|
+
const argv = ["spawn", def.spawn.soul, `--dir=${ws}`, `--purpose=${purpose}`, "--json"];
|
|
400
429
|
if (def.spawn.teams.length) {
|
|
401
430
|
const pv = previewSoul(ws, def, io);
|
|
402
431
|
if (!pv.ok) throw triggerError(pv.error.code, pv.error.message);
|
|
403
432
|
if (!pv.messaging) throw triggerError("E_TRIGGER_TEAMS", `trigger ${def.id}: spawn.teams [${def.spawn.teams.join(", ")}] needs a messaging capability, and soul ${def.spawn.soul} resolves none`, { details: { soul: def.spawn.soul, teams: def.spawn.teams } });
|
|
404
433
|
argv.push("--provider", pv.messaging, `join=${def.spawn.teams.join(",")}`);
|
|
405
434
|
}
|
|
406
|
-
if (def.spawn.launchConfig) argv.push(
|
|
407
|
-
if (def.spawn.harness) argv.push(
|
|
408
|
-
if (def.spawn.model) argv.push(
|
|
409
|
-
if (def.spawn.backend) argv.push(
|
|
435
|
+
if (def.spawn.launchConfig) argv.push(`--launch-config=${def.spawn.launchConfig}`);
|
|
436
|
+
if (def.spawn.harness) argv.push(`--harness=${def.spawn.harness}`);
|
|
437
|
+
if (def.spawn.model) argv.push(`--model=${def.spawn.model}`);
|
|
438
|
+
if (def.spawn.backend) argv.push(`--backend=${def.spawn.backend}`);
|
|
410
439
|
if (def.spawn.yolo === true) argv.push("--yolo"); else if (def.spawn.yolo === false) argv.push("--no-yolo");
|
|
411
440
|
if (io?.noLaunch === true) argv.push("--no-launch");
|
|
412
441
|
// The task and the event travel as private files, never in argv.
|
|
@@ -416,7 +445,7 @@ export function spawnForEvent(ws, def, ev, io) {
|
|
|
416
445
|
const taskFile = `${stem}.md`, eventFile = `${stem}.event.json`;
|
|
417
446
|
writeFileSync(eventFile, JSON.stringify(ev, null, 2) + "\n", { mode: 0o600 });
|
|
418
447
|
writeFileSync(taskFile, renderTemplate(def.spawn.task, fields).trimEnd() + triggerBlock(def, ev, "<home>/.oats/trigger-event.json"), { mode: 0o600 });
|
|
419
|
-
argv.push(
|
|
448
|
+
argv.push(`--task-file=${taskFile}`, `--trigger-event=${eventFile}`);
|
|
420
449
|
let r;
|
|
421
450
|
try { r = runOats(ws, argv, io); }
|
|
422
451
|
finally { for (const f of [taskFile, eventFile]) { try { rmSync(f, { force: true }); } catch { /* best effort */ } } }
|
|
@@ -438,10 +467,16 @@ function heldBy(def, ev, live) {
|
|
|
438
467
|
* whose poll is not due is not polled; pending events (a spawn that failed, or was held by
|
|
439
468
|
* concurrency) are retried whenever the trigger polls. `dryRun` reads only.
|
|
440
469
|
* → considered rows { workspace, trigger, action, … } */
|
|
441
|
-
export function tickTriggers(ws, { now = new Date(), io, dryRun = false, ctx = null } = {}) {
|
|
470
|
+
export function tickTriggers(ws, { now = new Date(), io, dryRun = false, ctx = null, reg = null, wsList = null } = {}) {
|
|
442
471
|
let entries;
|
|
443
472
|
try { entries = triggerEntries(ws, ctx); } catch (e) { return [{ workspace: ws, action: "error", error: e.message }]; }
|
|
444
473
|
if (!entries.length) return [];
|
|
474
|
+
// The host cap (registry triggersMaxConcurrent; absent = unbounded) counts every trigger-spawned
|
|
475
|
+
// live home in the host's scopes, and nothing a schedule runs (re-review B #5).
|
|
476
|
+
let hostCap;
|
|
477
|
+
try { hostCap = (reg || readRegistry()).triggersMaxConcurrent; } catch (e) { return [{ workspace: ws, action: "error", error: e.message }]; }
|
|
478
|
+
const scopes = [...new Set([...(wsList || []), ws])];
|
|
479
|
+
let hostLive = hostCap ? scopes.reduce((n, w) => n + liveTriggerInstances(w, null).length, 0) : 0;
|
|
445
480
|
const st = readTriggerState(ws);
|
|
446
481
|
const considered = [];
|
|
447
482
|
const rec = (id, action, extra = {}) => considered.push({ workspace: ws, trigger: id, action, ...extra });
|
|
@@ -471,15 +506,16 @@ export function tickTriggers(ws, { now = new Date(), io, dryRun = false, ctx = n
|
|
|
471
506
|
const queue = Object.values(work.pending).sort((a, b) => a.observedAt.localeCompare(b.observedAt) || a.key.localeCompare(b.key));
|
|
472
507
|
if (!queue.length) rec(id, "polled", { prs: work.lastPoll.prs, matching: work.lastPoll.matching });
|
|
473
508
|
for (const ev of queue) {
|
|
474
|
-
const held = heldBy(def, ev, live);
|
|
509
|
+
const held = heldBy(def, ev, live) ?? (hostCap && hostLive >= hostCap ? `host triggersMaxConcurrent ${hostCap} reached (${hostLive} live)` : null);
|
|
475
510
|
if (held) { rec(id, "held", { key: ev.key, reason: held }); continue; }
|
|
476
|
-
if (dryRun) { rec(id, "would-fire", { key: ev.key, event: ev.event, number: ev.number, url: ev.url }); live.push({ repo: ev.repo, number: ev.number }); continue; }
|
|
511
|
+
if (dryRun) { rec(id, "would-fire", { key: ev.key, event: ev.event, number: ev.number, url: ev.url }); live.push({ repo: ev.repo, number: ev.number }); hostLive++; continue; }
|
|
477
512
|
try {
|
|
478
513
|
const run = spawnForEvent(ws, def, ev, io);
|
|
479
514
|
work.fired[ev.key] = { at: now.toISOString(), instance: run.instance ?? null, home: run.home ?? null, event: ev.event, number: ev.number };
|
|
480
515
|
delete work.pending[ev.key];
|
|
481
516
|
work.lastFiredAt = now.toISOString();
|
|
482
517
|
live.push({ instance: run.instance, home: run.home, repo: ev.repo, number: ev.number });
|
|
518
|
+
hostLive++;
|
|
483
519
|
rec(id, "fired", { key: ev.key, instance: run.instance ?? null, home: run.home ?? null });
|
|
484
520
|
} catch (e) {
|
|
485
521
|
work.lastError = { at: now.toISOString(), code: e.code || "E_SPAWN_FAILED", message: e.message, key: ev.key };
|
package/lib/workspace.mjs
CHANGED
|
@@ -753,8 +753,8 @@ export function workspaceWarnings(workspace, members, externalRows, packageSouls
|
|
|
753
753
|
|
|
754
754
|
/** Teams contract decision 5: a soul label the workspace declares in `teams:` but does not map in
|
|
755
755
|
* `messaging.byTeam` is a WARNING, not a problem — the label stays eligible-but-unmapped and the
|
|
756
|
-
* messaging provider falls back to the
|
|
757
|
-
* ONE warning per unmapped label, naming its souls (a
|
|
756
|
+
* messaging provider falls back to the workspace's default team for it. (An undeclared label is E_TEAM_UNKNOWN.)
|
|
757
|
+
* ONE warning per unmapped label, naming its souls (a workspace on its default team alone would otherwise print
|
|
758
758
|
* a line per soul): { code, label, souls, paths, message }, sorted by label. */
|
|
759
759
|
const byCodepointOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
760
760
|
function unmappedLabelWarnings(workspace, members, externalRows, teams) {
|
|
@@ -774,7 +774,7 @@ function unmappedLabelWarnings(workspace, members, externalRows, teams) {
|
|
|
774
774
|
const order = names.map((n, i) => i).sort((x, y) => byCodepointOrder(names[x], names[y]) || byCodepointOrder(paths[x], paths[y]));
|
|
775
775
|
const sorted = order.map((i) => names[i]);
|
|
776
776
|
return { code: "unmapped-team-label", label, souls: sorted, paths: order.map((i) => paths[i]),
|
|
777
|
-
message: `team ${show(label)} has no messaging.byTeam entry; its souls (${sorted.join(", ")}) fall back to the
|
|
777
|
+
message: `team ${show(label)} has no messaging.byTeam entry; its souls (${sorted.join(", ")}) fall back to the workspace's default team for it` };
|
|
778
778
|
});
|
|
779
779
|
}
|
|
780
780
|
|