@wirecat/cli-core 0.590.0 → 0.591.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 +58 -0
- package/dist/background/schedules.d.ts +15 -1
- package/dist/background/schedules.js +16 -5
- 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 +23 -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 +143 -0
- package/dist/entities/index.js +5 -0
- package/dist/entities/list-results.d.ts +4 -0
- package/dist/entities/list-results.js +8 -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/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/package.json +13 -1
- package/parity.json +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,64 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.591.0 — 06.11.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`patiently` and `waitForTurn` are public.** `patiently` (`@wirecat/cli-core/services`) sits out a provider's
|
|
15
|
+
short "wait N seconds" a few times and ends the run on a long one. `waitForTurn(pacer, { name, operation, warn,
|
|
16
|
+
sleep })` (`@wirecat/cli-core/sends`) waits for a call's turn in the profile's shared pace, warns on a wait a person
|
|
17
|
+
would notice and refuses one longer than five minutes as `rate_limited`; the messenger layer uses the same one.
|
|
18
|
+
- **`store.crm` writes a CRM account's copies.** `savePipelines` and `saveItems` upsert pipelines, stages, deals and
|
|
19
|
+
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
|
|
20
|
+
CRM's own when it gives one (Pipedrive keeps won and lost on the deal), else its stage's outcome. Money keeps its
|
|
21
|
+
minor units, the people on an item are `links` with their role, and the stage history counts from the CRM's own
|
|
22
|
+
times. `savePeople` stores contacts as identities through `savePeople`, joined to a person by exact email only,
|
|
23
|
+
their ids kept apart per account and type (`crmIdentityId`, `<account>/contact/<id>`; `<account>/user/<id>` is
|
|
24
|
+
kept for the CRM's users). `saveOrganizations` keeps each company as a source record whose organization is made
|
|
25
|
+
for it on first sight, never one found by name (WC-DATA-6). `remove` and `removeUnlisted` mark rows deleted
|
|
26
|
+
(WC-DATA-7), never one the CRM changed after the list began. An item whose stage is not stored, or whose money the
|
|
27
|
+
store refuses, is skipped and named. Neutral types (`CrmItem`, `CrmPipeline`, `CrmPerson`, `CrmOrganization`) are
|
|
28
|
+
in `@wirecat/cli-core`.
|
|
29
|
+
- **`DealFilter.account` and `offset`, `pipelines({ account })`.** A list reads the owner's own (`"own"`) or one CRM
|
|
30
|
+
account's copies, a page at a time.
|
|
31
|
+
|
|
32
|
+
- **`syncCrm` and the `CrmSource` port, in `@wirecat/cli-core/crm`.** A CRM CLI writes an adapter (`pipelines(kind)`,
|
|
33
|
+
`changes(kind, cursor, page)` answering a page or `{ reset: true }`, and `ids(kind, page)` when the CRM does not name
|
|
34
|
+
deletions) and the engine copies the account into the store: both kinds' pipelines, then companies, contacts, deals
|
|
35
|
+
and leads, each kind from its own cursor (`store.sync`, `crm:<kind>`), a page at a time, saved as it comes, so a
|
|
36
|
+
run its request budget, deadline or cancel stops goes on from there. A dead cursor starts a full round. An item
|
|
37
|
+
whose stage arrived after the pipelines were read gets them read again once; one still not kept is an issue, and
|
|
38
|
+
the round goes on. Deletions come from `changes()`, or from a list of every id that marks rows deleted only once
|
|
39
|
+
its last page arrived (resumable across runs), never a row changed since the list began, and never most of a kind
|
|
40
|
+
without `--rescan`: a sign-in that lost its rights lists no ids. `crmStatus` gives each kind's progress in the
|
|
41
|
+
shared shape.
|
|
42
|
+
- **`@wirecat/cli-core/crm/testing`:** `crmSourceContract` for a CRM adapter, `crmSyncContract` for the engine over
|
|
43
|
+
a store, and `fakeCrmSource`, a CRM in memory with a change feed.
|
|
44
|
+
- **`addCrmCommands(program, { app, open, leads? })`, in `@wirecat/cli-core/crm/cli`.** One set of commands for
|
|
45
|
+
every CRM CLI: root `sync` (`--rescan`, `--max-pages`, partial and cooperative, a safe next step), `store status`,
|
|
46
|
+
and `deals list|show`, `leads list|show`, `pipelines list|show` (with the funnel) and `stages list`, which read the
|
|
47
|
+
store, as the calendar's lists do, and only the profile's account. Lists take `status:`, `stage:`, `pipeline:` and
|
|
48
|
+
`organization:` (`DEALS_QUERY`, `LEADS_QUERY`), `--limit` and `--page`; what the CRM's users typed is marked
|
|
49
|
+
untrusted. `search all` reads the account's deals and leads beside the stored messages, notes and events, and
|
|
50
|
+
takes `--only crm`.
|
|
51
|
+
- **Shared answer entities `deal`, `lead`, `pipeline` and `stage`.** memo's `deals`, `leads` and `pipelines` and every
|
|
52
|
+
CRM CLI answer them in one shape: a deal or lead as the store gives it, a pipeline with its stages and, from
|
|
53
|
+
`pipelines show`, its funnel.
|
|
54
|
+
- **`schedule agent` and `schedule command` take `--notify-on always|failures|never`.** The value is kept as `notify` in
|
|
55
|
+
the schedule file and shown by `schedule list`. A schedule without it keeps working: an agent that repeats with
|
|
56
|
+
`--every` notifies on failures only, any other schedule as before. A schedule's `last.skipped` carries a dotted
|
|
57
|
+
`reason` next to `why`: `cost.day-cap`, `mail.none-new`, `mail.below-threshold`; `schedule run` answers with the same
|
|
58
|
+
`reason` when it skips.
|
|
59
|
+
|
|
60
|
+
### Changed — may break callers
|
|
61
|
+
|
|
62
|
+
- **`answerEntity` refuses `deal`, `lead`, `pipeline` and `stage`**, now shared entities: a CLI that declared its own
|
|
63
|
+
under those names answers the shared shape instead.
|
|
64
|
+
- **`store.deals.add`, `move` and `close` leave a CRM's copies alone.** Moving or closing a copy is refused
|
|
65
|
+
(`deal.copy`): the CRM is where it changes. A stage or a pipeline named in `add` is looked up among the owner's own
|
|
66
|
+
pipelines only, so a synced CRM stage never makes it ambiguous.
|
|
67
|
+
|
|
10
68
|
## 0.590.0 — 06.11.2026
|
|
11
69
|
|
|
12
70
|
### Changed — may break callers
|
|
@@ -24,7 +24,13 @@ export interface Schedule {
|
|
|
24
24
|
mailBatch?: number;
|
|
25
25
|
/** An agent's: no run starts for the rest of the local day once its runs cost this much, in US dollars. */
|
|
26
26
|
maxCostPerDay?: number;
|
|
27
|
+
/** When a finished run shows a desktop notice; absent means `notifyOf`'s default. */
|
|
28
|
+
notify?: Notify;
|
|
27
29
|
}
|
|
30
|
+
export declare const NOTIFY_WHEN: readonly ["always", "failures", "never"];
|
|
31
|
+
export type Notify = (typeof NOTIFY_WHEN)[number];
|
|
32
|
+
/** A repeating agent would otherwise notify on every tick; any other schedule runs rarely enough to say each result. */
|
|
33
|
+
export declare const notifyOf: (schedule: Pick<Schedule, "kind" | "when" | "notify">) => Notify;
|
|
28
34
|
/** What a gated agent waits for, and what a day of its runs may cost, when neither its flags nor the config say. */
|
|
29
35
|
export declare const SCHEDULE_DEFAULTS: {
|
|
30
36
|
readonly mailWaitMinutes: 30;
|
|
@@ -63,8 +69,16 @@ export interface LastRun {
|
|
|
63
69
|
skipped?: {
|
|
64
70
|
at: string;
|
|
65
71
|
why: string;
|
|
72
|
+
reason?: SkipReason;
|
|
66
73
|
};
|
|
67
74
|
}
|
|
75
|
+
/** Why a timer left a run out, as an id a translation can key on; `why` stays the English sentence. */
|
|
76
|
+
export declare const SKIP_REASONS: {
|
|
77
|
+
readonly costDayCap: "cost.day-cap";
|
|
78
|
+
readonly mailNone: "mail.none-new";
|
|
79
|
+
readonly mailBelowThreshold: "mail.below-threshold";
|
|
80
|
+
};
|
|
81
|
+
export type SkipReason = (typeof SKIP_REASONS)[keyof typeof SKIP_REASONS];
|
|
68
82
|
/**
|
|
69
83
|
* Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
|
|
70
84
|
* message, which can name a chat or a person; the run's log keeps that. Any other line is another program's
|
|
@@ -90,7 +104,7 @@ export declare const recordRun: (path: string, run: {
|
|
|
90
104
|
costUsd?: number;
|
|
91
105
|
}) => LastRun;
|
|
92
106
|
/** 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;
|
|
107
|
+
export declare const recordSkip: (file: string, why: string, reason: SkipReason, at?: Date) => void;
|
|
94
108
|
/**
|
|
95
109
|
* One stderr line per failing background job, or nothing: `tg: scheduled digest failing since … (3 runs):
|
|
96
110
|
* <reason> — tg schedule list`. A status the owner has to ask for is how a broken timer goes unnoticed.
|
|
@@ -5,6 +5,9 @@ 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
|
+
export const NOTIFY_WHEN = ["always", "failures", "never"];
|
|
9
|
+
/** A repeating agent would otherwise notify on every tick; any other schedule runs rarely enough to say each result. */
|
|
10
|
+
export const notifyOf = (schedule) => schedule.notify ?? (schedule.kind === "agent" && "every" in schedule.when ? "failures" : "always");
|
|
8
11
|
/** What a gated agent waits for, and what a day of its runs may cost, when neither its flags nor the config say. */
|
|
9
12
|
export const SCHEDULE_DEFAULTS = { mailWaitMinutes: 30, mailBatch: 10, maxCostPerDay: 5 };
|
|
10
13
|
export const localDay = (at) => `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")}`;
|
|
@@ -49,6 +52,12 @@ const reportOf = (stdout) => {
|
|
|
49
52
|
return { text: stdout };
|
|
50
53
|
}
|
|
51
54
|
};
|
|
55
|
+
/** Why a timer left a run out, as an id a translation can key on; `why` stays the English sentence. */
|
|
56
|
+
export const SKIP_REASONS = {
|
|
57
|
+
costDayCap: "cost.day-cap",
|
|
58
|
+
mailNone: "mail.none-new",
|
|
59
|
+
mailBelowThreshold: "mail.below-threshold",
|
|
60
|
+
};
|
|
52
61
|
const REASON_ID = /^[a-z0-9][a-z0-9.-]{0,79}$/;
|
|
53
62
|
/**
|
|
54
63
|
* Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
|
|
@@ -97,12 +106,13 @@ export const recordRun = (path, run) => {
|
|
|
97
106
|
return last;
|
|
98
107
|
};
|
|
99
108
|
/** 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()) => {
|
|
109
|
+
export const recordSkip = (file, why, reason, at = new Date()) => {
|
|
101
110
|
const path = join(file, "..", "last.json");
|
|
102
111
|
const before = readRun(path);
|
|
103
|
-
|
|
112
|
+
// A timer ticking every few minutes would rewrite the file for the same cause each time.
|
|
113
|
+
if (!before || before.skipped?.reason === reason)
|
|
104
114
|
return;
|
|
105
|
-
writeSecurely(path, `${JSON.stringify({ ...before, skipped: { at: at.toISOString(), why } }, null, 2)}\n`, 0o600);
|
|
115
|
+
writeSecurely(path, `${JSON.stringify({ ...before, skipped: { at: at.toISOString(), why, reason } }, null, 2)}\n`, 0o600);
|
|
106
116
|
};
|
|
107
117
|
const shortTime = (iso) => {
|
|
108
118
|
const at = new Date(iso);
|
|
@@ -435,13 +445,14 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
|
|
|
435
445
|
if (!kept.has(stampOf(file)))
|
|
436
446
|
rmSync(join(runs, file), { force: true });
|
|
437
447
|
const title = `${app.command} ${schedule.name}`;
|
|
438
|
-
|
|
448
|
+
const mode = notifyOf(schedule);
|
|
449
|
+
if (agent && exitCode === 0 && mode === "always") {
|
|
439
450
|
const first = readFileSync(output, "utf8")
|
|
440
451
|
.split("\n")
|
|
441
452
|
.find((line) => line.trim()) ?? "report ready";
|
|
442
453
|
await notify(system, env, title, `${first.trim().slice(0, 180)}\n${join(folder, "latest.md")}`);
|
|
443
454
|
}
|
|
444
|
-
else if (exitCode !== 0) {
|
|
455
|
+
else if (exitCode !== 0 && mode !== "never") {
|
|
445
456
|
await notify(system, env, title, `failed (exit ${exitCode}) — ${errors}`);
|
|
446
457
|
}
|
|
447
458
|
if ("once" in schedule.when) {
|
|
@@ -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))
|
|
@@ -2,14 +2,15 @@ import { Command } from "commander";
|
|
|
2
2
|
import type { SearchAllIncludingMeetingsFound } from "../../services/search-all-meetings.js";
|
|
3
3
|
import { type Messenger } from "./context.js";
|
|
4
4
|
import { type SourceSearched } from "./search-answers.js";
|
|
5
|
-
export declare const searchAllOnlyOf: (value: string) => SourceSearched[];
|
|
5
|
+
export declare const searchAllOnlyOf: (value: string, allowed?: readonly string[]) => SourceSearched[];
|
|
6
6
|
/** One `search all` hit as a person reads it. */
|
|
7
7
|
export declare const searchAllLine: (item: SearchAllIncludingMeetingsFound["items"][number]) => string;
|
|
8
8
|
/**
|
|
9
9
|
* `search all`'s scope, paging and matching options, the same in every CLI that mounts it (WC-ACCT-8): a messenger's
|
|
10
10
|
* and a calendar's.
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
/** `allowed`: the resources `--only` takes, a CRM CLI's with `crm`. */
|
|
13
|
+
export declare const searchAllOptions: (command: Command, allowed?: readonly string[]) => Command;
|
|
13
14
|
/**
|
|
14
15
|
* `search <resource>`: every search of the tool, one leaf per resource (STANDARD.md, "Search
|
|
15
16
|
* hierarchy"). `topics` only where the CLI has forum topics.
|
|
@@ -16,13 +16,13 @@ import { messagesSearchCommand } from "./messages-search-command.js";
|
|
|
16
16
|
import { allAnswer, meaningNote, notesAnswer, offsetOf, SOURCE_SEARCHED, shownOf, } from "./search-answers.js";
|
|
17
17
|
import { backendOptions, backendRequest, noteServer } from "./search-backend-options.js";
|
|
18
18
|
import { topicsSearchCommand } from "./topics-command.js";
|
|
19
|
-
export const searchAllOnlyOf = (value) => {
|
|
19
|
+
export const searchAllOnlyOf = (value, allowed = SOURCE_SEARCHED) => {
|
|
20
20
|
const chosen = value.split(",").map((one) => one.trim());
|
|
21
21
|
if (chosen.includes("mail"))
|
|
22
22
|
throw new CliError("validation_error", "mail is searched by the separate memo CLI: install @wirecat/cli-memo, then `memo search mail …`");
|
|
23
|
-
const unknown = chosen.find((one) => !
|
|
23
|
+
const unknown = chosen.find((one) => !allowed.includes(one));
|
|
24
24
|
if (unknown !== undefined || chosen.length === 0)
|
|
25
|
-
throw new CliError("validation_error", `--only takes ${
|
|
25
|
+
throw new CliError("validation_error", `--only takes ${allowed.join(", ")}, separated by commas; meetings when --source names a meeting account`);
|
|
26
26
|
return [...new Set(chosen)];
|
|
27
27
|
};
|
|
28
28
|
/** One `search all` hit as a person reads it. */
|
|
@@ -40,8 +40,10 @@ const maxMeetingsOf = (value) => {
|
|
|
40
40
|
* `search all`'s scope, paging and matching options, the same in every CLI that mounts it (WC-ACCT-8): a messenger's
|
|
41
41
|
* and a calendar's.
|
|
42
42
|
*/
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
/** `allowed`: the resources `--only` takes, a CRM CLI's with `crm`. */
|
|
44
|
+
export const searchAllOptions = (command, allowed = SOURCE_SEARCHED) => command
|
|
45
|
+
.option("--only <resources>", `only these resources, separated by commas: ${allowed.filter((one) => one !== "meetings").join(", ")}; ` +
|
|
46
|
+
"meetings when --source names a meeting account", (value) => searchAllOnlyOf(value, allowed))
|
|
45
47
|
.option("--source <accounts>", "only these accounts, separated by commas: provider:account as a hit shows it, a provider, a kind or " +
|
|
46
48
|
"group:<name>; each resource reads the ones of its kind, and meetings only when it names one")
|
|
47
49
|
.option("--max-meetings <n>", "how many of the meeting account's meetings to look through, newest first, or all for every one (default 100)", maxMeetingsOf)
|
|
@@ -3,9 +3,9 @@ import type { CacheDatabase } from "../../store/driver.js";
|
|
|
3
3
|
import type { Messenger } from "./context.js";
|
|
4
4
|
/** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
|
|
5
5
|
export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
|
|
6
|
-
error?: undefined;
|
|
7
6
|
path: string;
|
|
8
7
|
exists: boolean;
|
|
8
|
+
error?: undefined;
|
|
9
9
|
} | {
|
|
10
10
|
path: string;
|
|
11
11
|
exists: boolean;
|
|
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { CliError, resolvePaths, writeSecurely } from "@wirecat/cli-base";
|
|
4
4
|
import { annotate } from "@wirecat/cli-base/commands";
|
|
5
5
|
import { Argument, Command, Option } from "commander";
|
|
6
|
-
import { describeWhen, findProgram, lastRun, listSchedules, readSchedule, recordSkip, runSchedule, SCHEDULE_DEFAULTS, scheduleFile, scheduleName, spentToday, timerFor, } from "../../background/schedules.js";
|
|
6
|
+
import { describeWhen, findProgram, lastRun, listSchedules, NOTIFY_WHEN, notifyOf, readSchedule, recordSkip, runSchedule, SCHEDULE_DEFAULTS, SKIP_REASONS, scheduleFile, scheduleName, spentToday, timerFor, } from "../../background/schedules.js";
|
|
7
7
|
import { thisMachine } from "../../background/system.js";
|
|
8
8
|
import { locationVariables } from "../../background/units.js";
|
|
9
9
|
import { parseDuration } from "../../domain/duration.js";
|
|
@@ -79,7 +79,8 @@ const scheduleAnswer = ({ environment, createdAt, ...schedule }, extra) => ({
|
|
|
79
79
|
const withWhen = (command) => command
|
|
80
80
|
.option("--every <duration>", "repeat: 30m, 2h, 1d")
|
|
81
81
|
.option("--at <HH:MM>", "every day at this local time")
|
|
82
|
-
.option("--at-time <time>", "one run: 30m, 2d from now, or an ISO time; the schedule removes itself after it")
|
|
82
|
+
.option("--at-time <time>", "one run: 30m, 2d from now, or an ISO time; the schedule removes itself after it")
|
|
83
|
+
.addOption(new Option("--notify-on <when>", "desktop notice after a run (default failures for an agent that repeats with --every, else always)").choices(NOTIFY_WHEN));
|
|
83
84
|
/** What the schedule command needs of a CLI: who it is and, per command, where it prints and which profile runs. */
|
|
84
85
|
/** `skill show follow-ups` exists only for messenger CLIs; any other CLI gets a read-only summary it can run. */
|
|
85
86
|
const promptExample = (app) => app.messenger === false
|
|
@@ -209,8 +210,8 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
|
|
|
209
210
|
const spent = spentToday(file);
|
|
210
211
|
if (spent >= maxCostPerDay) {
|
|
211
212
|
const why = `today's runs cost ${usd(spent)}, the ${usd(maxCostPerDay)} a day allowed; it runs again tomorrow`;
|
|
212
|
-
recordSkip(file, why);
|
|
213
|
-
return why;
|
|
213
|
+
recordSkip(file, why, SKIP_REASONS.costDayCap);
|
|
214
|
+
return { why, reason: SKIP_REASONS.costDayCap };
|
|
214
215
|
}
|
|
215
216
|
}
|
|
216
217
|
const holder = schedule.onNewMail;
|
|
@@ -219,13 +220,18 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
|
|
|
219
220
|
const waiting = await newMail(self, holder);
|
|
220
221
|
if (waiting.unmarked)
|
|
221
222
|
return undefined;
|
|
222
|
-
if (waiting.emails === 0)
|
|
223
|
-
|
|
223
|
+
if (waiting.emails === 0) {
|
|
224
|
+
const why = `no mail stored past ${holder}'s mark`;
|
|
225
|
+
recordSkip(file, why, SKIP_REASONS.mailNone);
|
|
226
|
+
return { why, reason: SKIP_REASONS.mailNone };
|
|
227
|
+
}
|
|
224
228
|
const waited = waiting.oldestStoredAt === null ? 0 : Date.now() - waiting.oldestStoredAt;
|
|
225
229
|
if (!timed || waiting.emails >= mailBatch || waited >= mailWaitMinutes * 60_000)
|
|
226
230
|
return undefined;
|
|
227
|
-
|
|
228
|
-
`or once the oldest has waited ${mailWaitMinutes}m
|
|
231
|
+
const why = `${waiting.emails} email(s) wait past ${holder}'s mark; it starts at ${mailBatch}, ` +
|
|
232
|
+
`or once the oldest has waited ${mailWaitMinutes}m`;
|
|
233
|
+
recordSkip(file, why, SKIP_REASONS.mailBelowThreshold);
|
|
234
|
+
return { why, reason: SKIP_REASONS.mailBelowThreshold };
|
|
229
235
|
};
|
|
230
236
|
withWhen(grantsAuthority(annotate(command.command("command"), {
|
|
231
237
|
literal_after_dashes: true,
|
|
@@ -248,6 +254,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
|
|
|
248
254
|
when: whenOf(this.opts(), COMMAND_MINIMUM_MINUTES),
|
|
249
255
|
environment: environment(),
|
|
250
256
|
createdAt: new Date().toISOString(),
|
|
257
|
+
...(this.opts().notifyOn ? { notify: this.opts().notifyOn } : {}),
|
|
251
258
|
});
|
|
252
259
|
});
|
|
253
260
|
withWhen(grantsAuthority(annotate(command.command("agent"), {
|
|
@@ -329,6 +336,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
|
|
|
329
336
|
...(mailWaitMinutes === undefined ? {} : { mailWaitMinutes }),
|
|
330
337
|
...(mailBatch === undefined ? {} : { mailBatch }),
|
|
331
338
|
...(options.maxCostPerDay === undefined ? {} : { maxCostPerDay: options.maxCostPerDay }),
|
|
339
|
+
...(options.notifyOn ? { notify: options.notifyOn } : {}),
|
|
332
340
|
});
|
|
333
341
|
});
|
|
334
342
|
if (newMail)
|
|
@@ -349,6 +357,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
|
|
|
349
357
|
name: schedule.name,
|
|
350
358
|
kind: schedule.kind,
|
|
351
359
|
when: describeWhen(schedule.when),
|
|
360
|
+
notify: notifyOf(schedule),
|
|
352
361
|
last: lastRun(scheduleFile(state, schedule.name)) ?? null,
|
|
353
362
|
...(schedule.kind === "agent"
|
|
354
363
|
? {
|
|
@@ -369,10 +378,11 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
|
|
|
369
378
|
return "";
|
|
370
379
|
}
|
|
371
380
|
return rows
|
|
372
|
-
.map(({ name, kind, when, last, spentTodayUsd, maxCostPerDayUsd }) => [
|
|
381
|
+
.map(({ name, kind, when, notify, last, spentTodayUsd, maxCostPerDayUsd }) => [
|
|
373
382
|
name,
|
|
374
383
|
kind,
|
|
375
384
|
when,
|
|
385
|
+
`notify ${notify}`,
|
|
376
386
|
last
|
|
377
387
|
? `last ${last.at.slice(0, 16).replace("T", " ")} exit ${last.exitCode}` +
|
|
378
388
|
(last.failures
|
|
@@ -398,9 +408,10 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
|
|
|
398
408
|
.argument("<name>", "the schedule's name, as `schedule list` shows it")
|
|
399
409
|
.action(async function (name) {
|
|
400
410
|
const { context, system, state, say, timer } = prepare(this);
|
|
401
|
-
const
|
|
402
|
-
if (
|
|
403
|
-
return say({ name, skipped: true, why }, [`${name}: skipped — ${why}`]);
|
|
411
|
+
const skip = await waits(this, scheduleFile(state, scheduleName(name)), { timed: false });
|
|
412
|
+
if (skip !== undefined) {
|
|
413
|
+
return say({ name, skipped: true, why: skip.why, reason: skip.reason }, [`${name}: skipped — ${skip.why}`]);
|
|
414
|
+
}
|
|
404
415
|
const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
|
|
405
416
|
timer: timer(),
|
|
406
417
|
agentRuns,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type Command } from "commander";
|
|
2
|
+
import { type Body } from "../../cli/envelope.js";
|
|
3
|
+
import type { AppIdentity } from "../../ports/app.js";
|
|
4
|
+
import type { MessageStore } from "../../store/store.js";
|
|
5
|
+
import type { AccountKey } from "../../store/types.js";
|
|
6
|
+
import type { CrmSource } from "../source.js";
|
|
7
|
+
/**
|
|
8
|
+
* The selected profile's CRM account, opened by the app for one command and closed once its answer is built, before
|
|
9
|
+
* anything is printed (WC-LIFE-4).
|
|
10
|
+
*/
|
|
11
|
+
export interface CrmRun {
|
|
12
|
+
/** The profile's name, which `store status` gives as each sync's `profile`. */
|
|
13
|
+
profile: string;
|
|
14
|
+
/** The profile's CRM account as the shared store names it: every command reads and syncs only it. */
|
|
15
|
+
account: AccountKey;
|
|
16
|
+
store: MessageStore;
|
|
17
|
+
/** The CRM, built on first use: a command that only reads the store never reads the sign-in. */
|
|
18
|
+
source: () => CrmSource;
|
|
19
|
+
/** A profile lock pins the process (WC-ACCT-5): `search all` then reads only this account's deals. */
|
|
20
|
+
locked?: boolean;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
close?: () => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export interface CrmCommandOptions {
|
|
25
|
+
/** The CLI itself: the first word of examples, and `search all`. */
|
|
26
|
+
app: AppIdentity;
|
|
27
|
+
/** Opens the selected profile for one command; `readOnly` when the command only reads the store. */
|
|
28
|
+
open: (command: Command, access: {
|
|
29
|
+
readOnly: boolean;
|
|
30
|
+
}) => Promise<CrmRun>;
|
|
31
|
+
/** The CRM has leads: mounts `leads list|show`. True when absent. */
|
|
32
|
+
leads?: boolean;
|
|
33
|
+
now?: () => number;
|
|
34
|
+
}
|
|
35
|
+
/** What `deals list` and `leads list` narrow by, the same in memo and every CRM CLI (WC-OUT-11). */
|
|
36
|
+
export declare const DEALS_QUERY: {
|
|
37
|
+
readonly entity: "deal";
|
|
38
|
+
readonly backend: "native";
|
|
39
|
+
readonly fields: import("@wirecat/cli-base/query").FieldSpec[];
|
|
40
|
+
};
|
|
41
|
+
export declare const LEADS_QUERY: {
|
|
42
|
+
readonly entity: "lead";
|
|
43
|
+
readonly backend: "native";
|
|
44
|
+
readonly fields: import("@wirecat/cli-base/query").FieldSpec[];
|
|
45
|
+
};
|
|
46
|
+
export declare const PIPELINES_QUERY: {
|
|
47
|
+
readonly entity: "pipeline";
|
|
48
|
+
readonly backend: "native";
|
|
49
|
+
readonly fields: readonly [import("@wirecat/cli-base/query").FieldSpec];
|
|
50
|
+
};
|
|
51
|
+
export declare const STAGES_QUERY: {
|
|
52
|
+
readonly entity: "stage";
|
|
53
|
+
readonly backend: "native";
|
|
54
|
+
readonly fields: readonly [import("@wirecat/cli-base/query").FieldSpec, import("@wirecat/cli-base/query").FieldSpec];
|
|
55
|
+
};
|
|
56
|
+
export type CrmWithin = (command: Command, work: (run: CrmRun) => Promise<Body>) => Promise<void>;
|
|
57
|
+
export declare const addCrmCommands: (program: Command, options: CrmCommandOptions) => void;
|