@wirecat/cli-core 0.590.0 → 0.592.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/dist/background/deliver.d.ts +58 -0
- package/dist/background/deliver.js +125 -0
- package/dist/background/schedules.d.ts +20 -1
- package/dist/background/schedules.js +48 -5
- package/dist/background/system.d.ts +9 -2
- package/dist/background/system.js +10 -3
- package/dist/calendar/mcp/index.d.ts +12 -12
- package/dist/cli/messenger/flooded.d.ts +1 -1
- package/dist/cli/messenger/flooded.js +3 -26
- package/dist/cli/messenger/search-command.d.ts +3 -2
- package/dist/cli/messenger/search-command.js +7 -5
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/schedule/schedule-command.js +35 -12
- package/dist/crm/cli/index.d.ts +57 -0
- package/dist/crm/cli/index.js +300 -0
- package/dist/crm/cli/search-all.d.ts +12 -0
- package/dist/crm/cli/search-all.js +63 -0
- package/dist/crm/index.d.ts +2 -0
- package/dist/crm/index.js +1 -0
- package/dist/crm/source.d.ts +43 -0
- package/dist/crm/source.js +1 -0
- package/dist/crm/sync.d.ts +121 -0
- package/dist/crm/sync.js +291 -0
- package/dist/crm/testing/index.d.ts +3 -0
- package/dist/crm/testing/index.js +3 -0
- package/dist/crm/testing/source-contract.d.ts +17 -0
- package/dist/crm/testing/source-contract.js +137 -0
- package/dist/crm/testing/source.d.ts +33 -0
- package/dist/crm/testing/source.js +126 -0
- package/dist/crm/testing/sync-contract.d.ts +13 -0
- package/dist/crm/testing/sync-contract.js +178 -0
- package/dist/domain/crm.d.ts +82 -0
- package/dist/domain/crm.js +3 -0
- package/dist/entities/deal.d.ts +159 -0
- package/dist/entities/deal.js +86 -0
- package/dist/entities/index.d.ts +150 -0
- package/dist/entities/index.js +5 -0
- package/dist/entities/list-results.d.ts +13 -0
- package/dist/entities/list-results.js +17 -1
- package/dist/errors/reasons.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sends/index.d.ts +1 -1
- package/dist/sends/index.js +1 -1
- package/dist/sends/pace.d.ts +18 -0
- package/dist/sends/pace.js +40 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/store/index.d.ts +2 -1
- package/dist/store/index.js +1 -0
- package/dist/store/sqlite/crm.d.ts +48 -0
- package/dist/store/sqlite/crm.js +318 -0
- package/dist/store/sqlite/deals.d.ts +10 -0
- package/dist/store/sqlite/deals.js +33 -15
- package/dist/store/sqlite/email-activity.d.ts +3 -0
- package/dist/store/sqlite/email-activity.js +11 -0
- package/dist/store/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/package.json +13 -1
- package/parity.json +29 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,77 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.592.0 — 06.11.2026
|
|
11
|
+
|
|
12
|
+
## 0.591.0 — 06.11.2026
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`patiently` and `waitForTurn` are public.** `patiently` (`@wirecat/cli-core/services`) sits out a provider's
|
|
17
|
+
short "wait N seconds" a few times and ends the run on a long one. `waitForTurn(pacer, { name, operation, warn,
|
|
18
|
+
sleep })` (`@wirecat/cli-core/sends`) waits for a call's turn in the profile's shared pace, warns on a wait a person
|
|
19
|
+
would notice and refuses one longer than five minutes as `rate_limited`; the messenger layer uses the same one.
|
|
20
|
+
- **`store.crm` writes a CRM account's copies.** `savePipelines` and `saveItems` upsert pipelines, stages, deals and
|
|
21
|
+
leads by the CRM's ids, with no review. A pipeline is keyed by its account, kind and id. An item's status is the
|
|
22
|
+
CRM's own when it gives one (Pipedrive keeps won and lost on the deal), else its stage's outcome. Money keeps its
|
|
23
|
+
minor units, the people on an item are `links` with their role, and the stage history counts from the CRM's own
|
|
24
|
+
times. `savePeople` stores contacts as identities through `savePeople`, joined to a person by exact email only,
|
|
25
|
+
their ids kept apart per account and type (`crmIdentityId`, `<account>/contact/<id>`; `<account>/user/<id>` is
|
|
26
|
+
kept for the CRM's users). `saveOrganizations` keeps each company as a source record whose organization is made
|
|
27
|
+
for it on first sight, never one found by name (WC-DATA-6). `remove` and `removeUnlisted` mark rows deleted
|
|
28
|
+
(WC-DATA-7), never one the CRM changed after the list began. An item whose stage is not stored, or whose money the
|
|
29
|
+
store refuses, is skipped and named. Neutral types (`CrmItem`, `CrmPipeline`, `CrmPerson`, `CrmOrganization`) are
|
|
30
|
+
in `@wirecat/cli-core`.
|
|
31
|
+
- **`DealFilter.account` and `offset`, `pipelines({ account })`.** A list reads the owner's own (`"own"`) or one CRM
|
|
32
|
+
account's copies, a page at a time.
|
|
33
|
+
- **`schedule agent` and `schedule command` take `--to <route>`, repeatable.** After the run, when `--notify-on`
|
|
34
|
+
says so, the scheduler sends the result through the owner's own CLI: `telegram-bot:<profile>/<chat id or
|
|
35
|
+
user:id>` runs `tg <profile> bot messages send`, `email:<profile>/<address>` runs `mail <profile> messages send`.
|
|
36
|
+
A finished run sends its output (an agent's report, a command's log): to a bot as one message up to 4096
|
|
37
|
+
characters, else as a file with its first lines as the caption; by mail as the body, the title as the subject. A
|
|
38
|
+
failed run sends one line: exit code, reason id, the log's path. The run itself stays read-only; nothing it prints
|
|
39
|
+
can change where the result goes; a run under `<PREFIX>_READ_ONLY` sends nothing. Each route's outcome is in
|
|
40
|
+
`last.json` as `deliveries` (`to`, `ok`, a dotted `reason`, the sending CLI's error code); a failed send never fails
|
|
41
|
+
the run, and `schedule list` shows it. `ServerSystem.run` takes an optional `{ input, timeoutMs }`.
|
|
42
|
+
- **`store.mail.writtenTo(addresses)`.** Of these addresses, the ones the owner wrote to: To or Cc of a live outgoing
|
|
43
|
+
email in any account, in lower case, in one read.
|
|
44
|
+
|
|
45
|
+
- **`syncCrm` and the `CrmSource` port, in `@wirecat/cli-core/crm`.** A CRM CLI writes an adapter (`pipelines(kind)`,
|
|
46
|
+
`changes(kind, cursor, page)` answering a page or `{ reset: true }`, and `ids(kind, page)` when the CRM does not name
|
|
47
|
+
deletions) and the engine copies the account into the store: both kinds' pipelines, then companies, contacts, deals
|
|
48
|
+
and leads, each kind from its own cursor (`store.sync`, `crm:<kind>`), a page at a time, saved as it comes, so a
|
|
49
|
+
run its request budget, deadline or cancel stops goes on from there. A dead cursor starts a full round. An item
|
|
50
|
+
whose stage arrived after the pipelines were read gets them read again once; one still not kept is an issue, and
|
|
51
|
+
the round goes on. Deletions come from `changes()`, or from a list of every id that marks rows deleted only once
|
|
52
|
+
its last page arrived (resumable across runs), never a row changed since the list began, and never most of a kind
|
|
53
|
+
without `--rescan`: a sign-in that lost its rights lists no ids. `crmStatus` gives each kind's progress in the
|
|
54
|
+
shared shape.
|
|
55
|
+
- **`@wirecat/cli-core/crm/testing`:** `crmSourceContract` for a CRM adapter, `crmSyncContract` for the engine over
|
|
56
|
+
a store, and `fakeCrmSource`, a CRM in memory with a change feed.
|
|
57
|
+
- **`addCrmCommands(program, { app, open, leads? })`, in `@wirecat/cli-core/crm/cli`.** One set of commands for
|
|
58
|
+
every CRM CLI: root `sync` (`--rescan`, `--max-pages`, partial and cooperative, a safe next step), `store status`,
|
|
59
|
+
and `deals list|show`, `leads list|show`, `pipelines list|show` (with the funnel) and `stages list`, which read the
|
|
60
|
+
store, as the calendar's lists do, and only the profile's account. Lists take `status:`, `stage:`, `pipeline:` and
|
|
61
|
+
`organization:` (`DEALS_QUERY`, `LEADS_QUERY`), `--limit` and `--page`; what the CRM's users typed is marked
|
|
62
|
+
untrusted. `search all` reads the account's deals and leads beside the stored messages, notes and events, and
|
|
63
|
+
takes `--only crm`.
|
|
64
|
+
- **Shared answer entities `deal`, `lead`, `pipeline` and `stage`.** memo's `deals`, `leads` and `pipelines` and every
|
|
65
|
+
CRM CLI answer them in one shape: a deal or lead as the store gives it, a pipeline with its stages and, from
|
|
66
|
+
`pipelines show`, its funnel.
|
|
67
|
+
- **`schedule agent` and `schedule command` take `--notify-on always|failures|never`.** The value is kept as `notify` in
|
|
68
|
+
the schedule file and shown by `schedule list`. A schedule without it keeps working: an agent that repeats with
|
|
69
|
+
`--every` notifies on failures only, any other schedule as before. A schedule's `last.skipped` carries a dotted
|
|
70
|
+
`reason` next to `why`: `cost.day-cap`, `mail.none-new`, `mail.below-threshold`; `schedule run` answers with the same
|
|
71
|
+
`reason` when it skips.
|
|
72
|
+
|
|
73
|
+
### Changed — may break callers
|
|
74
|
+
|
|
75
|
+
- **`answerEntity` refuses `deal`, `lead`, `pipeline` and `stage`**, now shared entities: a CLI that declared its own
|
|
76
|
+
under those names answers the shared shape instead.
|
|
77
|
+
- **`store.deals.add`, `move` and `close` leave a CRM's copies alone.** Moving or closing a copy is refused
|
|
78
|
+
(`deal.copy`): the CRM is where it changes. A stage or a pipeline named in `add` is looked up among the owner's own
|
|
79
|
+
pipelines only, so a synced CRM stage never makes it ambiguous.
|
|
80
|
+
|
|
10
81
|
## 0.590.0 — 06.11.2026
|
|
11
82
|
|
|
12
83
|
### Changed — may break callers
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ServerSystem } from "./system.js";
|
|
2
|
+
/**
|
|
3
|
+
* Where a schedule's result goes after the run, by the owner's own CLI: a Telegram bot's message (`tg <profile> bot
|
|
4
|
+
* messages send`) or an email (`mail <profile> messages send`). Only the schedule file names it, so nothing the run
|
|
5
|
+
* printed can move it; the run itself never sends.
|
|
6
|
+
*/
|
|
7
|
+
export type Route = {
|
|
8
|
+
kind: "telegram-bot";
|
|
9
|
+
profile: string;
|
|
10
|
+
chat: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "email";
|
|
13
|
+
profile: string;
|
|
14
|
+
address: string;
|
|
15
|
+
};
|
|
16
|
+
export declare const ROUTE_HELP = "telegram-bot:<profile>/<chat id or user:id>, or email:<profile>/<address>";
|
|
17
|
+
export declare const parseRoute: (text: string) => Route;
|
|
18
|
+
export declare const routeText: (route: Route) => string;
|
|
19
|
+
export declare const DELIVERY_REASONS: {
|
|
20
|
+
readonly readOnly: "delivery.read-only";
|
|
21
|
+
readonly programMissing: "delivery.program-missing";
|
|
22
|
+
readonly routeInvalid: "delivery.route-invalid";
|
|
23
|
+
readonly timeout: "delivery.timeout";
|
|
24
|
+
readonly failed: "delivery.failed";
|
|
25
|
+
};
|
|
26
|
+
export type DeliveryReason = (typeof DELIVERY_REASONS)[keyof typeof DELIVERY_REASONS];
|
|
27
|
+
/** One route's outcome, kept in `last.json`. `error` is the sending CLI's error code and reason id, never its message. */
|
|
28
|
+
export interface Delivery {
|
|
29
|
+
to: string;
|
|
30
|
+
ok: boolean;
|
|
31
|
+
reason?: DeliveryReason;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Telegram's limits, in UTF-16 units as JavaScript counts them: a message's text, and a file's caption. */
|
|
35
|
+
export declare const TELEGRAM_TEXT_CHARS = 4096;
|
|
36
|
+
export declare const CAPTION_CHARS = 1024;
|
|
37
|
+
/** The caption: the title, then the report's first lines as far as they fit, cut where a line ends. */
|
|
38
|
+
export declare const headOf: (title: string, report: string, limit?: number) => string;
|
|
39
|
+
export interface Parcel {
|
|
40
|
+
title: string;
|
|
41
|
+
/** A finished run: its output file, and the name the copy sent along has. */
|
|
42
|
+
file?: {
|
|
43
|
+
path: string;
|
|
44
|
+
name: string;
|
|
45
|
+
};
|
|
46
|
+
/** A failed run: one line, no file. */
|
|
47
|
+
line?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Hands the parcel to each route's CLI, one after another; a route that fails is recorded and the next one still goes. */
|
|
50
|
+
export declare const deliver: (system: ServerSystem, env: NodeJS.ProcessEnv, routes: readonly string[], parcel: Parcel, { find, errorOf, locked, timeoutMs, }: {
|
|
51
|
+
/** Where a program is on the run's PATH. */
|
|
52
|
+
find: (name: string, env: NodeJS.ProcessEnv) => string | undefined;
|
|
53
|
+
/** The one line kept of a failed send's stderr. */
|
|
54
|
+
errorOf: (stderr: string, code: number) => string;
|
|
55
|
+
/** The lock the scheduler itself runs under: nothing goes out from a locked run. */
|
|
56
|
+
locked?: string;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
}) => Promise<Delivery[]>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { copyFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CliError } from "@wirecat/cli-base";
|
|
5
|
+
import { TIMED_OUT } from "./system.js";
|
|
6
|
+
const PROGRAMS = {
|
|
7
|
+
"telegram-bot": { command: "tg", prefix: "TG" },
|
|
8
|
+
email: { command: "mail", prefix: "MAIL" },
|
|
9
|
+
};
|
|
10
|
+
// A chat by its id only: a title can change, or name a second chat, and the report would go there.
|
|
11
|
+
const CHAT = /^(-?\d+|user:\d+)$/;
|
|
12
|
+
// The profile-name rule of src/cli/profile.ts, which this layer may not import.
|
|
13
|
+
const PROFILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
14
|
+
const ADDRESS = /^[^\s@/,<>"]+@[^\s@/,<>"]+\.[^\s@/,<>"]+$/;
|
|
15
|
+
export const ROUTE_HELP = "telegram-bot:<profile>/<chat id or user:id>, or email:<profile>/<address>";
|
|
16
|
+
export const parseRoute = (text) => {
|
|
17
|
+
const [, kind = "", profile = "", target = ""] = /^([a-z-]+):([^/]+)\/(.+)$/.exec(text.trim()) ?? [];
|
|
18
|
+
const refuse = (why) => new CliError("validation_error", `--to ${text}: ${why} — ${ROUTE_HELP}`);
|
|
19
|
+
if (!(kind in PROGRAMS))
|
|
20
|
+
throw refuse(kind ? `${kind} is not a route` : "a route is <kind>:<profile>/<where>");
|
|
21
|
+
if (!PROFILE.test(profile))
|
|
22
|
+
throw refuse(`"${profile}" is not a profile name`);
|
|
23
|
+
if (kind === "telegram-bot") {
|
|
24
|
+
if (!CHAT.test(target))
|
|
25
|
+
throw refuse("a bot writes to a chat id or user:<id>, never a title");
|
|
26
|
+
return { kind, profile, chat: target };
|
|
27
|
+
}
|
|
28
|
+
if (!ADDRESS.test(target))
|
|
29
|
+
throw refuse(`"${target}" is not one email address`);
|
|
30
|
+
return { kind: "email", profile, address: target };
|
|
31
|
+
};
|
|
32
|
+
export const routeText = (route) => `${route.kind}:${route.profile}/${route.kind === "telegram-bot" ? route.chat : route.address}`;
|
|
33
|
+
export const DELIVERY_REASONS = {
|
|
34
|
+
readOnly: "delivery.read-only",
|
|
35
|
+
programMissing: "delivery.program-missing",
|
|
36
|
+
routeInvalid: "delivery.route-invalid",
|
|
37
|
+
timeout: "delivery.timeout",
|
|
38
|
+
failed: "delivery.failed",
|
|
39
|
+
};
|
|
40
|
+
/** Telegram's limits, in UTF-16 units as JavaScript counts them: a message's text, and a file's caption. */
|
|
41
|
+
export const TELEGRAM_TEXT_CHARS = 4096;
|
|
42
|
+
export const CAPTION_CHARS = 1024;
|
|
43
|
+
const DELIVERY_TIMEOUT_MS = 120_000;
|
|
44
|
+
/** The caption: the title, then the report's first lines as far as they fit, cut where a line ends. */
|
|
45
|
+
export const headOf = (title, report, limit = CAPTION_CHARS) => {
|
|
46
|
+
const more = "\n… the whole report is in the file";
|
|
47
|
+
const room = limit - title.length - 1;
|
|
48
|
+
const text = report.trim();
|
|
49
|
+
if (text.length <= room)
|
|
50
|
+
return text ? `${title}\n${text}` : title;
|
|
51
|
+
const cut = text.slice(0, room - more.length);
|
|
52
|
+
const line = cut.lastIndexOf("\n");
|
|
53
|
+
return `${title}\n${line > room / 2 ? cut.slice(0, line) : cut}${more}`;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The words that send along `route`. A bot sends a report that fits one message as its text, and a longer one as a
|
|
57
|
+
* file with its head as the caption; mail takes the text on stdin. The text goes after `--`, so a report that starts
|
|
58
|
+
* with a dash stays text.
|
|
59
|
+
*/
|
|
60
|
+
const argvOf = (program, route, title, report, copy) => {
|
|
61
|
+
const lead = [program, route.profile, "--json", "--no-input"];
|
|
62
|
+
if (route.kind === "email")
|
|
63
|
+
return [...lead, "messages", "send", "--to", route.address, "--subject", title];
|
|
64
|
+
const text = copy ? headOf(title, report) : report.trim() ? `${title}\n${report.trim()}` : title;
|
|
65
|
+
return [...lead, "bot", "messages", "send", ...(copy ? ["--file", copy] : []), "--", route.chat, text];
|
|
66
|
+
};
|
|
67
|
+
/** Hands the parcel to each route's CLI, one after another; a route that fails is recorded and the next one still goes. */
|
|
68
|
+
export const deliver = async (system, env, routes, parcel, { find, errorOf, locked, timeoutMs = DELIVERY_TIMEOUT_MS, }) => {
|
|
69
|
+
const failed = (to, reason, error) => ({
|
|
70
|
+
to,
|
|
71
|
+
ok: false,
|
|
72
|
+
reason,
|
|
73
|
+
...(error ? { error } : {}),
|
|
74
|
+
});
|
|
75
|
+
const report = parcel.line ?? (parcel.file ? readFileSync(parcel.file.path, "utf8") : "");
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const to of routes) {
|
|
78
|
+
let route;
|
|
79
|
+
try {
|
|
80
|
+
route = parseRoute(to);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
out.push(failed(to, DELIVERY_REASONS.routeInvalid));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const { command, prefix } = PROGRAMS[route.kind];
|
|
87
|
+
const lock = locked ?? [`${prefix}_READ_ONLY`].find((name) => env[name] && env[name] !== "0");
|
|
88
|
+
if (lock) {
|
|
89
|
+
out.push(failed(to, DELIVERY_REASONS.readOnly, lock));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const program = find(command, env);
|
|
93
|
+
if (!program) {
|
|
94
|
+
out.push(failed(to, DELIVERY_REASONS.programMissing, command));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const tooLong = `${parcel.title}\n${report.trim()}`.length > TELEGRAM_TEXT_CHARS;
|
|
98
|
+
// The sending CLI refuses its own and the other WireCat folders, where the report lives, so it gets a copy.
|
|
99
|
+
const folder = route.kind === "telegram-bot" && parcel.file && tooLong ? mkdtempSync(join(tmpdir(), "wirecat-deliver-")) : "";
|
|
100
|
+
const copy = folder && parcel.file ? join(folder, parcel.file.name) : undefined;
|
|
101
|
+
try {
|
|
102
|
+
if (copy && parcel.file)
|
|
103
|
+
copyFileSync(parcel.file.path, copy);
|
|
104
|
+
const { [`${prefix}_PROFILE`]: _, ...childEnv } = env;
|
|
105
|
+
const ran = await system.run(argvOf(program, route, parcel.title, report, copy), childEnv, {
|
|
106
|
+
timeoutMs,
|
|
107
|
+
...(route.kind === "email" ? { input: report.trim() ? report : parcel.title } : {}),
|
|
108
|
+
});
|
|
109
|
+
if (ran.code === 0)
|
|
110
|
+
out.push({ to, ok: true });
|
|
111
|
+
else if (ran.code === TIMED_OUT)
|
|
112
|
+
out.push(failed(to, DELIVERY_REASONS.timeout));
|
|
113
|
+
else
|
|
114
|
+
out.push(failed(to, DELIVERY_REASONS.failed, errorOf(ran.stderr, ran.code)));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
out.push(failed(to, DELIVERY_REASONS.failed));
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
if (folder)
|
|
121
|
+
rmSync(folder, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AppIdentity } from "../ports/app.js";
|
|
2
|
+
import { type Delivery } from "./deliver.js";
|
|
2
3
|
import type { ServerSystem } from "./system.js";
|
|
3
4
|
/** Minutes between runs, a daily local time, or one local moment. */
|
|
4
5
|
export type When = {
|
|
@@ -24,7 +25,15 @@ export interface Schedule {
|
|
|
24
25
|
mailBatch?: number;
|
|
25
26
|
/** An agent's: no run starts for the rest of the local day once its runs cost this much, in US dollars. */
|
|
26
27
|
maxCostPerDay?: number;
|
|
28
|
+
/** When a finished run shows a desktop notice and goes along `to`; absent means `notifyOf`'s default. */
|
|
29
|
+
notify?: Notify;
|
|
30
|
+
/** Delivery routes (`telegram-bot:<profile>/<chat>`, `email:<profile>/<address>`) the result goes along after the run. */
|
|
31
|
+
to?: string[];
|
|
27
32
|
}
|
|
33
|
+
export declare const NOTIFY_WHEN: readonly ["always", "failures", "never"];
|
|
34
|
+
export type Notify = (typeof NOTIFY_WHEN)[number];
|
|
35
|
+
/** A repeating agent would otherwise notify on every tick; any other schedule runs rarely enough to say each result. */
|
|
36
|
+
export declare const notifyOf: (schedule: Pick<Schedule, "kind" | "when" | "notify">) => Notify;
|
|
28
37
|
/** What a gated agent waits for, and what a day of its runs may cost, when neither its flags nor the config say. */
|
|
29
38
|
export declare const SCHEDULE_DEFAULTS: {
|
|
30
39
|
readonly mailWaitMinutes: 30;
|
|
@@ -63,8 +72,18 @@ export interface LastRun {
|
|
|
63
72
|
skipped?: {
|
|
64
73
|
at: string;
|
|
65
74
|
why: string;
|
|
75
|
+
reason?: SkipReason;
|
|
66
76
|
};
|
|
77
|
+
/** How the result went along each of the schedule's routes; a failed delivery never fails the run. */
|
|
78
|
+
deliveries?: Delivery[];
|
|
67
79
|
}
|
|
80
|
+
/** Why a timer left a run out, as an id a translation can key on; `why` stays the English sentence. */
|
|
81
|
+
export declare const SKIP_REASONS: {
|
|
82
|
+
readonly costDayCap: "cost.day-cap";
|
|
83
|
+
readonly mailNone: "mail.none-new";
|
|
84
|
+
readonly mailBelowThreshold: "mail.below-threshold";
|
|
85
|
+
};
|
|
86
|
+
export type SkipReason = (typeof SKIP_REASONS)[keyof typeof SKIP_REASONS];
|
|
68
87
|
/**
|
|
69
88
|
* Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
|
|
70
89
|
* message, which can name a chat or a person; the run's log keeps that. Any other line is another program's
|
|
@@ -90,7 +109,7 @@ export declare const recordRun: (path: string, run: {
|
|
|
90
109
|
costUsd?: number;
|
|
91
110
|
}) => LastRun;
|
|
92
111
|
/** Notes on the last run that the timer left this one out, keeping that run's result. */
|
|
93
|
-
export declare const recordSkip: (file: string, why: string, at?: Date) => void;
|
|
112
|
+
export declare const recordSkip: (file: string, why: string, reason: SkipReason, at?: Date) => void;
|
|
94
113
|
/**
|
|
95
114
|
* One stderr line per failing background job, or nothing: `tg: scheduled digest failing since … (3 runs):
|
|
96
115
|
* <reason> — tg schedule list`. A status the owner has to ask for is how a broken timer goes unnoticed.
|
|
@@ -5,6 +5,10 @@ import { CliError, errorCodes, writeSecurely } from "@wirecat/cli-base";
|
|
|
5
5
|
import { mailHolderVariable, readOnlyLockOf } from "../ports/app.js";
|
|
6
6
|
import { recordLogFor } from "../runs/record-log.js";
|
|
7
7
|
import { childTrace } from "../runs/scope.js";
|
|
8
|
+
import { deliver } from "./deliver.js";
|
|
9
|
+
export const NOTIFY_WHEN = ["always", "failures", "never"];
|
|
10
|
+
/** A repeating agent would otherwise notify on every tick; any other schedule runs rarely enough to say each result. */
|
|
11
|
+
export const notifyOf = (schedule) => schedule.notify ?? (schedule.kind === "agent" && "every" in schedule.when ? "failures" : "always");
|
|
8
12
|
/** What a gated agent waits for, and what a day of its runs may cost, when neither its flags nor the config say. */
|
|
9
13
|
export const SCHEDULE_DEFAULTS = { mailWaitMinutes: 30, mailBatch: 10, maxCostPerDay: 5 };
|
|
10
14
|
export const localDay = (at) => `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")}`;
|
|
@@ -49,6 +53,12 @@ const reportOf = (stdout) => {
|
|
|
49
53
|
return { text: stdout };
|
|
50
54
|
}
|
|
51
55
|
};
|
|
56
|
+
/** Why a timer left a run out, as an id a translation can key on; `why` stays the English sentence. */
|
|
57
|
+
export const SKIP_REASONS = {
|
|
58
|
+
costDayCap: "cost.day-cap",
|
|
59
|
+
mailNone: "mail.none-new",
|
|
60
|
+
mailBelowThreshold: "mail.below-threshold",
|
|
61
|
+
};
|
|
52
62
|
const REASON_ID = /^[a-z0-9][a-z0-9.-]{0,79}$/;
|
|
53
63
|
/**
|
|
54
64
|
* Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
|
|
@@ -96,13 +106,19 @@ export const recordRun = (path, run) => {
|
|
|
96
106
|
writeSecurely(path, `${JSON.stringify(last, null, 2)}\n`, 0o600);
|
|
97
107
|
return last;
|
|
98
108
|
};
|
|
109
|
+
const recordDeliveries = (path, last, deliveries) => {
|
|
110
|
+
const delivered = { ...last, deliveries };
|
|
111
|
+
writeSecurely(path, `${JSON.stringify(delivered, null, 2)}\n`, 0o600);
|
|
112
|
+
return delivered;
|
|
113
|
+
};
|
|
99
114
|
/** Notes on the last run that the timer left this one out, keeping that run's result. */
|
|
100
|
-
export const recordSkip = (file, why, at = new Date()) => {
|
|
115
|
+
export const recordSkip = (file, why, reason, at = new Date()) => {
|
|
101
116
|
const path = join(file, "..", "last.json");
|
|
102
117
|
const before = readRun(path);
|
|
103
|
-
|
|
118
|
+
// A timer ticking every few minutes would rewrite the file for the same cause each time.
|
|
119
|
+
if (!before || before.skipped?.reason === reason)
|
|
104
120
|
return;
|
|
105
|
-
writeSecurely(path, `${JSON.stringify({ ...before, skipped: { at: at.toISOString(), why } }, null, 2)}\n`, 0o600);
|
|
121
|
+
writeSecurely(path, `${JSON.stringify({ ...before, skipped: { at: at.toISOString(), why, reason } }, null, 2)}\n`, 0o600);
|
|
106
122
|
};
|
|
107
123
|
const shortTime = (iso) => {
|
|
108
124
|
const at = new Date(iso);
|
|
@@ -435,15 +451,42 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
|
|
|
435
451
|
if (!kept.has(stampOf(file)))
|
|
436
452
|
rmSync(join(runs, file), { force: true });
|
|
437
453
|
const title = `${app.command} ${schedule.name}`;
|
|
438
|
-
|
|
454
|
+
const mode = notifyOf(schedule);
|
|
455
|
+
if (agent && exitCode === 0 && mode === "always") {
|
|
439
456
|
const first = readFileSync(output, "utf8")
|
|
440
457
|
.split("\n")
|
|
441
458
|
.find((line) => line.trim()) ?? "report ready";
|
|
442
459
|
await notify(system, env, title, `${first.trim().slice(0, 180)}\n${join(folder, "latest.md")}`);
|
|
443
460
|
}
|
|
444
|
-
else if (exitCode !== 0) {
|
|
461
|
+
else if (exitCode !== 0 && mode !== "never") {
|
|
445
462
|
await notify(system, env, title, `failed (exit ${exitCode}) — ${errors}`);
|
|
446
463
|
}
|
|
464
|
+
const routes = schedule.to ?? [];
|
|
465
|
+
if (routes.length > 0 && (exitCode === 0 ? mode === "always" : mode !== "never")) {
|
|
466
|
+
const at = shortTime(last.at);
|
|
467
|
+
const runEnv = { ...env, ...schedule.environment };
|
|
468
|
+
const locked = readOnlyLockOf(app, runEnv);
|
|
469
|
+
const deliveries = await deliver(system, runEnv, routes, exitCode === 0
|
|
470
|
+
? {
|
|
471
|
+
title: `${title} · ${at}`,
|
|
472
|
+
file: { path: output, name: `${schedule.name}-${at.replace(/[ :]/g, "-")}.${agent ? "md" : "txt"}` },
|
|
473
|
+
}
|
|
474
|
+
: {
|
|
475
|
+
title: `${title} failed · ${at}`,
|
|
476
|
+
line: `${title} failed at ${at} (exit ${exitCode}` +
|
|
477
|
+
`${last.failures && last.failures > 1 ? `, ${last.failures} runs in a row` : ""})` +
|
|
478
|
+
`${last.reason ? `: ${last.reason}` : ""} — ${errors}`,
|
|
479
|
+
}, {
|
|
480
|
+
find: (name, on) => findProgram(name, on, system.platform),
|
|
481
|
+
errorOf: reasonOf,
|
|
482
|
+
...(locked ? { locked } : {}),
|
|
483
|
+
});
|
|
484
|
+
last = recordDeliveries(join(folder, "last.json"), last, deliveries);
|
|
485
|
+
const lost = deliveries.filter((one) => !one.ok);
|
|
486
|
+
if (lost.length > 0 && mode !== "never") {
|
|
487
|
+
await notify(system, env, title, lost.map((one) => `not delivered to ${one.to}: ${one.reason}`).join("\n"));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
447
490
|
if ("once" in schedule.when) {
|
|
448
491
|
await timer?.remove(schedule.name);
|
|
449
492
|
rmSync(file, { force: true });
|
|
@@ -4,9 +4,16 @@ export interface Ran {
|
|
|
4
4
|
stderr: string;
|
|
5
5
|
}
|
|
6
6
|
/** What `server` asks of the machine. Tests hand one in; nothing else does. */
|
|
7
|
+
/** What a run may hand the program besides its words: text on its stdin, and a time after which it is stopped. */
|
|
8
|
+
export interface RunOptions {
|
|
9
|
+
input?: string;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
}
|
|
12
|
+
/** The exit code a program stopped at `RunOptions.timeoutMs` answers, as `timeout(1)` does. */
|
|
13
|
+
export declare const TIMED_OUT = 124;
|
|
7
14
|
export interface ServerSystem {
|
|
8
15
|
platform: NodeJS.Platform;
|
|
9
|
-
run: (argv: string[], env: NodeJS.ProcessEnv) => Promise<Ran>;
|
|
16
|
+
run: (argv: string[], env: NodeJS.ProcessEnv, options?: RunOptions) => Promise<Ran>;
|
|
10
17
|
/** The command line that starts this CLI: the node binary and the script, both absolute. */
|
|
11
18
|
entry: string[];
|
|
12
19
|
uid: number;
|
|
@@ -15,5 +22,5 @@ export interface ServerSystem {
|
|
|
15
22
|
/** The pause between two looks at whether `serve` is listening yet. */
|
|
16
23
|
pause: () => Promise<void>;
|
|
17
24
|
}
|
|
18
|
-
export declare const runProgram: (argv: string[], env: NodeJS.ProcessEnv) => Promise<Ran>;
|
|
25
|
+
export declare const runProgram: (argv: string[], env: NodeJS.ProcessEnv, { input, timeoutMs }?: RunOptions) => Promise<Ran>;
|
|
19
26
|
export declare const thisMachine: () => ServerSystem;
|
|
@@ -3,15 +3,22 @@ import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
5
|
import { CliError } from "@wirecat/cli-base";
|
|
6
|
-
|
|
6
|
+
/** The exit code a program stopped at `RunOptions.timeoutMs` answers, as `timeout(1)` does. */
|
|
7
|
+
export const TIMED_OUT = 124;
|
|
8
|
+
export const runProgram = (argv, env, { input, timeoutMs } = {}) => new Promise((resolve, reject) => {
|
|
7
9
|
const [file = "", ...args] = argv;
|
|
8
|
-
execFile(file, args, { env, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
10
|
+
const child = execFile(file, args, { env, encoding: "utf8", ...(timeoutMs ? { timeout: timeoutMs } : {}) }, (error, stdout, stderr) => {
|
|
9
11
|
if (error?.code === "ENOENT") {
|
|
10
12
|
reject(new CliError("configuration_error", `${file} was not found — this machine has no ${file} to run serve`));
|
|
11
13
|
return;
|
|
12
14
|
}
|
|
13
|
-
|
|
15
|
+
const code = error?.killed ? TIMED_OUT : typeof error?.code === "number" ? error.code : error ? 1 : 0;
|
|
16
|
+
resolve({ code, stdout, stderr });
|
|
14
17
|
});
|
|
18
|
+
// A program that does not read its stdin may close it first; that is no failure of the run.
|
|
19
|
+
child.stdin?.on("error", () => { });
|
|
20
|
+
if (input !== undefined)
|
|
21
|
+
child.stdin?.end(input);
|
|
15
22
|
});
|
|
16
23
|
export const thisMachine = () => {
|
|
17
24
|
// The path as the package manager's shim gave it, not resolved: pnpm's global link keeps its name across upgrades
|
|
@@ -10,13 +10,13 @@ export declare const calendarTools: ({
|
|
|
10
10
|
inputSchema: {
|
|
11
11
|
type: string;
|
|
12
12
|
properties: {
|
|
13
|
+
limit?: undefined;
|
|
13
14
|
account_id: {
|
|
14
15
|
type: string;
|
|
15
16
|
minimum: number;
|
|
16
17
|
};
|
|
17
18
|
query?: undefined;
|
|
18
19
|
timezone?: undefined;
|
|
19
|
-
limit?: undefined;
|
|
20
20
|
page?: undefined;
|
|
21
21
|
event?: undefined;
|
|
22
22
|
} | {
|
|
@@ -45,9 +45,9 @@ export declare const calendarTools: ({
|
|
|
45
45
|
};
|
|
46
46
|
event?: undefined;
|
|
47
47
|
} | {
|
|
48
|
+
limit?: undefined;
|
|
48
49
|
query?: undefined;
|
|
49
50
|
timezone?: undefined;
|
|
50
|
-
limit?: undefined;
|
|
51
51
|
page?: undefined;
|
|
52
52
|
event: {
|
|
53
53
|
type: string;
|
|
@@ -58,9 +58,9 @@ export declare const calendarTools: ({
|
|
|
58
58
|
minimum: number;
|
|
59
59
|
};
|
|
60
60
|
} | {
|
|
61
|
+
limit?: undefined;
|
|
61
62
|
query?: undefined;
|
|
62
63
|
timezone?: undefined;
|
|
63
|
-
limit?: undefined;
|
|
64
64
|
page?: undefined;
|
|
65
65
|
event?: undefined;
|
|
66
66
|
account_id?: undefined;
|
|
@@ -80,13 +80,13 @@ export declare const calendarTools: ({
|
|
|
80
80
|
inputSchema: {
|
|
81
81
|
type: string;
|
|
82
82
|
properties: {
|
|
83
|
+
limit?: undefined;
|
|
83
84
|
account_id: {
|
|
84
85
|
type: string;
|
|
85
86
|
minimum: number;
|
|
86
87
|
};
|
|
87
88
|
query?: undefined;
|
|
88
89
|
timezone?: undefined;
|
|
89
|
-
limit?: undefined;
|
|
90
90
|
page?: undefined;
|
|
91
91
|
event?: undefined;
|
|
92
92
|
} | {
|
|
@@ -115,9 +115,9 @@ export declare const calendarTools: ({
|
|
|
115
115
|
};
|
|
116
116
|
event?: undefined;
|
|
117
117
|
} | {
|
|
118
|
+
limit?: undefined;
|
|
118
119
|
query?: undefined;
|
|
119
120
|
timezone?: undefined;
|
|
120
|
-
limit?: undefined;
|
|
121
121
|
page?: undefined;
|
|
122
122
|
event: {
|
|
123
123
|
type: string;
|
|
@@ -128,9 +128,9 @@ export declare const calendarTools: ({
|
|
|
128
128
|
minimum: number;
|
|
129
129
|
};
|
|
130
130
|
} | {
|
|
131
|
+
limit?: undefined;
|
|
131
132
|
query?: undefined;
|
|
132
133
|
timezone?: undefined;
|
|
133
|
-
limit?: undefined;
|
|
134
134
|
page?: undefined;
|
|
135
135
|
event?: undefined;
|
|
136
136
|
account_id?: undefined;
|
|
@@ -150,13 +150,13 @@ export declare const calendarTools: ({
|
|
|
150
150
|
inputSchema: {
|
|
151
151
|
type: string;
|
|
152
152
|
properties: {
|
|
153
|
+
limit?: undefined;
|
|
153
154
|
account_id: {
|
|
154
155
|
type: string;
|
|
155
156
|
minimum: number;
|
|
156
157
|
};
|
|
157
158
|
query?: undefined;
|
|
158
159
|
timezone?: undefined;
|
|
159
|
-
limit?: undefined;
|
|
160
160
|
page?: undefined;
|
|
161
161
|
event?: undefined;
|
|
162
162
|
} | {
|
|
@@ -185,9 +185,9 @@ export declare const calendarTools: ({
|
|
|
185
185
|
};
|
|
186
186
|
event?: undefined;
|
|
187
187
|
} | {
|
|
188
|
+
limit?: undefined;
|
|
188
189
|
query?: undefined;
|
|
189
190
|
timezone?: undefined;
|
|
190
|
-
limit?: undefined;
|
|
191
191
|
page?: undefined;
|
|
192
192
|
event: {
|
|
193
193
|
type: string;
|
|
@@ -198,9 +198,9 @@ export declare const calendarTools: ({
|
|
|
198
198
|
minimum: number;
|
|
199
199
|
};
|
|
200
200
|
} | {
|
|
201
|
+
limit?: undefined;
|
|
201
202
|
query?: undefined;
|
|
202
203
|
timezone?: undefined;
|
|
203
|
-
limit?: undefined;
|
|
204
204
|
page?: undefined;
|
|
205
205
|
event?: undefined;
|
|
206
206
|
account_id?: undefined;
|
|
@@ -220,13 +220,13 @@ export declare const calendarTools: ({
|
|
|
220
220
|
inputSchema: {
|
|
221
221
|
type: string;
|
|
222
222
|
properties: {
|
|
223
|
+
limit?: undefined;
|
|
223
224
|
account_id: {
|
|
224
225
|
type: string;
|
|
225
226
|
minimum: number;
|
|
226
227
|
};
|
|
227
228
|
query?: undefined;
|
|
228
229
|
timezone?: undefined;
|
|
229
|
-
limit?: undefined;
|
|
230
230
|
page?: undefined;
|
|
231
231
|
event?: undefined;
|
|
232
232
|
} | {
|
|
@@ -255,9 +255,9 @@ export declare const calendarTools: ({
|
|
|
255
255
|
};
|
|
256
256
|
event?: undefined;
|
|
257
257
|
} | {
|
|
258
|
+
limit?: undefined;
|
|
258
259
|
query?: undefined;
|
|
259
260
|
timezone?: undefined;
|
|
260
|
-
limit?: undefined;
|
|
261
261
|
page?: undefined;
|
|
262
262
|
event: {
|
|
263
263
|
type: string;
|
|
@@ -268,9 +268,9 @@ export declare const calendarTools: ({
|
|
|
268
268
|
minimum: number;
|
|
269
269
|
};
|
|
270
270
|
} | {
|
|
271
|
+
limit?: undefined;
|
|
271
272
|
query?: undefined;
|
|
272
273
|
timezone?: undefined;
|
|
273
|
-
limit?: undefined;
|
|
274
274
|
page?: undefined;
|
|
275
275
|
event?: undefined;
|
|
276
276
|
account_id?: undefined;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type MessengerAdapter } from "../../ports/messenger.js";
|
|
2
2
|
import { type Warn } from "../../runs/events.js";
|
|
3
3
|
import { type FloodMemory } from "../../sends/flood.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type Pacer } from "../../sends/pace.js";
|
|
5
5
|
/**
|
|
6
6
|
* The adapter, refusing a call the messenger already said to hold off on, before it is asked again;
|
|
7
7
|
* remembering each new "wait" it answers; and stopping writes once it says the account may not write.
|
|
@@ -3,14 +3,11 @@ import { isCliFailure } from "../../errors/failures.js";
|
|
|
3
3
|
import { throughWrapper } from "../../ports/messenger.js";
|
|
4
4
|
import { providerErrorKey } from "../../runs/events.js";
|
|
5
5
|
import { holdEnd } from "../../sends/flood.js";
|
|
6
|
+
import { waitForTurn } from "../../sends/pace.js";
|
|
6
7
|
const LONG_LIVED = new Set(["self", "newSendId", "close", "formatMarkdown", "formatHtml", "storedChats"]);
|
|
7
8
|
/** Neither held nor paced, as they listen for as long as the run does; what ends them is still remembered. */
|
|
8
9
|
const LISTENING = new Set(["watch", "feed", "listen", "listenHistory"]);
|
|
9
10
|
const BLOCKING = new Set(["frozen", "limited", "banned"]);
|
|
10
|
-
/** Shorter waits for the pace pass unannounced; a person at a terminal would not notice them. */
|
|
11
|
-
const ANNOUNCE_WAIT_MS = 5_000;
|
|
12
|
-
/** Longer than this the pace refuses instead of waiting, as a run already ends at a wait this long. */
|
|
13
|
-
const LONGEST_PACE_WAIT_MS = 5 * 60_000;
|
|
14
11
|
const chatOf = (args) => typeof args[0] === "string" && /^-?\d+$/.test(args[0]) ? args[0] : undefined;
|
|
15
12
|
/**
|
|
16
13
|
* The adapter, refusing a call the messenger already said to hold off on, before it is asked again;
|
|
@@ -73,28 +70,8 @@ export const flooded = (messenger, memory, { name, warn, pacer }) => {
|
|
|
73
70
|
await kept(operation, () => pacer.holdFor(waitMs));
|
|
74
71
|
};
|
|
75
72
|
const turn = async (operation) => {
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
let at = Date.now();
|
|
79
|
-
await kept(operation, async () => {
|
|
80
|
-
at = await pacer.reserve();
|
|
81
|
-
});
|
|
82
|
-
const waitMs = at - Date.now();
|
|
83
|
-
if (waitMs <= 0)
|
|
84
|
-
return;
|
|
85
|
-
if (waitMs > LONGEST_PACE_WAIT_MS) {
|
|
86
|
-
throw new CliError("rate_limited", `${name} asked this profile to wait, ${Math.ceil(waitMs / 1000)} s are left — nothing was sent to ${name}`, { retry_after_ms: waitMs, retry_at: new Date(at).toISOString(), remembered: true });
|
|
87
|
-
}
|
|
88
|
-
if (waitMs >= ANNOUNCE_WAIT_MS) {
|
|
89
|
-
const seconds = Math.ceil(waitMs / 1000);
|
|
90
|
-
warn(`waiting ${seconds} s to keep this profile's pace with ${name}`, {
|
|
91
|
-
event: "warning",
|
|
92
|
-
code: "pace_wait",
|
|
93
|
-
operation,
|
|
94
|
-
counts: { seconds },
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
73
|
+
if (pacer)
|
|
74
|
+
await waitForTurn(pacer, { name, operation, warn });
|
|
98
75
|
};
|
|
99
76
|
return throughWrapper(messenger, {}, (operation, call) => {
|
|
100
77
|
if (LONG_LIVED.has(operation))
|