@awebai/oats 0.22.10 → 0.22.14
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 +186 -6
- package/docs/design/2026-09-07-architecture-reassessment.md +131 -0
- package/docs/desktop.md +44 -2
- package/docs/execution-targets.md +21 -0
- package/docs/release-notes/v0.22.11.md +49 -0
- package/docs/release-notes/v0.22.12.md +53 -0
- package/docs/release-notes/v0.22.13.md +44 -0
- package/docs/release-notes/v0.22.14.md +48 -0
- package/docs/schedules.md +143 -0
- package/lib/attachments.mjs +164 -0
- package/lib/schedule-host.mjs +150 -0
- package/lib/schedule.mjs +731 -0
- package/lib/servers.mjs +19 -1
- package/lib/session-viewer.mjs +4 -0
- package/package.json +3 -2
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# OATS v0.22.13
|
|
2
|
+
|
|
3
|
+
Terminal essentials for the Desktop and one kernel operation behind them:
|
|
4
|
+
attachments reach the agent as a path, never as typed input.
|
|
5
|
+
|
|
6
|
+
## Attachments (kernel)
|
|
7
|
+
|
|
8
|
+
`oats session upload --file <local> --home <abs>` copies a file into the
|
|
9
|
+
instance home's private `.oats-attachments/` directory (0700, file 0600,
|
|
10
|
+
`name-2` when the name is taken) and answers `{path, bytes, sha256}`. With
|
|
11
|
+
`--server <id> --instance <name>` (or `--home`), the same saved route as
|
|
12
|
+
`session attach` is resolved, the remote must advertise the new
|
|
13
|
+
`session-upload` feature, and the bytes travel on ssh stdin into
|
|
14
|
+
`oats session receive --home <abs> --name <file>` on the execution host,
|
|
15
|
+
which reads stdin event-driven, enforces the 64 MiB kernel bound before
|
|
16
|
+
writing anything, allocates the destination exclusively so simultaneous
|
|
17
|
+
uploads never overwrite each other, refuses a symlinked or redirected
|
|
18
|
+
attachments directory, and reports the path only after the bytes are
|
|
19
|
+
synced. The local side refuses the answer unless size and sha256 equal the
|
|
20
|
+
local file's. Each side buffers the whole file up to the bound; a remote
|
|
21
|
+
file is never a local path over SSH. Upload never sends terminal input.
|
|
22
|
+
`oats version --json` lists `session-upload` in `remote` and `features`.
|
|
23
|
+
The CLI's temporary tmux viewer session hides its status line; the agents'
|
|
24
|
+
session and the operator's tmux settings are untouched.
|
|
25
|
+
|
|
26
|
+
## Desktop
|
|
27
|
+
|
|
28
|
+
Drop files or paste clipboard images onto an agent terminal: the paths are
|
|
29
|
+
inserted into the draft with bracketed paste and no Enter, local files in
|
|
30
|
+
place, clipboard images saved privately under the app's data directory, and
|
|
31
|
+
remote terminals uploaded through the installed CLI first (both CLIs must
|
|
32
|
+
advertise `session-upload`; a failed transfer leaves the draft unchanged and
|
|
33
|
+
never inserts a local path). Up to 16 files and 25 MB per drop. The viewer's
|
|
34
|
+
tmux status bar is hidden. Ctrl+Tab stays tab navigation inside terminals
|
|
35
|
+
on macOS while other Ctrl chords stay with the program. An already-open
|
|
36
|
+
terminal fills an empty split without a second attach; separators resize by
|
|
37
|
+
pointer or keyboard. Guide: docs/desktop.md, "Attach files and screenshots".
|
|
38
|
+
|
|
39
|
+
## Design
|
|
40
|
+
|
|
41
|
+
docs/design/2026-09-07-architecture-reassessment.md records the direction:
|
|
42
|
+
keep the package and capability-layer machinery, fix the places where the
|
|
43
|
+
CLI and GUI bypass it, one session contract with both tmux and Herdr
|
|
44
|
+
adapters until Herdr is qualified, and the Desktop correction sequence.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# OATS v0.22.14
|
|
2
|
+
|
|
3
|
+
The same reviewed content as the unpublished v0.22.13 tag, whose hosted
|
|
4
|
+
run failed in one Desktop test pin before anything was published; the
|
|
5
|
+
test now checks the behaviour instead of the source text.
|
|
6
|
+
|
|
7
|
+
Terminal essentials for the Desktop and one kernel operation behind them:
|
|
8
|
+
attachments reach the agent as a path, never as typed input.
|
|
9
|
+
|
|
10
|
+
## Attachments (kernel)
|
|
11
|
+
|
|
12
|
+
`oats session upload --file <local> --home <abs>` copies a file into the
|
|
13
|
+
instance home's private `.oats-attachments/` directory (0700, file 0600,
|
|
14
|
+
`name-2` when the name is taken) and answers `{path, bytes, sha256}`. With
|
|
15
|
+
`--server <id> --instance <name>` (or `--home`), the same saved route as
|
|
16
|
+
`session attach` is resolved, the remote must advertise the new
|
|
17
|
+
`session-upload` feature, and the bytes travel on ssh stdin into
|
|
18
|
+
`oats session receive --home <abs> --name <file>` on the execution host,
|
|
19
|
+
which reads stdin event-driven, enforces the 64 MiB kernel bound before
|
|
20
|
+
writing anything, allocates the destination exclusively so simultaneous
|
|
21
|
+
uploads never overwrite each other, refuses a symlinked or redirected
|
|
22
|
+
attachments directory, and reports the path only after the bytes are
|
|
23
|
+
synced. The local side refuses the answer unless size and sha256 equal the
|
|
24
|
+
local file's. Each side buffers the whole file up to the bound; a remote
|
|
25
|
+
file is never a local path over SSH. Upload never sends terminal input.
|
|
26
|
+
`oats version --json` lists `session-upload` in `remote` and `features`.
|
|
27
|
+
The CLI's temporary tmux viewer session hides its status line; the agents'
|
|
28
|
+
session and the operator's tmux settings are untouched.
|
|
29
|
+
|
|
30
|
+
## Desktop
|
|
31
|
+
|
|
32
|
+
Drop files or paste clipboard images onto an agent terminal: the paths are
|
|
33
|
+
inserted into the draft with bracketed paste and no Enter, local files in
|
|
34
|
+
place, clipboard images saved privately under the app's data directory, and
|
|
35
|
+
remote terminals uploaded through the installed CLI first (both CLIs must
|
|
36
|
+
advertise `session-upload`; a failed transfer leaves the draft unchanged and
|
|
37
|
+
never inserts a local path). Up to 16 files and 25 MB per drop. The viewer's
|
|
38
|
+
tmux status bar is hidden. Ctrl+Tab stays tab navigation inside terminals
|
|
39
|
+
on macOS while other Ctrl chords stay with the program. An already-open
|
|
40
|
+
terminal fills an empty split without a second attach; separators resize by
|
|
41
|
+
pointer or keyboard. Guide: docs/desktop.md, "Attach files and screenshots".
|
|
42
|
+
|
|
43
|
+
## Design
|
|
44
|
+
|
|
45
|
+
docs/design/2026-09-07-architecture-reassessment.md records the direction:
|
|
46
|
+
keep the package and capability-layer machinery, fix the places where the
|
|
47
|
+
CLI and GUI bypass it, one session contract with both tmux and Herdr
|
|
48
|
+
adapters until Herdr is qualified, and the Desktop correction sequence.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Schedules
|
|
2
|
+
|
|
3
|
+
A schedule launches an agent, runs an oats command, or wakes an existing
|
|
4
|
+
instance on a cron. Definitions belong to a scope, the team workspace (the
|
|
5
|
+
config level that declares the team, else the outermost `oats-config.yaml`
|
|
6
|
+
level), and are committable; every `oats schedule` command run anywhere
|
|
7
|
+
inside that scope, including from an instance home, reads and writes the
|
|
8
|
+
same file. Execution belongs to the host that holds the scope, so a
|
|
9
|
+
schedule on a registered server keeps running while your laptop sleeps.
|
|
10
|
+
|
|
11
|
+
There is no daemon. One host timer (a launchd user agent on macOS, a systemd
|
|
12
|
+
user timer on Linux) runs `oats schedule tick --host` once a minute; the tick
|
|
13
|
+
is a short-lived process that evaluates only the current minute, launches
|
|
14
|
+
what is due through the same `spawn`, `session start` and `session input`
|
|
15
|
+
paths you use by hand, records what it observed, and exits. Minutes missed
|
|
16
|
+
while the machine slept are skipped, never replayed. There are no retries
|
|
17
|
+
and no queue.
|
|
18
|
+
|
|
19
|
+
## Files
|
|
20
|
+
|
|
21
|
+
- `<workspace>/oats-schedules.json` — the definitions (`{version: 1, jobs:
|
|
22
|
+
{<id>: ...}}`). Commit it if you want the schedule shared with the team.
|
|
23
|
+
- `<workspace>/.agents/schedules/state.json` — last attempted minute and
|
|
24
|
+
last run per job (gitignored), plus one lock directory per running job.
|
|
25
|
+
- `~/.oats/schedules/registry.json` — the host registry: which scopes the
|
|
26
|
+
host ticks, `maxConcurrent` (default 1) and the tick interval. One host
|
|
27
|
+
lock serializes ticks, run-now, reconcile and remove; it is never reclaimed
|
|
28
|
+
by another process: a lock whose owner is unreadable or gone is reported
|
|
29
|
+
with the directory to remove, and the holder removes its own lock on exit
|
|
30
|
+
and on SIGINT/SIGTERM. Definition edits take a short per-scope lock.
|
|
31
|
+
|
|
32
|
+
## Kinds
|
|
33
|
+
|
|
34
|
+
- **spawn** `{id, enabled, cron, tz, kind: "spawn", agent, agentsRoot?,
|
|
35
|
+
repo?, backend?, purpose?, task, runtime?, model?, yolo?, wake?}` — every
|
|
36
|
+
due minute launches one disposable instance of `agent` with the same
|
|
37
|
+
options `oats spawn` takes. `agentsRoot` names the exact agents root that
|
|
38
|
+
holds the soul (it must lie inside the workspace and defaults to the
|
|
39
|
+
workspace's own root); it is what tells same-named souls in different
|
|
40
|
+
member repositories apart. `repo` is the work repository, as `--repo`. The task gets a trailing schedule block naming the job and the
|
|
41
|
+
minute and ending with `oats retire --self`. An optional `wake` object
|
|
42
|
+
(`{cron, tz, message}`) attaches a wake schedule to each launched instance;
|
|
43
|
+
nothing is attached unless you ask.
|
|
44
|
+
- **command** `{id, enabled, cron, tz, kind: "command", cwd, argv}` — runs
|
|
45
|
+
an oats-only argv (`argv[0]` is `oats`, no shell) in `cwd`, which must be
|
|
46
|
+
inside the workspace. The runner parses the command's envelope and tracks
|
|
47
|
+
any instance it names, so `["oats", "okf", "harvest"]` run in a source
|
|
48
|
+
instance's home is followed until the harvester it spawned is gone.
|
|
49
|
+
Command return is not task completion.
|
|
50
|
+
- **wake** `{id, enabled, cron, tz, kind: "wake", home, message}` — every
|
|
51
|
+
due minute inspects the instance at `home` through its session receipts.
|
|
52
|
+
Running: `message` is delivered once with `session input`. Not running
|
|
53
|
+
(absent, dead pane or fallback shell): the same home is started with
|
|
54
|
+
`session start` and the message becomes the job's one pending delivery,
|
|
55
|
+
completed on a later tick, any tick, as soon as the session is active; the
|
|
56
|
+
home is started again only at due minutes, never every minute, so a
|
|
57
|
+
harness that keeps exiting is not restarted in a loop. A job holds at most
|
|
58
|
+
one pending delivery: a due minute while one is pending adds nothing.
|
|
59
|
+
Unobservable or still starting: skipped with the reason, delivery kept
|
|
60
|
+
pending. Whether a running harness is busy cannot be seen from the
|
|
61
|
+
terminal: delivery is terminal input (bracketed paste plus Enter), never an
|
|
62
|
+
interrupt, never Ctrl-C, never into a stopped or starting shell. Word wake
|
|
63
|
+
messages so that receiving one again is harmless.
|
|
64
|
+
|
|
65
|
+
`cron` has five fields (minute hour day month weekday) and `tz` is a
|
|
66
|
+
required IANA zone; both are evaluated by the croner library. `--wake-every
|
|
67
|
+
N` at spawn time means `*/N * * * *`: every 7 fires at :00, :07, ... :56 and
|
|
68
|
+
then :00 again, so 1, 5, 10, 15 and 30 give an even cadence.
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
oats schedule add <id> --file spec.json --dir <workspace> --json
|
|
74
|
+
oats schedule update <id> --file spec.json
|
|
75
|
+
oats schedule list | show <id> | enable <id> | disable <id> | remove <id>
|
|
76
|
+
oats schedule run <id> # now, under the same lock and bound
|
|
77
|
+
oats schedule tick --dry-run # what would run this minute, launching nothing
|
|
78
|
+
oats schedule reconcile <id> [--clear] # resolve an attempt whose result was never recorded
|
|
79
|
+
oats schedule host install # register this scope and install the ONE host timer (idempotent while active)
|
|
80
|
+
oats schedule host status | uninstall
|
|
81
|
+
oats spawn <agent> ... --wake-every 15 --wake-message "Anything new?" # or --wake-file spec.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Every subcommand takes `--server <id>` instead of `--dir`: it then runs on
|
|
85
|
+
that host, in its registered workspace, because schedules are host-owned.
|
|
86
|
+
|
|
87
|
+
`list --json` answers `{schedules: [{id, ...definition, nextRun, lastRun,
|
|
88
|
+
running}], scheduler: {installed, active, lastTick, maxConcurrent, ...}}`.
|
|
89
|
+
`active` is what the OS reports about the timer, not whether a file exists.
|
|
90
|
+
|
|
91
|
+
## What a run reports
|
|
92
|
+
|
|
93
|
+
`launched` (spawn or command returned), `active` (the instance is running;
|
|
94
|
+
a home whose retirement is pending still counts, its runtime may be alive),
|
|
95
|
+
`ended` (its home is gone), `stopped` (home present, nothing running: needs
|
|
96
|
+
attention, never removed for you), `launch-failed`, `unknown`, and for wake
|
|
97
|
+
jobs `delivered`, `started` or `skipped`. The kernel never claims a task
|
|
98
|
+
succeeded.
|
|
99
|
+
|
|
100
|
+
`unknown` means the launch's side effects are unconfirmed: a command timed
|
|
101
|
+
out or answered no envelope, an envelope named an instance the roster
|
|
102
|
+
cannot place, or an attempt was never recorded. The job keeps its slot and
|
|
103
|
+
is skipped until `oats schedule reconcile <id>`. Reconcile adopts only an
|
|
104
|
+
attributable receipt: a spawn job's instance is named deterministically for
|
|
105
|
+
its minute, a command job's only by the instance its answer named. Nothing
|
|
106
|
+
is inferred from file times. A command whose answer named nothing stays
|
|
107
|
+
unknown; check the roster and the host by hand, then
|
|
108
|
+
`oats schedule reconcile <id> --clear` records launch-failed and frees the
|
|
109
|
+
slot (or `remove --force` forgets the job).
|
|
110
|
+
|
|
111
|
+
A wake job that starts a stopped home holds a launch slot while that
|
|
112
|
+
runtime is starting, active, retiring or unobservable, and releases it when
|
|
113
|
+
the runtime is proven stopped (the session start receipt's exit marker for
|
|
114
|
+
that launch, or a home that no longer has a session) or the home is gone.
|
|
115
|
+
A persistent home that outlives its process does not keep a slot. Delivering
|
|
116
|
+
a message to a home that is already running takes no slot. The host tick
|
|
117
|
+
observes every registered scope first, then admits due jobs in one
|
|
118
|
+
host-wide order, least recently launched first (only an actual runtime
|
|
119
|
+
launch counts; a skipped or pending job keeps its place at the front), so
|
|
120
|
+
one frequent job in one scope cannot keep the only slot forever. An invalid
|
|
121
|
+
or malformed definition is reported on that job and the rest of the tick
|
|
122
|
+
continues.
|
|
123
|
+
|
|
124
|
+
`disable` never stops anything. `update` never touches a running instance,
|
|
125
|
+
and while a job holds a slot or has an unresolved attempt what its run is
|
|
126
|
+
tracked or reconciled by (kind, agent, agentsRoot, repo, purpose, home, cwd,
|
|
127
|
+
argv) cannot change; cron, tz, task, message, runtime, model and enabled
|
|
128
|
+
can. A cold wake persists its slot before the session start runs and keeps
|
|
129
|
+
it on any start exception, whatever its code (the kernel can refuse while
|
|
130
|
+
recording, after the session exists); the next observation releases it once
|
|
131
|
+
the runtime is proven stopped or absent, one tick at worst.
|
|
132
|
+
`remove` refuses while the job's instance is still tracked (`--force`
|
|
133
|
+
forgets the job without stopping anything). Retiring an instance removes the
|
|
134
|
+
wake jobs bound to its home; a wake whose home is gone otherwise stays
|
|
135
|
+
listed with its skipped reason.
|
|
136
|
+
|
|
137
|
+
## Wake at spawn
|
|
138
|
+
|
|
139
|
+
`oats spawn ... --wake-file <private JSON {cron, tz, message, enabled}>`
|
|
140
|
+
saves a wake job `wake-<instance>` bound to the new home after the spawn
|
|
141
|
+
succeeded. If the spawn succeeds but the save fails, the spawn result still
|
|
142
|
+
carries the full instance receipt, plus `wakeScheduleError` and a warning;
|
|
143
|
+
the instance is neither hidden nor spawned again.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** Instance attachments: bytes a viewer drops or pastes for an agent, kept as
|
|
2
|
+
* private files INSIDE the instance home so the agent can read them by path.
|
|
3
|
+
* Upload never touches the terminal; the caller pastes the returned path.
|
|
4
|
+
* Remote uploads stream through the same ssh transport as every routed
|
|
5
|
+
* command, into `oats session receive` on the execution host. */
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeSync, realpathSync } from "node:fs";
|
|
8
|
+
import { basename, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
9
|
+
import { checkRemote, resolveRoute, runRemote, serverError } from "./servers.mjs";
|
|
10
|
+
|
|
11
|
+
export const ATTACHMENTS_DIRNAME = ".oats-attachments";
|
|
12
|
+
/** Kernel bound per file; a GUI imposes its own stricter interaction bound. */
|
|
13
|
+
export const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
|
|
14
|
+
/** The kernel version whose probe first advertises session-upload. */
|
|
15
|
+
export const SESSION_UPLOAD_REMOTE_VERSION = "0.22.13";
|
|
16
|
+
|
|
17
|
+
const err = (code, message, extra) => Object.assign(new Error(message), { code, ...(extra || {}) });
|
|
18
|
+
|
|
19
|
+
/** A file name as it will exist in the attachments directory: one path
|
|
20
|
+
* segment, printable, never a dot name, at most 200 bytes. */
|
|
21
|
+
export function attachmentName(name) {
|
|
22
|
+
if (typeof name !== "string" || !name.trim()) throw err("E_BAD_ARGS", "attachment name must be a non-empty file name");
|
|
23
|
+
if (name.includes("/") || name.includes("\\") || name.includes("\0")) throw err("E_BAD_ARGS", "attachment name must be a single path segment without slashes or NUL");
|
|
24
|
+
if (/[\x00-\x1f\x7f]/.test(name)) throw err("E_BAD_ARGS", "attachment name must not contain control characters");
|
|
25
|
+
if (name === "." || name === "..") throw err("E_BAD_ARGS", "attachment name may not be a dot name");
|
|
26
|
+
// Deliberate: a name starting with a dash would read as an option on the
|
|
27
|
+
// remote command line; the caller renames the file (screenshots never
|
|
28
|
+
// start with one).
|
|
29
|
+
if (name.startsWith("-")) throw err("E_BAD_ARGS", `attachment name ${JSON.stringify(name)} starts with a dash; rename the file before attaching it`);
|
|
30
|
+
if (Buffer.byteLength(name) > 200) throw err("E_BAD_ARGS", "attachment name is longer than 200 bytes");
|
|
31
|
+
return name;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sha256Hex(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|
|
35
|
+
|
|
36
|
+
/** Read a REGULAR file descriptor to its end, refusing once more than
|
|
37
|
+
* `maxBytes` arrive. Regular files never answer EAGAIN; pipes must use
|
|
38
|
+
* readStreamBounded, which waits for data instead of spinning. */
|
|
39
|
+
export function readBounded(fd, maxBytes = MAX_ATTACHMENT_BYTES) {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let total = 0;
|
|
42
|
+
const buf = Buffer.allocUnsafe(1024 * 1024);
|
|
43
|
+
for (;;) {
|
|
44
|
+
const n = readSync(fd, buf, 0, buf.length, null);
|
|
45
|
+
if (n === 0) break;
|
|
46
|
+
total += n;
|
|
47
|
+
if (total > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`);
|
|
48
|
+
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
49
|
+
}
|
|
50
|
+
return Buffer.concat(chunks, total);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Collect a readable stream (stdin from ssh) into memory, bounded: the
|
|
54
|
+
* read is event-driven, so a slow or stalled sender costs no CPU, and the
|
|
55
|
+
* bound is checked as chunks arrive, before anything is written. The whole
|
|
56
|
+
* file is buffered on both sides (up to the bound); this is not end-to-end
|
|
57
|
+
* streaming. */
|
|
58
|
+
export async function readStreamBounded(stream, maxBytes = MAX_ATTACHMENT_BYTES) {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
let total = 0;
|
|
61
|
+
for await (const chunk of stream) {
|
|
62
|
+
const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
63
|
+
total += b.length;
|
|
64
|
+
if (total > maxBytes) { stream.destroy?.(); throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`); }
|
|
65
|
+
chunks.push(b);
|
|
66
|
+
}
|
|
67
|
+
return Buffer.concat(chunks, total);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function instanceHomeOf(home) {
|
|
71
|
+
if (typeof home !== "string" || !isAbsolute(home)) throw err("E_BAD_ARGS", "attachments need an absolute instance home");
|
|
72
|
+
if (!existsSync(join(home, "instance.json"))) throw err("E_SESSION_UNKNOWN", `${home} is not an OATS instance home (no instance.json); nothing was written`);
|
|
73
|
+
return realpathSync(home);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The attachments directory must be a real directory inside the home: a
|
|
77
|
+
* symlink planted there (or a file) must not turn a receive into a write
|
|
78
|
+
* somewhere else. Created 0700 when absent. */
|
|
79
|
+
function attachmentsDir(realHome) {
|
|
80
|
+
const dir = join(realHome, ATTACHMENTS_DIRNAME);
|
|
81
|
+
let st;
|
|
82
|
+
try { st = lstatSync(dir); }
|
|
83
|
+
catch (e) {
|
|
84
|
+
if (e.code !== "ENOENT") throw e;
|
|
85
|
+
// Two first uploads may both see ENOENT: the loser's mkdir answers
|
|
86
|
+
// EEXIST and it validates what the winner (or a planted entry) put there.
|
|
87
|
+
try { mkdirSync(dir, { mode: 0o700 }); } catch (m) { if (m.code !== "EEXIST") throw m; }
|
|
88
|
+
st = lstatSync(dir);
|
|
89
|
+
}
|
|
90
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw err("E_UPLOAD_FAILED", `${dir} is not a real directory inside the instance home; nothing was written`);
|
|
91
|
+
const real = realpathSync(dir);
|
|
92
|
+
if (real !== dir && !real.startsWith(realHome + sep)) throw err("E_UPLOAD_FAILED", `${dir} resolves outside the instance home; nothing was written`);
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Store `bytes` as a private file under <home>/.oats-attachments/<name>,
|
|
97
|
+
* taking name-2, name-3 ... when the name is taken. The destination is
|
|
98
|
+
* allocated exclusively (O_CREAT|O_EXCL, 0600): two simultaneous uploads
|
|
99
|
+
* of the same name get two files and neither clobbers the other, and a
|
|
100
|
+
* symlink planted under a candidate name is skipped, never followed. The
|
|
101
|
+
* path is reported only after the bytes are written and synced. */
|
|
102
|
+
export function receiveAttachment(home, name, bytes, { maxBytes = MAX_ATTACHMENT_BYTES } = {}) {
|
|
103
|
+
const realHome = instanceHomeOf(home);
|
|
104
|
+
const safe = attachmentName(name);
|
|
105
|
+
if (!Buffer.isBuffer(bytes)) throw err("E_BAD_ARGS", "attachment bytes must be a Buffer");
|
|
106
|
+
if (bytes.length > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`);
|
|
107
|
+
const dir = attachmentsDir(realHome);
|
|
108
|
+
const ext = extname(safe), stem = safe.slice(0, safe.length - ext.length);
|
|
109
|
+
for (let i = 1; i <= 10000; i++) {
|
|
110
|
+
const path = i === 1 ? join(dir, safe) : join(dir, `${stem}-${i}${ext}`);
|
|
111
|
+
let fd;
|
|
112
|
+
try { fd = openSync(path, "wx", 0o600); }
|
|
113
|
+
catch (e) { if (e.code === "EEXIST") continue; throw e; }
|
|
114
|
+
try {
|
|
115
|
+
let off = 0;
|
|
116
|
+
while (off < bytes.length) off += writeSync(fd, bytes, off, bytes.length - off);
|
|
117
|
+
fsyncSync(fd);
|
|
118
|
+
} catch (e) { closeSync(fd); rmSync(path, { force: true }); throw e; }
|
|
119
|
+
closeSync(fd);
|
|
120
|
+
return { path, bytes: bytes.length, sha256: sha256Hex(bytes) };
|
|
121
|
+
}
|
|
122
|
+
throw err("E_UPLOAD_FAILED", `no free name for ${safe} under ${dir}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readLocalFile(file, maxBytes) {
|
|
126
|
+
if (typeof file !== "string" || !file) throw err("E_BAD_ARGS", "--file needs a path");
|
|
127
|
+
const abs = resolve(file);
|
|
128
|
+
let st;
|
|
129
|
+
try { st = statSync(abs); } catch { throw err("E_BAD_ARGS", `no such file: ${abs}`); }
|
|
130
|
+
if (!st.isFile()) throw err("E_BAD_ARGS", `${abs} is not a regular file`);
|
|
131
|
+
if (st.size > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `${abs} is ${st.size} bytes; the kernel bound is ${maxBytes}`);
|
|
132
|
+
const fd = openSync(abs, "r");
|
|
133
|
+
try { if (fstatSync(fd).size !== st.size) throw err("E_UPLOAD_FAILED", `${abs} changed while being read`); return { abs, bytes: readBounded(fd, maxBytes) }; }
|
|
134
|
+
finally { closeSync(fd); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Upload a local file into an instance's attachments: locally by home, or
|
|
138
|
+
* on the execution host of a registered server through its saved route
|
|
139
|
+
* (the same resolution as session attach). The remote answer's sha256 and
|
|
140
|
+
* size must equal the local file's before the path is reported. */
|
|
141
|
+
export function uploadAttachment({ file, home, server, instance }, io = {}) {
|
|
142
|
+
const maxBytes = io.maxBytes || MAX_ATTACHMENT_BYTES;
|
|
143
|
+
const { abs, bytes } = readLocalFile(file, maxBytes);
|
|
144
|
+
const name = basename(abs);
|
|
145
|
+
const sha256 = sha256Hex(bytes);
|
|
146
|
+
if (!server) {
|
|
147
|
+
if (!home) throw err("E_BAD_ARGS", "session upload needs --home </absolute/instance> or --server <id> with --instance <name> or --home");
|
|
148
|
+
return { ...receiveAttachment(home, name, bytes, { maxBytes }), name, home: realpathSync(home), source: abs };
|
|
149
|
+
}
|
|
150
|
+
const route = resolveRoute(server, { instance, home }, "session upload");
|
|
151
|
+
const remote = checkRemote(route.target, io);
|
|
152
|
+
// Both lists must carry the token; a probe without the arrays is an old kernel.
|
|
153
|
+
const advertises = (list) => Array.isArray(list) && list.includes("session-upload");
|
|
154
|
+
if (!advertises(remote.remote) || !advertises(remote.features)) {
|
|
155
|
+
throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-upload (kernels from ${SESSION_UPLOAD_REMOTE_VERSION} do); upgrade it there; nothing was sent`);
|
|
156
|
+
}
|
|
157
|
+
const { envelope, stderr } = runRemote(route.target, ["session", "receive", "--home", route.home, "--name", name, "--json"], { ...io, input: bytes, timeoutMs: io.timeoutMs || 600000 });
|
|
158
|
+
if (!envelope.ok) throw serverError(envelope.error?.code || "E_UPLOAD_FAILED", `${server}: ${envelope.error?.message || "receive failed"}`);
|
|
159
|
+
const r = envelope.result || {};
|
|
160
|
+
if (r.sha256 !== sha256 || r.bytes !== bytes.length || typeof r.path !== "string" || !r.path.startsWith("/")) {
|
|
161
|
+
throw serverError("E_UPLOAD_FAILED", `${server} stored ${r.bytes ?? "?"} bytes with sha256 ${r.sha256 ?? "?"} at ${r.path || "?"}, but the local file is ${bytes.length} bytes with sha256 ${sha256}; the remote file is left for inspection`);
|
|
162
|
+
}
|
|
163
|
+
return { path: r.path, bytes: r.bytes, sha256: r.sha256, name, home: route.home, server, instance: instance || route.snapshot?.instance, source: abs, ...(stderr?.trim() ? { stderr: stderr.trim() } : {}) };
|
|
164
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** The ONE host timer that runs `oats schedule tick --host` once a minute:
|
|
2
|
+
* a launchd user agent on macOS, a systemd user timer on Linux. Nothing is
|
|
3
|
+
* installed unless `oats schedule host install` is run explicitly, and
|
|
4
|
+
* status reports what the OS says is loaded, never what a file implies. */
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { hostScheduleDir, scheduleError } from "./schedule.mjs";
|
|
11
|
+
|
|
12
|
+
export const LAUNCHD_LABEL = "ai.oats.schedule-tick";
|
|
13
|
+
export const SYSTEMD_UNIT = "oats-schedule-tick";
|
|
14
|
+
const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oats.mjs");
|
|
15
|
+
|
|
16
|
+
function escapeXml(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
|
17
|
+
|
|
18
|
+
/** launchd and systemd start units with a short PATH that has no Homebrew
|
|
19
|
+
* tmux and no installed harness. The unit therefore carries an explicit
|
|
20
|
+
* PATH: the installer's own PATH (the operator's tool environment) plus
|
|
21
|
+
* the node running the installer and the usual prefixes. */
|
|
22
|
+
export function hostPath({ node = process.execPath, envPath = process.env.PATH || "" } = {}) {
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const p of [dirname(node), ...envPath.split(":"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]) {
|
|
26
|
+
if (p && !seen.has(p)) { seen.add(p); out.push(p); }
|
|
27
|
+
}
|
|
28
|
+
return out.join(":");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderLaunchdPlist({ node = process.execPath, oatsBin = OATS_BIN, logDir = join(hostScheduleDir(), "log"), intervalSec = 60, oatsHomeDir = process.env.OATS_HOME_DIR, path = hostPath({ node }) } = {}) {
|
|
32
|
+
const env = `\n <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key><string>${escapeXml(path)}</string>${oatsHomeDir ? `\n <key>OATS_HOME_DIR</key><string>${escapeXml(oatsHomeDir)}</string>` : ""}\n </dict>`;
|
|
33
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
35
|
+
<plist version="1.0">
|
|
36
|
+
<dict>
|
|
37
|
+
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
38
|
+
<key>ProgramArguments</key>
|
|
39
|
+
<array>
|
|
40
|
+
<string>${escapeXml(node)}</string>
|
|
41
|
+
<string>${escapeXml(oatsBin)}</string>
|
|
42
|
+
<string>schedule</string>
|
|
43
|
+
<string>tick</string>
|
|
44
|
+
<string>--host</string>
|
|
45
|
+
<string>--json</string>
|
|
46
|
+
</array>
|
|
47
|
+
<key>StartInterval</key><integer>${intervalSec}</integer>
|
|
48
|
+
<key>RunAtLoad</key><false/>
|
|
49
|
+
<key>ProcessType</key><string>Background</string>
|
|
50
|
+
<key>StandardOutPath</key><string>${escapeXml(join(logDir, "tick.log"))}</string>
|
|
51
|
+
<key>StandardErrorPath</key><string>${escapeXml(join(logDir, "tick.err"))}</string>${env}
|
|
52
|
+
</dict>
|
|
53
|
+
</plist>
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
/** systemd unit values: `%` is a specifier escape (`%%`), and a quoted
|
|
57
|
+
* string protects spaces; backslashes and double quotes are escaped. */
|
|
58
|
+
export function systemdQuote(value) {
|
|
59
|
+
return `"${String(value).replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
60
|
+
}
|
|
61
|
+
export function renderSystemdUnits({ node = process.execPath, oatsBin = OATS_BIN, intervalSec = 60, oatsHomeDir = process.env.OATS_HOME_DIR, path = hostPath({ node }) } = {}) {
|
|
62
|
+
const env = `Environment=${systemdQuote(`PATH=${path}`)}\n${oatsHomeDir ? `Environment=${systemdQuote(`OATS_HOME_DIR=${oatsHomeDir}`)}\n` : ""}`;
|
|
63
|
+
return {
|
|
64
|
+
service: `[Unit]\nDescription=OATS schedule tick\n\n[Service]\nType=oneshot\n${env}ExecStart=${systemdQuote(node)} ${systemdQuote(oatsBin)} schedule tick --host --json\n`,
|
|
65
|
+
timer: `[Unit]\nDescription=OATS schedule tick every ${intervalSec}s\n\n[Timer]\nOnBootSec=${intervalSec}\nOnUnitActiveSec=${intervalSec}\nAccuracySec=5\nUnit=${SYSTEMD_UNIT}.service\n\n[Install]\nWantedBy=timers.target\n`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `unitDir` overrides the OS location (tests never touch a live timer's files). */
|
|
70
|
+
export function hostUnitPaths(os = platform(), { unitDir } = {}) {
|
|
71
|
+
if (os === "darwin") return { kind: "launchd", plist: join(unitDir || join(homedir(), "Library", "LaunchAgents"), `${LAUNCHD_LABEL}.plist`) };
|
|
72
|
+
if (os === "linux") { const dir = unitDir || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "systemd", "user"); return { kind: "systemd", service: join(dir, `${SYSTEMD_UNIT}.service`), timer: join(dir, `${SYSTEMD_UNIT}.timer`) }; }
|
|
73
|
+
return { kind: "unsupported" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function run(exec, argv) {
|
|
77
|
+
try { return { ok: true, out: (exec || execFileSync)(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20000 }) }; }
|
|
78
|
+
catch (e) { return { ok: false, out: String(e.stdout || ""), err: String(e.stderr || e.message || "").trim() }; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** installed = the unit file exists; active = the OS reports it loaded/active. */
|
|
82
|
+
export function hostUnitStatus({ exec, os = platform(), unitDir } = {}) {
|
|
83
|
+
const p = hostUnitPaths(os, { unitDir });
|
|
84
|
+
if (p.kind === "launchd") {
|
|
85
|
+
const installed = existsSync(p.plist);
|
|
86
|
+
const r = run(exec, ["launchctl", "print", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`]);
|
|
87
|
+
return { kind: p.kind, unit: p.plist, installed, active: r.ok };
|
|
88
|
+
}
|
|
89
|
+
if (p.kind === "systemd") {
|
|
90
|
+
const installed = existsSync(p.timer) && existsSync(p.service);
|
|
91
|
+
const r = run(exec, ["systemctl", "--user", "is-active", `${SYSTEMD_UNIT}.timer`]);
|
|
92
|
+
return { kind: p.kind, unit: p.timer, installed, active: r.ok && r.out.trim() === "active" };
|
|
93
|
+
}
|
|
94
|
+
return { kind: "unsupported", installed: false, active: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function installHostUnit({ exec, os = platform(), intervalSec = 60, unitDir } = {}) {
|
|
98
|
+
const p = hostUnitPaths(os, { unitDir });
|
|
99
|
+
mkdirSync(join(hostScheduleDir(), "log"), { recursive: true });
|
|
100
|
+
if (p.kind === "launchd") {
|
|
101
|
+
mkdirSync(dirname(p.plist), { recursive: true });
|
|
102
|
+
const status = hostUnitStatus({ exec, os, unitDir });
|
|
103
|
+
const rendered = renderLaunchdPlist({ intervalSec });
|
|
104
|
+
// Idempotent: registering another scope while the same timer is active
|
|
105
|
+
// must not unload an in-flight tick just to rewrite identical bytes.
|
|
106
|
+
if (status.active && existsSync(p.plist) && readFileSync(p.plist, "utf8") === rendered) return status;
|
|
107
|
+
if (status.active) { const r = run(exec, ["launchctl", "bootout", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`]); if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `launchctl bootout failed before reinstall: ${r.err}`); }
|
|
108
|
+
writeFileSync(p.plist, rendered);
|
|
109
|
+
const r = run(exec, ["launchctl", "bootstrap", `gui/${userInfo().uid}`, p.plist]);
|
|
110
|
+
if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `launchctl bootstrap failed: ${r.err}`);
|
|
111
|
+
return hostUnitStatus({ exec, os, unitDir });
|
|
112
|
+
}
|
|
113
|
+
if (p.kind === "systemd") {
|
|
114
|
+
mkdirSync(dirname(p.timer), { recursive: true });
|
|
115
|
+
const units = renderSystemdUnits({ intervalSec });
|
|
116
|
+
const status = hostUnitStatus({ exec, os, unitDir });
|
|
117
|
+
if (status.active && existsSync(p.service) && existsSync(p.timer) && readFileSync(p.service, "utf8") === units.service && readFileSync(p.timer, "utf8") === units.timer) return status;
|
|
118
|
+
writeFileSync(p.service, units.service); writeFileSync(p.timer, units.timer);
|
|
119
|
+
for (const argv of [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", `${SYSTEMD_UNIT}.timer`]]) { const r = run(exec, argv); if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `${argv.join(" ")} failed: ${r.err}`); }
|
|
120
|
+
return hostUnitStatus({ exec, os, unitDir });
|
|
121
|
+
}
|
|
122
|
+
throw scheduleError("E_SCHEDULE_HOST", `no host timer support on ${os}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Uninstall reports the OS's answer: a unit that stays loaded after the
|
|
126
|
+
* OS refused to unload it is an error, not a removed file. */
|
|
127
|
+
export function uninstallHostUnit({ exec, os = platform(), unitDir } = {}) {
|
|
128
|
+
const p = hostUnitPaths(os, { unitDir });
|
|
129
|
+
if (p.kind === "launchd") {
|
|
130
|
+
const before = hostUnitStatus({ exec, os, unitDir });
|
|
131
|
+
if (before.active) {
|
|
132
|
+
const r = run(exec, ["launchctl", "bootout", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`]);
|
|
133
|
+
if (!r.ok || hostUnitStatus({ exec, os, unitDir }).active) throw scheduleError("E_SCHEDULE_HOST", `launchctl bootout did not unload ${LAUNCHD_LABEL}: ${r.err || "still loaded"}; the unit file is kept`);
|
|
134
|
+
}
|
|
135
|
+
rmSync(p.plist, { force: true });
|
|
136
|
+
return hostUnitStatus({ exec, os, unitDir });
|
|
137
|
+
}
|
|
138
|
+
if (p.kind === "systemd") {
|
|
139
|
+
const before = hostUnitStatus({ exec, os, unitDir });
|
|
140
|
+
if (before.active) {
|
|
141
|
+
const r = run(exec, ["systemctl", "--user", "disable", "--now", `${SYSTEMD_UNIT}.timer`]);
|
|
142
|
+
if (!r.ok || hostUnitStatus({ exec, os, unitDir }).active) throw scheduleError("E_SCHEDULE_HOST", `systemctl --user disable --now ${SYSTEMD_UNIT}.timer failed: ${r.err || "still active"}; the unit files are kept`);
|
|
143
|
+
}
|
|
144
|
+
rmSync(p.timer, { force: true }); rmSync(p.service, { force: true });
|
|
145
|
+
const r = run(exec, ["systemctl", "--user", "daemon-reload"]);
|
|
146
|
+
if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `systemctl --user daemon-reload failed: ${r.err}`);
|
|
147
|
+
return hostUnitStatus({ exec, os, unitDir });
|
|
148
|
+
}
|
|
149
|
+
throw scheduleError("E_SCHEDULE_HOST", `no host timer support on ${os}`);
|
|
150
|
+
}
|