@manyoars/oars 0.4.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -29
- package/dist/bin.js +3 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +207 -21
- package/dist/club.d.ts +19 -0
- package/dist/club.js +132 -0
- package/dist/gate/hook-fill-main.js +1 -1
- package/dist/gate/hook-fill.d.ts +1 -1
- package/dist/gate/hook-fill.js +1 -1
- package/dist/gate/hook-main.js +7 -5
- package/dist/gate/hook.d.ts +12 -1
- package/dist/gate/hook.js +38 -7
- package/dist/hook-bin.js +23 -4
- package/dist/main.js +5 -0
- package/dist/node-floor.d.ts +10 -0
- package/dist/node-floor.js +23 -0
- package/dist/oars-hook.d.ts +2 -0
- package/dist/oars-hook.js +7 -0
- package/dist/sessions/allow.d.ts +1 -0
- package/dist/sessions/allow.js +1 -1
- package/dist/sessions/call-shape.d.ts +111 -0
- package/dist/sessions/call-shape.js +464 -0
- package/dist/sessions/candidates.d.ts +65 -0
- package/dist/sessions/candidates.js +236 -0
- package/dist/sessions/card-command.d.ts +24 -0
- package/dist/sessions/card-command.js +71 -0
- package/dist/sessions/card-font.d.ts +6 -0
- package/dist/sessions/card-font.js +36 -0
- package/dist/sessions/card-line.d.ts +30 -0
- package/dist/sessions/card-line.js +105 -0
- package/dist/sessions/card-render.d.ts +15 -0
- package/dist/sessions/card-render.js +146 -0
- package/dist/sessions/card.d.ts +59 -0
- package/dist/sessions/card.js +107 -0
- package/dist/sessions/cell-increments.d.ts +14 -3
- package/dist/sessions/cell-increments.js +79 -12
- package/dist/sessions/cells.d.ts +42 -4
- package/dist/sessions/cells.js +72 -18
- package/dist/sessions/codex-hook-bin.d.ts +1 -0
- package/dist/sessions/codex-hook-bin.js +24 -0
- package/dist/sessions/codex-hook-install.d.ts +21 -0
- package/dist/sessions/codex-hook-install.js +47 -0
- package/dist/sessions/codex-hook-view.d.ts +20 -0
- package/dist/sessions/codex-hook-view.js +20 -0
- package/dist/sessions/codex-hook.d.ts +77 -0
- package/dist/sessions/codex-hook.js +255 -0
- package/dist/sessions/codex.d.ts +9 -0
- package/dist/sessions/codex.js +104 -18
- package/dist/sessions/command.d.ts +17 -2
- package/dist/sessions/command.js +145 -33
- package/dist/sessions/copilot.d.ts +3 -0
- package/dist/sessions/copilot.js +12 -4
- package/dist/sessions/derive.js +2 -1
- package/dist/sessions/empty-state.d.ts +17 -0
- package/dist/sessions/empty-state.js +38 -0
- package/dist/sessions/exclusion.js +48 -3
- package/dist/sessions/extract.d.ts +16 -3
- package/dist/sessions/extract.js +281 -24
- package/dist/sessions/gemini.d.ts +2 -0
- package/dist/sessions/gemini.js +29 -2
- package/dist/sessions/harness-files.d.ts +27 -0
- package/dist/sessions/harness-files.js +109 -0
- package/dist/sessions/head-only.d.ts +7 -0
- package/dist/sessions/head-only.js +21 -0
- package/dist/sessions/hook-calls.js +4 -3
- package/dist/sessions/hooks.d.ts +1 -1
- package/dist/sessions/hooks.js +6 -2
- package/dist/sessions/identity.d.ts +6 -0
- package/dist/sessions/injected.d.ts +13 -0
- package/dist/sessions/injected.js +260 -0
- package/dist/sessions/install.d.ts +31 -20
- package/dist/sessions/install.js +80 -82
- package/dist/sessions/load-status.d.ts +41 -0
- package/dist/sessions/load-status.js +156 -0
- package/dist/sessions/machine-child-deps.d.ts +5 -0
- package/dist/sessions/machine-child-deps.js +83 -0
- package/dist/sessions/machine-child.d.ts +43 -0
- package/dist/sessions/machine-child.js +120 -0
- package/dist/sessions/machine-credential.d.ts +10 -0
- package/dist/sessions/machine-credential.js +20 -0
- package/dist/sessions/machine-enroll.d.ts +47 -0
- package/dist/sessions/machine-enroll.js +230 -0
- package/dist/sessions/machine-hold.d.ts +49 -0
- package/dist/sessions/machine-hold.js +88 -0
- package/dist/sessions/machine-ids.d.ts +6 -0
- package/dist/sessions/machine-ids.js +9 -0
- package/dist/sessions/machine-notice.d.ts +21 -0
- package/dist/sessions/machine-notice.js +89 -0
- package/dist/sessions/machine-pull.d.ts +40 -0
- package/dist/sessions/machine-pull.js +220 -0
- package/dist/sessions/machine-repo.d.ts +7 -0
- package/dist/sessions/machine-repo.js +30 -0
- package/dist/sessions/machine-scan-cache.d.ts +12 -0
- package/dist/sessions/machine-scan-cache.js +47 -0
- package/dist/sessions/machine-send.d.ts +12 -0
- package/dist/sessions/machine-send.js +156 -0
- package/dist/sessions/machine-stop.d.ts +22 -0
- package/dist/sessions/machine-stop.js +53 -0
- package/dist/sessions/machine.d.ts +41 -0
- package/dist/sessions/machine.js +89 -0
- package/dist/sessions/opencode.d.ts +2 -0
- package/dist/sessions/opencode.js +6 -0
- package/dist/sessions/otel.d.ts +21 -0
- package/dist/sessions/otel.js +109 -38
- package/dist/sessions/owner-only.d.ts +7 -0
- package/dist/sessions/owner-only.js +34 -0
- package/dist/sessions/pi.d.ts +13 -1
- package/dist/sessions/pi.js +27 -13
- package/dist/sessions/pool-books.d.ts +1 -1
- package/dist/sessions/pool-books.js +1 -1
- package/dist/sessions/pool-choices.d.ts +4 -2
- package/dist/sessions/pool-choices.js +4 -2
- package/dist/sessions/pool-command.d.ts +46 -6
- package/dist/sessions/pool-command.js +613 -136
- package/dist/sessions/pool-delta.d.ts +7 -0
- package/dist/sessions/pool-delta.js +28 -5
- package/dist/sessions/pool-due.d.ts +43 -0
- package/dist/sessions/pool-due.js +113 -0
- package/dist/sessions/pool-nightly.d.ts +9 -1
- package/dist/sessions/pool-nightly.js +65 -21
- package/dist/sessions/pool-outbox.d.ts +51 -5
- package/dist/sessions/pool-outbox.js +217 -24
- package/dist/sessions/pool-print.d.ts +1 -1
- package/dist/sessions/pool-raw.js +4 -3
- package/dist/sessions/pool-replica.d.ts +4 -4
- package/dist/sessions/pool-replica.js +26 -7
- package/dist/sessions/pool-send-lock.js +1 -1
- package/dist/sessions/pool-sent.d.ts +2 -0
- package/dist/sessions/pool-sent.js +21 -4
- package/dist/sessions/pool-wire.d.ts +176 -15
- package/dist/sessions/pool-wire.js +297 -33
- package/dist/sessions/pool.js +4 -2
- package/dist/sessions/public-vocabulary.d.ts +5 -0
- package/dist/sessions/public-vocabulary.js +16 -0
- package/dist/sessions/read-call-shape.js +12 -6
- package/dist/sessions/read-fit.d.ts +5 -0
- package/dist/sessions/read-fit.js +23 -2
- package/dist/sessions/read-interaction.js +1 -1
- package/dist/sessions/read-lead-lag.js +1 -1
- package/dist/sessions/read-readable.js +1 -1
- package/dist/sessions/read.d.ts +1 -1
- package/dist/sessions/recall-consequence.d.ts +2 -2
- package/dist/sessions/recall-consequence.js +14 -9
- package/dist/sessions/recall-object.d.ts +9 -2
- package/dist/sessions/receipt.d.ts +8 -0
- package/dist/sessions/receipt.js +78 -0
- package/dist/sessions/recovery-calls.d.ts +3 -2
- package/dist/sessions/recovery-calls.js +9 -3
- package/dist/sessions/redact.d.ts +11 -2
- package/dist/sessions/redact.js +201 -23
- package/dist/sessions/repo-root.d.ts +7 -0
- package/dist/sessions/repo-root.js +17 -13
- package/dist/sessions/reversion.d.ts +8 -0
- package/dist/sessions/reversion.js +114 -0
- package/dist/sessions/schema.d.ts +78 -4
- package/dist/sessions/schema.js +31 -1
- package/dist/sessions/session-shape.d.ts +2 -1
- package/dist/sessions/session-shape.js +15 -0
- package/dist/sessions/sources.d.ts +9 -0
- package/dist/sessions/sources.js +16 -5
- package/dist/sessions/steps.js +3 -2
- package/dist/sessions/store.d.ts +41 -2
- package/dist/sessions/store.js +68 -16
- package/dist/sessions/try-demo.d.ts +8 -0
- package/dist/sessions/try-demo.js +90 -0
- package/dist/sessions/try.d.ts +13 -0
- package/dist/sessions/try.js +50 -0
- package/dist/sessions/vscode-copilot.d.ts +4 -0
- package/dist/sessions/vscode-copilot.js +25 -18
- package/dist/telemetry/command.js +12 -6
- package/dist/telemetry/consent.d.ts +9 -3
- package/dist/telemetry/consent.js +25 -9
- package/dist/telemetry/fields.d.ts +11 -0
- package/dist/telemetry/fields.js +56 -0
- package/dist/telemetry/measures.d.ts +13 -0
- package/dist/telemetry/measures.js +30 -0
- package/dist/telemetry/observe.d.ts +15 -2
- package/dist/telemetry/observe.js +34 -7
- package/dist/telemetry/region.d.ts +6 -0
- package/dist/telemetry/region.js +28 -0
- package/dist/telemetry/report.js +1 -1
- package/dist/telemetry/schema.d.ts +3 -0
- package/dist/telemetry/schema.js +11 -1
- package/dist/telemetry/send.d.ts +5 -2
- package/dist/telemetry/send.js +15 -7
- package/dist/telemetry/words.d.ts +6 -0
- package/dist/telemetry/words.js +6 -0
- package/package.json +24 -5
|
@@ -52,3 +52,10 @@ export interface Carried {
|
|
|
52
52
|
* again under the same key, builds none it already carried. Nothing is marked here: the caller marks them once the batch is written.
|
|
53
53
|
*/
|
|
54
54
|
export declare const carriedDeltas: (account: string, sessions: readonly Gated[], sent: ReadonlySet<string>) => Map<string, Carried>;
|
|
55
|
+
/** The delta entries the batch under `key` in this outbox already carries; none when it has no batch, or one this reader does not take. */
|
|
56
|
+
export declare const heldDeltas: (outbox: string, key: string) => Promise<DeltaEntry[]>;
|
|
57
|
+
/**
|
|
58
|
+
* What a machine's Stop writes for the one open session it builds again: the deltas its own earlier batch carried, then the new ones. A session keeps exactly what its own batch carried, so the batch that replaces its own never loses a delta, and one that did
|
|
59
|
+
* not carry a held delta never takes it, whichever session fills the window now. A delta that appears in both (a batch written before its mark was) is written once.
|
|
60
|
+
*/
|
|
61
|
+
export declare const keptDeltas: (held: readonly DeltaEntry[], fresh: readonly DeltaEntry[]) => DeltaEntry[];
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { isMissing } from './pool-outbox.js';
|
|
3
|
+
import { heldBatchOf, isMissing } from './pool-outbox.js';
|
|
4
|
+
import { appendOwnerOnly, mkdirOwnerOnly } from './owner-only.js';
|
|
4
5
|
import { contentHash } from './read.js';
|
|
5
|
-
import { bucketOf, PACKAGE_REGISTRIES } from './pool-wire.js';
|
|
6
|
+
import { bucketOf, PACKAGE_REGISTRIES, parseSkeleton } from './pool-wire.js';
|
|
6
7
|
import { PRIVATE, PUBLIC_PACKAGES, publicEffort } from './public-vocabulary.js';
|
|
7
8
|
import { harnessOf } from './schema.js';
|
|
8
9
|
// Local deltas (spec Section 2, "Deltas"): the client compares its own before and after windows and builds each change once. Nothing here reads a store or sends a byte; `pool deposit` hands it the
|
|
@@ -157,8 +158,8 @@ export const sentDeltas = async (outbox) => {
|
|
|
157
158
|
export const markDeltasSent = async (outbox, marks) => {
|
|
158
159
|
if (marks.length === 0)
|
|
159
160
|
return;
|
|
160
|
-
await
|
|
161
|
-
await
|
|
161
|
+
await mkdirOwnerOnly(outbox);
|
|
162
|
+
await appendOwnerOnly(join(outbox, MARKS_FILE), marks.map((mark) => `${mark}\n`).join(''));
|
|
162
163
|
};
|
|
163
164
|
/** The session placed by when it began (epoch milliseconds) and its own id; none when it has no start that reads as a time, since no window can hold it. */
|
|
164
165
|
export const placedOf = (records) => {
|
|
@@ -188,3 +189,25 @@ export const carriedDeltas = (account, sessions, sent) => {
|
|
|
188
189
|
}
|
|
189
190
|
return carried;
|
|
190
191
|
};
|
|
192
|
+
/** The delta entries the batch under `key` in this outbox already carries; none when it has no batch, or one this reader does not take. */
|
|
193
|
+
export const heldDeltas = async (outbox, key) => {
|
|
194
|
+
const held = await heldBatchOf(outbox, key);
|
|
195
|
+
if (held === undefined)
|
|
196
|
+
return [];
|
|
197
|
+
const parsed = parseSkeleton(JSON.parse(held.bytes));
|
|
198
|
+
return parsed.ok ? parsed.skeleton.deltas : [];
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* What a machine's Stop writes for the one open session it builds again: the deltas its own earlier batch carried, then the new ones. A session keeps exactly what its own batch carried, so the batch that replaces its own never loses a delta, and one that did
|
|
202
|
+
* not carry a held delta never takes it, whichever session fills the window now. A delta that appears in both (a batch written before its mark was) is written once.
|
|
203
|
+
*/
|
|
204
|
+
export const keptDeltas = (held, fresh) => {
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
return [...held, ...fresh].filter(({ deltaKind, field, name, registry, metric, change }) => {
|
|
207
|
+
const identity = JSON.stringify([deltaKind, field, name, registry, metric, change]);
|
|
208
|
+
if (seen.has(identity))
|
|
209
|
+
return false;
|
|
210
|
+
seen.add(identity);
|
|
211
|
+
return true;
|
|
212
|
+
});
|
|
213
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** The part of `child_process.spawn` the sender start uses, so a test can hand in a child that never starts or one that fails to. */
|
|
2
|
+
export type SenderSpawn = (command: string, args: string[], options: {
|
|
3
|
+
detached: true;
|
|
4
|
+
stdio: 'ignore';
|
|
5
|
+
windowsHide: true;
|
|
6
|
+
cwd: string;
|
|
7
|
+
}) => {
|
|
8
|
+
on: (event: 'error', listener: (error: Error) => void) => unknown;
|
|
9
|
+
unref: () => void;
|
|
10
|
+
};
|
|
11
|
+
/** The debounce: a sweep is started at most once in this long. */
|
|
12
|
+
export declare const SEND_EVERY_MS: number;
|
|
13
|
+
export type CatchUpResult = 'started' | 'not-due' | 'claimed' | 'not-ready' | 'failed';
|
|
14
|
+
export interface CatchUpDeps {
|
|
15
|
+
storeDir: string;
|
|
16
|
+
now: () => number;
|
|
17
|
+
/** Whether this machine has a session store the person made (a deposit or a hook install ran): nothing is swept into a store nobody asked for. */
|
|
18
|
+
ready: () => Promise<boolean>;
|
|
19
|
+
/** Starts the sender detached and returns at once; nothing waits for it. */
|
|
20
|
+
start: () => void;
|
|
21
|
+
}
|
|
22
|
+
/** True once the person ran `oars sessions hook install --schedule off` and has not run `--schedule on` since. A record that cannot be read or parsed counts as no opt-out. */
|
|
23
|
+
export declare const scheduleOptedOut: (storeDir: string) => Promise<boolean>;
|
|
24
|
+
/** Records the choice of `--schedule`: `off` writes the opt-out; `on` clears it. */
|
|
25
|
+
export declare const recordSchedule: (storeDir: string, mode: "on" | "off") => Promise<void>;
|
|
26
|
+
/** One masked line in `pool-send.log`, the place a failure of the trigger or the sender goes. Never throws. */
|
|
27
|
+
export declare const logFailure: (storeDir: string, at: number, error: unknown) => Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Never throws. Bound: the claim is per window and the mark is written after it, so two callers on either side of an hour boundary can both start a sender in the worst case; the send lock
|
|
30
|
+
* (`pool-send-lock.ts`) makes the second sender wait for the first, so nothing is posted twice.
|
|
31
|
+
*/
|
|
32
|
+
export declare const catchUp: (deps: CatchUpDeps) => Promise<CatchUpResult>;
|
|
33
|
+
/** What the hook and the command line call: the check on the real store, clock, and spawner, with the sender being `hook-bin.js nightly`. Never throws, and returns once the sender is started. */
|
|
34
|
+
export declare const catchUpOnStore: (storeDir: string, hookBin: string, now?: () => number, start?: SenderSpawn) => Promise<CatchUpResult>;
|
|
35
|
+
/** What the session-end hook calls after its deposit: the same check, returning once the sender is started and writing nothing to stdout (the hook's output is its deposit's, unchanged). */
|
|
36
|
+
export declare const afterStop: (storeDir: string, hookBin: string, now?: () => number, start?: SenderSpawn) => Promise<CatchUpResult>;
|
|
37
|
+
/** The store counts once `state.json` is there and the person has not opted out with `--schedule off`. */
|
|
38
|
+
export declare const storeReady: (storeDir: string) => (() => Promise<boolean>);
|
|
39
|
+
/**
|
|
40
|
+
* Starts `node <hook-bin> nightly` detached with no stdio and never waits for it, as telemetry's sender and the gate's helper are started. The sender inherits this process's environment but runs in the home folder, so it never holds the caller's project folder or worktree open (Windows cannot remove a folder that is a running process's working directory). A child that cannot
|
|
41
|
+
* start (its `error` event) is written to `pool-send.log` like any other failure; the retry is the next trigger after the debounce.
|
|
42
|
+
*/
|
|
43
|
+
export declare const startSender: (hookBin: string, storeDir: string, now: () => number, start?: SenderSpawn) => (() => void);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The one due check behind every trigger. oars installs no OS scheduler: the SessionEnd hook, after its deposit, and any `oars` command call `catchUp`, which decides whether a sweep and
|
|
2
|
+
// send is due (at most about hourly) and starts it detached, so the caller never waits and its exit code and output never change. The detached process is `hook-bin.js nightly` (the name
|
|
3
|
+
// is the persisted trigger word in the step log): it deposits every harness the person allowed, including those with no session-end hook, derives, sends telemetry when telemetry is on,
|
|
4
|
+
// and sends the pool outbox after the covenant is accepted; each of those gates itself, so a start by this check sends nothing a person has not agreed to.
|
|
5
|
+
// Fail-open: an error is written to `pool-send.log` in the session store (masked, one line). The mark is written before the start, so a failed start is retried by the next trigger after the debounce, about an hour later.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { appendFile, mkdir, open, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { errorLine } from './steps.js';
|
|
11
|
+
/** The debounce: a sweep is started at most once in this long. */
|
|
12
|
+
export const SEND_EVERY_MS = 60 * 60 * 1000;
|
|
13
|
+
const MARK = 'pool-due.json';
|
|
14
|
+
const LOG = 'pool-send.log';
|
|
15
|
+
const CLAIM = /^pool-due\.(\d+)\.claim$/;
|
|
16
|
+
/** Where `--schedule off` is remembered: a file of its own in the sessions store, because `state.json` is rewritten whole by every deposit. */
|
|
17
|
+
const OPT_OUT = 'schedule.json';
|
|
18
|
+
/** True once the person ran `oars sessions hook install --schedule off` and has not run `--schedule on` since. A record that cannot be read or parsed counts as no opt-out. */
|
|
19
|
+
export const scheduleOptedOut = async (storeDir) => {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(await readFile(join(storeDir, OPT_OUT), 'utf8'));
|
|
22
|
+
return typeof parsed === 'object' && parsed !== null && Reflect.get(parsed, 'schedule') === 'off';
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/** Records the choice of `--schedule`: `off` writes the opt-out; `on` clears it. */
|
|
29
|
+
export const recordSchedule = async (storeDir, mode) => {
|
|
30
|
+
if (mode === 'on') {
|
|
31
|
+
await rm(join(storeDir, OPT_OUT), { force: true });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await mkdir(storeDir, { recursive: true });
|
|
35
|
+
await writeFile(join(storeDir, OPT_OUT), `${JSON.stringify({ schedule: 'off' })}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
36
|
+
};
|
|
37
|
+
/** One masked line in `pool-send.log`, the place a failure of the trigger or the sender goes. Never throws. */
|
|
38
|
+
export const logFailure = async (storeDir, at, error) => {
|
|
39
|
+
const line = await errorLine(storeDir, error).catch(() => 'unknown error');
|
|
40
|
+
await mkdir(storeDir, { recursive: true }).then(() => appendFile(join(storeDir, LOG), `${new Date(at).toISOString()} ${line}\n`, 'utf8')).catch(() => undefined);
|
|
41
|
+
};
|
|
42
|
+
/** When the last sender was started; a missing, unreadable, or future mark is no mark. */
|
|
43
|
+
const lastStart = async (storeDir, now) => {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(await readFile(join(storeDir, MARK), 'utf8'));
|
|
46
|
+
const at = typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'startedAt') : undefined;
|
|
47
|
+
return typeof at === 'number' && at <= now ? at : undefined;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* One caller per window wins: the claim is a file named for the window and created with `wx`, so two callers in one window (two hooks that end together, a hook and a command) cannot both
|
|
55
|
+
* create it. Claims of earlier windows are swept away by the winner.
|
|
56
|
+
*/
|
|
57
|
+
const claim = async (storeDir, window) => {
|
|
58
|
+
try {
|
|
59
|
+
await (await open(join(storeDir, `pool-due.${String(window)}.claim`), 'wx')).close();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error instanceof Error && Reflect.get(error, 'code') === 'EEXIST')
|
|
63
|
+
return false;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
for (const name of await readdir(storeDir)) {
|
|
67
|
+
const found = CLAIM.exec(name);
|
|
68
|
+
if (found !== null && Number(found[1]) < window)
|
|
69
|
+
await rm(join(storeDir, name), { force: true });
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Never throws. Bound: the claim is per window and the mark is written after it, so two callers on either side of an hour boundary can both start a sender in the worst case; the send lock
|
|
75
|
+
* (`pool-send-lock.ts`) makes the second sender wait for the first, so nothing is posted twice.
|
|
76
|
+
*/
|
|
77
|
+
export const catchUp = async (deps) => {
|
|
78
|
+
try {
|
|
79
|
+
if (!(await deps.ready()))
|
|
80
|
+
return 'not-ready';
|
|
81
|
+
const now = deps.now();
|
|
82
|
+
const last = await lastStart(deps.storeDir, now);
|
|
83
|
+
if (last !== undefined && now - last < SEND_EVERY_MS)
|
|
84
|
+
return 'not-due';
|
|
85
|
+
if (!(await claim(deps.storeDir, Math.floor(now / SEND_EVERY_MS))))
|
|
86
|
+
return 'claimed';
|
|
87
|
+
await writeFile(join(deps.storeDir, MARK), `${JSON.stringify({ startedAt: now })}\n`, 'utf8');
|
|
88
|
+
deps.start();
|
|
89
|
+
return 'started';
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
// The mark is already written when the start itself failed; the debounce holds until SEND_EVERY_MS after the mark, and the next trigger after that retries.
|
|
93
|
+
await logFailure(deps.storeDir, deps.now(), error);
|
|
94
|
+
return 'failed';
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
/** What the hook and the command line call: the check on the real store, clock, and spawner, with the sender being `hook-bin.js nightly`. Never throws, and returns once the sender is started. */
|
|
98
|
+
export const catchUpOnStore = (storeDir, hookBin, now = Date.now, start = spawn) => catchUp({ storeDir, now, ready: storeReady(storeDir), start: startSender(hookBin, storeDir, now, start) });
|
|
99
|
+
/** What the session-end hook calls after its deposit: the same check, returning once the sender is started and writing nothing to stdout (the hook's output is its deposit's, unchanged). */
|
|
100
|
+
export const afterStop = (storeDir, hookBin, now = Date.now, start = spawn) => catchUpOnStore(storeDir, hookBin, now, start);
|
|
101
|
+
/** The store counts once `state.json` is there and the person has not opted out with `--schedule off`. */
|
|
102
|
+
export const storeReady = (storeDir) => async () => (await stat(join(storeDir, 'state.json')).then(() => true, () => false)) && !(await scheduleOptedOut(storeDir));
|
|
103
|
+
/**
|
|
104
|
+
* Starts `node <hook-bin> nightly` detached with no stdio and never waits for it, as telemetry's sender and the gate's helper are started. The sender inherits this process's environment but runs in the home folder, so it never holds the caller's project folder or worktree open (Windows cannot remove a folder that is a running process's working directory). A child that cannot
|
|
105
|
+
* start (its `error` event) is written to `pool-send.log` like any other failure; the retry is the next trigger after the debounce.
|
|
106
|
+
*/
|
|
107
|
+
export const startSender = (hookBin, storeDir, now, start = spawn) => () => {
|
|
108
|
+
const child = start(process.execPath, [hookBin, 'nightly'], { detached: true, stdio: 'ignore', windowsHide: true, cwd: homedir() });
|
|
109
|
+
child.on('error', (error) => {
|
|
110
|
+
void logFailure(storeDir, now(), error);
|
|
111
|
+
});
|
|
112
|
+
child.unref();
|
|
113
|
+
};
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import type { RunDeps } from '../cli.js';
|
|
2
2
|
import { type Sender } from './pool-command.js';
|
|
3
|
-
export type NightlyOutcome = 'no-server' | 'not-signed-in' | 'not-accepted' | 'failed' | 'sent';
|
|
3
|
+
export type NightlyOutcome = 'no-server' | 'machine' | 'not-signed-in' | 'not-accepted' | 'failed' | 'sent';
|
|
4
4
|
export interface NightlyPool {
|
|
5
5
|
outcome: NightlyOutcome;
|
|
6
6
|
/** Batches and raw copies this night sent. */
|
|
7
7
|
batches: number;
|
|
8
8
|
raw: number;
|
|
9
|
+
/** Pulled batches the pool refused for its closed vocabulary or its format this night: each stays in the outbox, unsent, and is asked again the next night. Set only when there were some. */
|
|
10
|
+
refused?: number;
|
|
11
|
+
/** Pulled batches removed this night because they now hold a term of the exclusion file. Set only when there were some. */
|
|
12
|
+
removed?: number;
|
|
13
|
+
/** Set when a raw yes was held back because it was given under another covenant than the one in force; `oars pool raw --consent yes` answers for the covenant now in force. The sender prints nothing, so this note is where it shows. */
|
|
14
|
+
rawHeld?: true;
|
|
9
15
|
}
|
|
16
|
+
/** One night's send. `nightlyPoolSend` folds what it throws into a note, so a test reads the thrown error here. */
|
|
17
|
+
export declare const night: (deps: RunDeps, net: Sender, tally: NightlyPool) => Promise<NightlyPool>;
|
|
10
18
|
export declare const nightlyPoolSend: (deps: RunDeps, via?: Sender) => Promise<NightlyPool>;
|
|
@@ -1,24 +1,30 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The detached sender (`hook-bin.js nightly`). After the person has accepted the pool's covenant, the sender builds the outbox and sends what is new: skeleton batches, and, only after a yes to the raw prompt, the locally redacted raw copies.
|
|
2
2
|
// Acceptance is the consent: this module never accepts a covenant, and with an unusable server URL, no sign-in, or no acceptance it makes one request at most (the covenant read) and sends nothing. `oars pool outbox` still prints everything that is queued.
|
|
3
3
|
// It never throws, and what it records is counts and an outcome word, never a path, a host, or a byte of a transcript.
|
|
4
4
|
import { writeFile } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { CliError } from '../errors.js';
|
|
7
|
+
import { machineOf } from './machine.js';
|
|
8
|
+
import { ask, currentTerms, DEPOSIT, isObject, networkSender, poolDeposit, poolServer, postBatch, postRaw, runtimeOf, signedIn, textIn, vocabularyExpired } from './pool-command.js';
|
|
9
|
+
import { rawStale } from './pool-choices.js';
|
|
10
|
+
import { heldAcross, lockedBatch, outboxFolder, rescanPulled } from './pool-outbox.js';
|
|
8
11
|
import { heldRaw } from './pool-raw.js';
|
|
9
12
|
import { withSendLock } from './pool-send-lock.js';
|
|
10
|
-
import { markSent, sentHashes } from './pool-sent.js';
|
|
13
|
+
import { markSent, retirePulled, sentHashes } from './pool-sent.js';
|
|
11
14
|
import { readState } from './store.js';
|
|
12
15
|
const record = async (storeDir, result, at) => {
|
|
13
16
|
// A note that cannot be written changes nothing about the night.
|
|
14
17
|
await writeFile(join(storeDir, 'pool-nightly.json'), `${JSON.stringify({ at: new Date(at).toISOString(), ...result })}\n`, 'utf8').catch(() => undefined);
|
|
15
18
|
};
|
|
16
|
-
/**
|
|
19
|
+
/** The covenant version in force when the pool says this account has accepted it. Anything else, a closed pool and a refusal included, is `undefined`. */
|
|
17
20
|
const accepted = async (deps, server, token, via) => {
|
|
18
21
|
const reply = await ask(deps, DEPOSIT, server, '/v1/covenant', { token }, via);
|
|
19
|
-
return reply.status === 200 && isObject(reply.body) &&
|
|
22
|
+
return reply.status === 200 && isObject(reply.body) && Reflect.get(reply.body, 'accepted') === true ? textIn(reply.body, 'version') : undefined;
|
|
20
23
|
};
|
|
21
|
-
|
|
24
|
+
/** The pool's 422 for a value or a format its closed vocabulary does not read (a format older than it reads is the same code, on the field `format`). */
|
|
25
|
+
const isVocabularyRefusal = (error) => error instanceof CliError && error.problem.code === 'closed_vocabulary';
|
|
26
|
+
/** One night's send. `nightlyPoolSend` folds what it throws into a note, so a test reads the thrown error here. */
|
|
27
|
+
export const night = async (deps, net, tally) => {
|
|
22
28
|
const none = (outcome) => ({ outcome, batches: 0, raw: 0 });
|
|
23
29
|
let server;
|
|
24
30
|
try {
|
|
@@ -27,6 +33,9 @@ const night = async (deps, net, tally) => {
|
|
|
27
33
|
catch {
|
|
28
34
|
return none('no-server');
|
|
29
35
|
}
|
|
36
|
+
// A machine's batches wait in the outbox: its Stop builds them for open sessions and allow-listed repositories only, and none of that is checked here, so this sender must never post them.
|
|
37
|
+
if (machineOf(deps.env) !== undefined)
|
|
38
|
+
return none('machine');
|
|
30
39
|
const { storeDir } = runtimeOf(deps);
|
|
31
40
|
let token;
|
|
32
41
|
try {
|
|
@@ -37,32 +46,67 @@ const night = async (deps, net, tally) => {
|
|
|
37
46
|
}
|
|
38
47
|
if (vocabularyExpired(deps) !== undefined)
|
|
39
48
|
return none('failed');
|
|
40
|
-
|
|
49
|
+
const version = await accepted(deps, server, token, net);
|
|
50
|
+
if (version === undefined)
|
|
41
51
|
return none('not-accepted');
|
|
42
52
|
const outbox = outboxFolder(storeDir, server);
|
|
43
53
|
tally.outcome = 'sent';
|
|
44
54
|
// One sender at a time per outbox, the lock `deposit --send` takes: a second run waits, then finds `sent.log` full and sends nothing twice.
|
|
45
55
|
await withSendLock(outbox, async () => {
|
|
46
56
|
// The outbox is built only for an account that has accepted, then sent: the same gates `oars pool deposit` runs, so nothing queues that a person could not print.
|
|
47
|
-
await poolDeposit(deps, { server });
|
|
57
|
+
await poolDeposit(deps, { server, covenant: version });
|
|
48
58
|
const done = await sentHashes(outbox);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
// The current stamp's batches, then those `hold pull` filed under another stamp that outlived a vocabulary or field-list change.
|
|
60
|
+
// A pulled batch can wait as long as the covenant stays closed, so each pass reads it against the term file as it is now: one that holds a term goes, and with no terms none is sent.
|
|
61
|
+
const scan = await rescanPulled(outbox, await currentTerms(deps));
|
|
62
|
+
if (scan.removed.length > 0)
|
|
63
|
+
tally.removed = scan.removed.length;
|
|
64
|
+
let ended;
|
|
65
|
+
try {
|
|
66
|
+
for (const batch of (await heldAcross(outbox)).batches) {
|
|
67
|
+
if (done.has(batch.hash) || (batch.pulled && !scan.open))
|
|
68
|
+
continue;
|
|
69
|
+
// Acceptance is the consent, so nothing was printed first; the file is still read and hashed again just before the request.
|
|
70
|
+
const locked = await lockedBatch(outbox, batch.hash, { requireShown: false, stamp: batch.stamp });
|
|
71
|
+
if (!locked.ok)
|
|
72
|
+
continue;
|
|
73
|
+
try {
|
|
74
|
+
await postBatch(deps, { server, token, bytes: locked.bytes, hash: batch.hash, via: net });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// A pulled batch cannot be rebuilt (its raw is gone), and one the pool no longer reads must not stop the batches after it on every night: it stays, unsent and unmarked, and the note counts it.
|
|
78
|
+
if (batch.pulled && isVocabularyRefusal(error)) {
|
|
79
|
+
tally.refused = (tally.refused ?? 0) + 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
await markSent(outbox, batch.hash, deps.now());
|
|
85
|
+
tally.batches += 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
ended = { error };
|
|
90
|
+
}
|
|
91
|
+
// A pulled batch whose hash is in `sent.log` is done, however the night ended. A `pulled.json` that cannot be read now must not replace the error that ended the loop; after a clean loop it is the night's error.
|
|
92
|
+
try {
|
|
93
|
+
await retirePulled(outbox, 'pool-nightly');
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (ended === undefined)
|
|
97
|
+
throw error;
|
|
59
98
|
}
|
|
99
|
+
if (ended !== undefined)
|
|
100
|
+
throw ended.error;
|
|
60
101
|
// Raw goes only with its own yes, read again here so an answer changed since the build wins.
|
|
61
|
-
|
|
102
|
+
const choices = (await readState(storeDir)).pool;
|
|
103
|
+
if (rawStale(choices, version))
|
|
104
|
+
tally.rawHeld = true;
|
|
105
|
+
if (choices.rawConsent === 'granted' && !rawStale(choices, version)) {
|
|
62
106
|
for (const copy of (await heldRaw(outbox)).copies) {
|
|
63
107
|
if (done.has(copy.hash) || copy.bytes === 0)
|
|
64
108
|
continue;
|
|
65
|
-
await postRaw(deps, { server, token, hash: copy.hash, body: copy.body, bytes: copy.bytes, via: net });
|
|
109
|
+
await postRaw(deps, { server, token, hash: copy.hash, body: copy.body, bytes: copy.bytes, consent: version, via: net });
|
|
66
110
|
await markSent(outbox, copy.hash, deps.now());
|
|
67
111
|
tally.raw += 1;
|
|
68
112
|
}
|
|
@@ -31,7 +31,7 @@ export declare const writeBatch: (outbox: string, key: string, batch: unknown, s
|
|
|
31
31
|
}>;
|
|
32
32
|
export declare const isMissing: (error: unknown) => boolean;
|
|
33
33
|
/** Removes every batch under this stamp whose session is not in `kept`, and returns how many went. A session that fails a gate, leaves the allowlist, or is gone from the store never enters the outbox, so an earlier pass's batch of it cannot stay. */
|
|
34
|
-
export declare const removeBatchesExcept: (outbox: string, kept: ReadonlySet<string
|
|
34
|
+
export declare const removeBatchesExcept: (outbox: string, kept: ReadonlySet<string> | (() => Promise<ReadonlySet<string>>), stamp?: string, only?: string) => Promise<number>;
|
|
35
35
|
/** Removes the batch of one session, if it has one under this stamp, and returns how many files went. */
|
|
36
36
|
export declare const removeBatchOf: (outbox: string, key: string, stamp?: string) => Promise<number>;
|
|
37
37
|
/** The bytes of a batch file, when they still hash to the name the file was written under; undefined when the file is gone or was edited. */
|
|
@@ -39,20 +39,40 @@ export declare const unchangedBytes: (file: string, hash: string) => Promise<str
|
|
|
39
39
|
export interface Held {
|
|
40
40
|
/** The session's key in the outbox (the batch's account-hashed id). */
|
|
41
41
|
key: string;
|
|
42
|
+
/** The stamp folder the batch is filed under. */
|
|
43
|
+
stamp: string;
|
|
44
|
+
/** Whether `oars pool hold pull` admitted it (its key is listed in `pulled.json` at this stamp), as against a batch this machine built. */
|
|
45
|
+
pulled: boolean;
|
|
42
46
|
hash: string;
|
|
43
47
|
file: string;
|
|
44
48
|
bytes: string;
|
|
45
49
|
}
|
|
46
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* The batches filed under this stamp, and how many of their files no longer hash to their names. An edited file is not printed and cannot be sent: run `deposit` again to rebuild it. Under the current stamp that is every batch this client built;
|
|
52
|
+
* under any other stamp it is only a batch `pulled.json` lists there, since anything else in that folder is stale and is never printed or sent.
|
|
53
|
+
*/
|
|
47
54
|
export declare const heldBatches: (outbox: string, stamp?: string) => Promise<{
|
|
48
55
|
batches: Held[];
|
|
49
56
|
edited: number;
|
|
50
57
|
}>;
|
|
58
|
+
/** The stamps a send reads: the current one, then every other stamp `pulled.json` lists. A listed name that is not a stamp folder is not a path this reads. */
|
|
59
|
+
export declare const sendStamps: (outbox: string) => Promise<string[]>;
|
|
60
|
+
/** `heldBatches` over every stamp a send reads, the current one first. */
|
|
61
|
+
export declare const heldAcross: (outbox: string) => Promise<{
|
|
62
|
+
batches: Held[];
|
|
63
|
+
edited: number;
|
|
64
|
+
}>;
|
|
65
|
+
/** The held batch of one session under this stamp, read whole only when its file still hashes to its name; undefined when the session has none, or its file was edited. */
|
|
66
|
+
export declare const heldBatchOf: (outbox: string, key: string, stamp?: string) => Promise<Omit<Held, "stamp" | "pulled"> | undefined>;
|
|
67
|
+
/** The batch keys a machine's Stop built, one per line. A person's `oars pool deposit` shares the outbox and cannot recompute them (the machine's name is in the key), so its sweep keeps these instead of treating them as batches of sessions the store lost. */
|
|
68
|
+
export declare const machineKeys: (outbox: string) => Promise<Set<string>>;
|
|
69
|
+
/** Notes that a machine built this key; a key already noted adds no line. */
|
|
70
|
+
export declare const noteMachineKey: (outbox: string, key: string) => Promise<void>;
|
|
51
71
|
/** Records that this hash was printed, one line for each print: the lock below sends only a hash that appears here. */
|
|
52
72
|
export declare const markShown: (outbox: string, hash: string, at: number) => Promise<void>;
|
|
53
73
|
export type LockRefusal = 'batch_unknown' | 'batch_not_shown' | 'batch_changed';
|
|
54
74
|
/**
|
|
55
|
-
* The lock on a send: a batch goes only if it is in the outbox, its hash was printed, and its file still hashes to that hash. The first check that fails names why. The nightly
|
|
75
|
+
* The lock on a send: a batch goes only if it is in the outbox, its hash was printed, and its file still hashes to that hash. The first check that fails names why. The detached sender (hook-bin.js nightly) sends with `requireShown: false`,
|
|
56
76
|
* because covenant acceptance is its consent and it prints nothing; the other two checks still hold.
|
|
57
77
|
*/
|
|
58
78
|
export declare const lockedBatch: (outbox: string, hash: string, options?: {
|
|
@@ -66,11 +86,37 @@ export declare const lockedBatch: (outbox: string, hash: string, options?: {
|
|
|
66
86
|
ok: false;
|
|
67
87
|
code: LockRefusal;
|
|
68
88
|
}>;
|
|
69
|
-
/**
|
|
89
|
+
/** The batches `oars pool hold pull` admitted and has not seen sent: each `id` (the outbox key) and the stamp folder it is filed under. */
|
|
90
|
+
export interface Pulled {
|
|
91
|
+
[id: string]: string;
|
|
92
|
+
}
|
|
93
|
+
/** `pulled.json` as it stands; none when the file is missing. A file that is not a list of ids and stamps fails the command that reads it, so nothing is pruned or sent on a guess. */
|
|
94
|
+
export declare const readPulled: (outbox: string, step: string) => Promise<Pulled>;
|
|
95
|
+
/** Staged and renamed, so a crash leaves the whole old file or the whole new one. */
|
|
96
|
+
export declare const writePulled: (outbox: string, pulled: Pulled) => Promise<void>;
|
|
97
|
+
/** Whether any string or key in `value` holds one of `terms`, compared lowercased the way the local gate compares a transcript. */
|
|
98
|
+
export declare const holdsTerm: (value: unknown, terms: readonly string[]) => boolean;
|
|
99
|
+
/**
|
|
100
|
+
* The exclusion rescan of a send pass: every pulled batch is read against the trusted machine's current term file, and one that now holds a term is removed, with its `pulled.json` entry, and its hash returned. A batch can wait as long as the covenant
|
|
101
|
+
* stays closed, so the terms it was admitted under may not be the terms now. With no terms (a missing or empty file) nothing can be checked: `open` is false, nothing is removed, and the caller sends no pulled batch.
|
|
102
|
+
*/
|
|
103
|
+
export declare const rescanPulled: (outbox: string, terms: readonly string[]) => Promise<{
|
|
104
|
+
open: boolean;
|
|
105
|
+
removed: string[];
|
|
106
|
+
}>;
|
|
107
|
+
/** The keys `pulled.json` lists at this stamp: the batches of that folder that no local build made and no stale pass may touch. */
|
|
108
|
+
export declare const pulledAt: (outbox: string, stamp?: string) => Promise<Set<string>>;
|
|
109
|
+
/** `lockedBatch` over every stamp a send reads: the first stamp that holds the hash answers, whether it locks or refuses. */
|
|
110
|
+
export declare const lockedAcross: (outbox: string, hash: string, options?: {
|
|
111
|
+
requireShown?: boolean;
|
|
112
|
+
}) => ReturnType<typeof lockedBatch>;
|
|
113
|
+
/** The name of a stamp folder: a vocabulary version, then eight hex digits of the field list and format. No dot or slash, so a name that matches is one path segment. */
|
|
114
|
+
export declare const STAMP_FOLDER: RegExp;
|
|
115
|
+
/** Batches in one server's outbox that were built under another vocabulary, field list, or skeleton format: never printed, never sent. A pulled batch is not one of them, at the stamp it is listed under. */
|
|
70
116
|
export declare const staleBatches: (outbox: string, stamp?: string) => Promise<number>;
|
|
71
117
|
/** Every server's stale batches, for `verify`. */
|
|
72
118
|
export declare const staleBatchesAnywhere: (storeDir: string, stamp?: string) => Promise<number>;
|
|
73
|
-
/** Removes one server's stale batches, and returns how many there were. */
|
|
119
|
+
/** Removes one server's stale batches, and returns how many there were. A folder that holds a pulled batch loses only the files of the others; one that holds none goes whole. */
|
|
74
120
|
export declare const discardStale: (outbox: string, stamp?: string) => Promise<number>;
|
|
75
121
|
/** Every field name in a batch, at any depth, each once and sorted. */
|
|
76
122
|
export declare const fieldsOf: (batchBytes: string) => string[];
|