@wirecat/cli-core 0.576.0 → 0.577.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 CHANGED
@@ -7,6 +7,26 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.577.0 — 05.11.2026
11
+
12
+ ### Added
13
+
14
+ - **Tasks keep their id in an outside service and the page they are about (store version 51).** `tasks.provider`
15
+ and `tasks.external_id` hold a task's id in a service such as Avito, Cian or hh.ru, given and shown as
16
+ `<service>:<id>` (`avito:4512345678`), so ids of two services never collide; several tasks may share one.
17
+ `tasks.source_url` and `orders.source_url` hold the http or https page a task or an order is about, such as an Avito
18
+ ad or an Amazon order; anything else is refused (`task.source-url`, `order.source-url`, `task.external-id`).
19
+ `TaskFields.externalId` and `sourceUrl` set them on create and edit, `null` clears them; `TaskDetails` and the
20
+ `task` entity carry `external_id` and `source_url`; `OrderInput` and `Order` carry `sourceUrl`. Mail findings take
21
+ `external_id` and `source_url` on a `task` and `source_url` on an `order`, and accepting keeps them. `minCompatible`
22
+ stays 34.
23
+ - **The owner moves a holder's mail mark back: `messages reset`.** `--holder <name>` with `--since-time <time>` or
24
+ `--last <n>`, and `--dry-run` to count first; the answer (`mail_mark_reset`) says how many emails the holder reads
25
+ whole again, how many bulk ones come again as excerpts and how many are skipped again. It is owner only: no
26
+ proposal, refused under the read-only lock, for a scheduled holder and for an agent (`mail.reset-owner-only`); a
27
+ holder with no mark yet is `mail.no-mark`. `rewindMark` is the function under it, and `MailActivityStore` gets
28
+ `emailsBefore`.
29
+
10
30
  ## 0.576.0 — 05.11.2026
11
31
 
12
32
  ### Added
@@ -14,6 +14,8 @@ const keys = {
14
14
  supplier_name: "supplierName",
15
15
  order_date: "orderDate",
16
16
  fulfilled_date: "fulfilledDate",
17
+ external_id: "externalId",
18
+ source_url: "sourceUrl",
17
19
  };
18
20
  const parseFindingsJson = (text, label, validate) => {
19
21
  if (Buffer.byteLength(text, "utf8") > 65536)
@@ -41,6 +41,8 @@ const card = (task) => [
41
41
  `due ${when(task.dueAt)} next check ${when(task.nextCheckAt)} source ${task.source ?? "—"}`,
42
42
  ...(task.priorityLabel === null ? [] : [`priority ${task.priorityLabel}`]),
43
43
  ...(task.agent === null ? [] : [`agent ${task.agent}`]),
44
+ ...(task.externalId === null ? [] : [`external id ${task.externalId}`]),
45
+ ...(task.sourceUrl === null ? [] : [`page ${task.sourceUrl}`]),
44
46
  ...(task.claimedBy === null ? [] : [`claimed by ${task.claimedBy} until ${when(task.claimedUntil)}`]),
45
47
  ...task.assignments.filter((one) => one.role === "assignee").map((one) => `assignee ${one.to}`),
46
48
  ...(task.closeSuggestion === null ? [] : [suggestionLine(task.closeSuggestion)]),
@@ -0,0 +1,13 @@
1
+ /** The outside service an external id belongs to: `avito`, `cian`, `amazon`, `hh`. */
2
+ export declare const EXTERNAL_SERVICE: RegExp;
3
+ export declare const EXTERNAL_ID_CHARS = 200;
4
+ export declare const SOURCE_URL_CHARS = 2000;
5
+ export interface ExternalId {
6
+ provider: string;
7
+ id: string;
8
+ }
9
+ /** `avito:4512345678` → the service and its id; the id is everything after the first colon. */
10
+ export declare const externalIdOf: (value: string) => ExternalId;
11
+ export declare const externalIdText: (provider: unknown, id: unknown) => string | null;
12
+ /** An http or https address, as given; anything else is refused, so a stored link always opens a web page. */
13
+ export declare const sourceUrlOf: (value: string, reason?: string) => string;
@@ -0,0 +1,30 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ /** The outside service an external id belongs to: `avito`, `cian`, `amazon`, `hh`. */
3
+ export const EXTERNAL_SERVICE = /^[a-z][a-z0-9-]{0,31}$/;
4
+ export const EXTERNAL_ID_CHARS = 200;
5
+ export const SOURCE_URL_CHARS = 2000;
6
+ /** `avito:4512345678` → the service and its id; the id is everything after the first colon. */
7
+ export const externalIdOf = (value) => {
8
+ const text = value.trim();
9
+ const colon = text.indexOf(":");
10
+ const provider = colon === -1 ? "" : text.slice(0, colon).toLowerCase();
11
+ const id = colon === -1 ? "" : text.slice(colon + 1).trim();
12
+ if (!EXTERNAL_SERVICE.test(provider) || id.length === 0 || id.length > EXTERNAL_ID_CHARS || /\s/.test(id))
13
+ throw new CliError("validation_error", `an external id is <service>:<id>, such as avito:4512345678, at most ${EXTERNAL_ID_CHARS} characters after the colon and no spaces, not "${value}"`, { reason: "task.external-id" });
14
+ return { provider, id };
15
+ };
16
+ export const externalIdText = (provider, id) => provider == null || id == null ? null : `${String(provider)}:${String(id)}`;
17
+ /** An http or https address, as given; anything else is refused, so a stored link always opens a web page. */
18
+ export const sourceUrlOf = (value, reason = "task.source-url") => {
19
+ const text = value.trim();
20
+ let url;
21
+ try {
22
+ url = new URL(text);
23
+ }
24
+ catch {
25
+ url = undefined;
26
+ }
27
+ if (!url || (url.protocol !== "http:" && url.protocol !== "https:") || text.length > SOURCE_URL_CHARS)
28
+ throw new CliError("validation_error", `a source URL is an http or https address of at most ${SOURCE_URL_CHARS} characters, not "${value}"`, { reason });
29
+ return text;
30
+ };
@@ -4303,6 +4303,8 @@ export declare const ENTITIES: {
4303
4303
  readonly account: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
4304
4304
  readonly chat: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
4305
4305
  readonly source_message: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
4306
+ readonly external_id: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
4307
+ readonly source_url: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
4306
4308
  readonly priority: v.OptionalSchema<v.NullableSchema<v.NumberSchema<undefined>, undefined>, undefined>;
4307
4309
  readonly priority_label: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>, undefined>;
4308
4310
  readonly agent: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
@@ -4386,6 +4388,8 @@ export declare const ENTITIES: {
4386
4388
  readonly revision: v.NumberSchema<undefined>;
4387
4389
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
4388
4390
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4391
+ readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4392
+ readonly source_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4389
4393
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4390
4394
  readonly next_check_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4391
4395
  readonly closed_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -4500,6 +4504,8 @@ export declare const ENTITIES: {
4500
4504
  readonly revision: v.NumberSchema<undefined>;
4501
4505
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
4502
4506
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4507
+ readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4508
+ readonly source_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4503
4509
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4504
4510
  readonly next_check_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4505
4511
  readonly closed_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -4615,6 +4621,8 @@ export declare const ENTITIES: {
4615
4621
  readonly revision: v.NumberSchema<undefined>;
4616
4622
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
4617
4623
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4624
+ readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4625
+ readonly source_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4618
4626
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4619
4627
  readonly next_check_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4620
4628
  readonly closed_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -4758,6 +4766,8 @@ export declare const ENTITIES: {
4758
4766
  readonly revision: v.NumberSchema<undefined>;
4759
4767
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
4760
4768
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4769
+ readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4770
+ readonly source_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4761
4771
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4762
4772
  readonly next_check_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4763
4773
  readonly closed_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -4866,6 +4876,8 @@ export declare const ENTITIES: {
4866
4876
  readonly revision: v.NumberSchema<undefined>;
4867
4877
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
4868
4878
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4879
+ readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4880
+ readonly source_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4869
4881
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4870
4882
  readonly next_check_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4871
4883
  readonly closed_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -5911,6 +5923,8 @@ export declare const ENTITIES: {
5911
5923
  readonly due_evidence: v.OptionalSchema<v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
5912
5924
  readonly assignee: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
5913
5925
  readonly assignee_evidence: v.OptionalSchema<v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
5926
+ readonly external_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
5927
+ readonly source_url: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
5914
5928
  }, undefined>, v.StrictObjectSchema<{
5915
5929
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
5916
5930
  readonly evidence: v.ArraySchema<v.StrictObjectSchema<{
@@ -6028,6 +6042,7 @@ export declare const ENTITIES: {
6028
6042
  readonly invoice: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
6029
6043
  readonly payment: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
6030
6044
  readonly project: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
6045
+ readonly source_url: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
6031
6046
  readonly reason: v.StringSchema<undefined>;
6032
6047
  }, undefined>, v.StrictObjectSchema<{
6033
6048
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -26,6 +26,10 @@ export declare const mailFindings: v.StrictObjectSchema<{
26
26
  readonly due_evidence: v.OptionalSchema<v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
27
27
  readonly assignee: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
28
28
  readonly assignee_evidence: v.OptionalSchema<v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
29
+ /** `<service>:<id>` in the outside service the mail came from, such as `avito:4512345678`. */
30
+ readonly external_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
31
+ /** The http or https page the task is about, such as the conversation the mail links to. */
32
+ readonly source_url: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
29
33
  }, undefined>, v.StrictObjectSchema<{
30
34
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
31
35
  readonly evidence: v.ArraySchema<v.StrictObjectSchema<{
@@ -143,6 +147,8 @@ export declare const mailFindings: v.StrictObjectSchema<{
143
147
  readonly invoice: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
144
148
  readonly payment: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
145
149
  readonly project: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
150
+ /** The order's page at the shop, http or https. */
151
+ readonly source_url: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
146
152
  readonly reason: v.StringSchema<undefined>;
147
153
  }, undefined>, v.StrictObjectSchema<{
148
154
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -18,6 +18,10 @@ const finding = v.variant("kind", [
18
18
  due_evidence: v.optional(v.array(reference)),
19
19
  assignee: v.optional(reference),
20
20
  assignee_evidence: v.optional(v.array(reference)),
21
+ /** `<service>:<id>` in the outside service the mail came from, such as `avito:4512345678`. */
22
+ external_id: v.optional(text),
23
+ /** The http or https page the task is about, such as the conversation the mail links to. */
24
+ source_url: v.optional(text),
21
25
  }),
22
26
  v.strictObject({
23
27
  ...base,
@@ -96,6 +100,8 @@ const finding = v.variant("kind", [
96
100
  invoice: v.optional(reference),
97
101
  payment: v.optional(reference),
98
102
  project: v.optional(reference),
103
+ /** The order's page at the shop, http or https. */
104
+ source_url: v.optional(text),
99
105
  reason: text,
100
106
  }),
101
107
  v.strictObject({
@@ -22,6 +22,10 @@ export declare const taskCard: v.StrictObjectSchema<{
22
22
  readonly revision: v.NumberSchema<undefined>;
23
23
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
24
24
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
25
+ /** `<service>:<id>`, its id in an outside service, such as `avito:4512345678`. */
26
+ readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
27
+ /** The http or https page it is about, such as an Avito ad. */
28
+ readonly source_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
25
29
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
26
30
  readonly next_check_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
27
31
  readonly closed_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -136,6 +140,10 @@ export declare const taskCreated: v.StrictObjectSchema<{
136
140
  readonly revision: v.NumberSchema<undefined>;
137
141
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
138
142
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
143
+ /** `<service>:<id>`, its id in an outside service, such as `avito:4512345678`. */
144
+ readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
145
+ /** The http or https page it is about, such as an Avito ad. */
146
+ readonly source_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
139
147
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
140
148
  readonly next_check_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
141
149
  readonly closed_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -251,6 +259,10 @@ export declare const taskLinks: v.UnionSchema<[v.StrictObjectSchema<{
251
259
  readonly revision: v.NumberSchema<undefined>;
252
260
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
253
261
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
262
+ /** `<service>:<id>`, its id in an outside service, such as `avito:4512345678`. */
263
+ readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
264
+ /** The http or https page it is about, such as an Avito ad. */
265
+ readonly source_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
254
266
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
255
267
  readonly next_check_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
256
268
  readonly closed_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -394,6 +406,10 @@ export declare const taskMerge: v.StrictObjectSchema<{
394
406
  readonly revision: v.NumberSchema<undefined>;
395
407
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
396
408
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
409
+ /** `<service>:<id>`, its id in an outside service, such as `avito:4512345678`. */
410
+ readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
411
+ /** The http or https page it is about, such as an Avito ad. */
412
+ readonly source_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
397
413
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
398
414
  readonly next_check_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
399
415
  readonly closed_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -502,6 +518,10 @@ export declare const taskUnmerge: v.StrictObjectSchema<{
502
518
  readonly revision: v.NumberSchema<undefined>;
503
519
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
504
520
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
521
+ /** `<service>:<id>`, its id in an outside service, such as `avito:4512345678`. */
522
+ readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
523
+ /** The http or https page it is about, such as an Avito ad. */
524
+ readonly source_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
505
525
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
506
526
  readonly next_check_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
507
527
  readonly closed_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -39,6 +39,10 @@ export const taskCard = v.strictObject({
39
39
  revision: v.number(),
40
40
  parent: v.nullable(reference),
41
41
  source: v.nullable(text),
42
+ /** `<service>:<id>`, its id in an outside service, such as `avito:4512345678`. */
43
+ external_id: v.nullable(text),
44
+ /** The http or https page it is about, such as an Avito ad. */
45
+ source_url: v.nullable(text),
42
46
  due_at: nullableTime,
43
47
  next_check_at: nullableTime,
44
48
  closed_at: nullableTime,
@@ -34,6 +34,10 @@ export declare const task: v.StrictObjectSchema<{
34
34
  readonly chat: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
35
35
  /** The message it came from. */
36
36
  readonly source_message: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
37
+ /** `<service>:<id>`, its id in an outside service, such as `avito:4512345678`. */
38
+ readonly external_id: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
39
+ /** The http or https page it is about, such as an Avito ad. */
40
+ readonly source_url: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
37
41
  /** 0 to 1, higher first; `priority_label` reads it as low, medium, high or critical. */
38
42
  readonly priority: v.OptionalSchema<v.NullableSchema<v.NumberSchema<undefined>, undefined>, undefined>;
39
43
  readonly priority_label: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>, undefined>;
@@ -32,6 +32,10 @@ export const task = v.strictObject({
32
32
  chat: v.optional(v.nullable(reference)),
33
33
  /** The message it came from. */
34
34
  source_message: v.optional(v.nullable(reference)),
35
+ /** `<service>:<id>`, its id in an outside service, such as `avito:4512345678`. */
36
+ external_id: v.optional(v.nullable(text)),
37
+ /** The http or https page it is about, such as an Avito ad. */
38
+ source_url: v.optional(v.nullable(text)),
35
39
  /** 0 to 1, higher first; `priority_label` reads it as low, medium, high or critical. */
36
40
  priority: v.optional(v.nullable(v.number())),
37
41
  priority_label: v.optional(v.nullable(v.picklist(PRIORITY_LABELS))),
@@ -69,6 +69,8 @@ const OTHERS = [
69
69
  validation("file.locked", "another process holds the file's lock"),
70
70
  validation("mail.cursor-ahead", "an acknowledge cursor is past the newest stored email"),
71
71
  ["mail.holder-not-yours", "permission_error", "a run moves only the mark of the holder its schedule names"],
72
+ ["mail.no-mark", "not_found", "the holder has no mark in the mailbox yet; its first review starts it"],
73
+ ["mail.reset-owner-only", "permission_error", "only the owner moves a holder's mark back"],
72
74
  ["mail.source-failed", "provider_error", "the mail server refused or did not complete a request"],
73
75
  ["mail.search-refused", "provider_error", "the mail server refused to search a folder"],
74
76
  [
@@ -199,6 +201,7 @@ const OTHERS = [
199
201
  validation("money.quantity", "the quantity is not a decimal above 0"),
200
202
  validation("order.duplicate", "the shop's order with this number is already stored; change its status instead"),
201
203
  validation("order.field", "a field of the order is not valid"),
204
+ validation("order.source-url", "an order's source URL is not an http or https address"),
202
205
  ["order.not-found", "not_found", "no stored order of that shop has that number"],
203
206
  validation("order.ref", "the reference names no order, or a shop's number names more than one"),
204
207
  validation("order.status", "a rejected or deleted order's status does not change"),
@@ -246,6 +249,8 @@ const OTHERS = [
246
249
  validation("tag.invalid", "the tag is not valid"),
247
250
  ["reactions.list-hidden", "permission_error", "the chat does not let its members see who reacted"],
248
251
  ["reactions.broadcast", "permission_error", "a channel does not show who reacted to its posts"],
252
+ validation("task.external-id", "an external id is <service>:<id>, such as avito:4512345678"),
253
+ validation("task.source-url", "a task's source URL is not an http or https address"),
249
254
  validation("task.category-unknown", "the category is not in the task category list"),
250
255
  validation("task.claimed", "someone else holds the task"),
251
256
  validation("task.closed", "the task is already closed"),
@@ -19,7 +19,7 @@ import { searchMailServer } from "../../mail/server-search.js";
19
19
  import { atDetail } from "../../output-detail.js";
20
20
  import { sourceKey } from "../../services/server-search.js";
21
21
  import { queryFilter, THREAD_COLUMNS } from "../../store/store.js";
22
- import { acknowledgeCommand } from "./marks.js";
22
+ import { acknowledgeCommand, resetMarkCommand } from "./marks.js";
23
23
  import { mailReviewCommand } from "./review.js";
24
24
  export { bulkTriageOf, MAIL_BULK_SETTINGS } from "./bulk-settings.js";
25
25
  export { mailMark, markItem, markText } from "./marks.js";
@@ -254,6 +254,7 @@ const messagesCommand = (mail) => {
254
254
  : itemOf(shown.find((_, at) => emails[at]?.anchor) ?? shown[0], unread ? { meta: { complete: false, issues: [unread] } } : undefined), () => shownText(emails));
255
255
  });
256
256
  messages.addCommand(acknowledgeCommand(mail));
257
+ messages.addCommand(resetMarkCommand(mail));
257
258
  return messages;
258
259
  };
259
260
  const mailThread = v.strictObject({
@@ -26,3 +26,9 @@ export declare const markText: (mark: MailMark) => string;
26
26
  * schedule names only: an email that talks its reader into acknowledging would otherwise silence another pipeline.
27
27
  */
28
28
  export declare const acknowledgeCommand: (mail: MailReads) => Command;
29
+ /**
30
+ * `messages reset`: the owner moves a holder's mark back, so its next reviews give it that mail again — after its
31
+ * rules changed, say. An agent never does: a holder that could move its own mark back could be talked by an email
32
+ * into reading the whole mailbox again, so this is no proposal and the read-only lock refuses it.
33
+ */
34
+ export declare const resetMarkCommand: (mail: MailReads) => Command;
@@ -8,7 +8,8 @@ import { environmentOf, outputFor } from "../../cli/context.js";
8
8
  import { LOCAL_WRITE, proposesOnly } from "../../cli/effects.js";
9
9
  import { itemOf, renderAnswer } from "../../cli/envelope.js";
10
10
  import { mailHolderVariable, readOnlyLockOf } from "../../ports/app.js";
11
- import { acknowledgeMark } from "../marks.js";
11
+ import { momentOf } from "../../services/moment.js";
12
+ import { acknowledgeMark, rewindMark } from "../marks.js";
12
13
  const iso = (ms) => (ms === null ? null : new Date(ms).toISOString());
13
14
  /** A holder's mark as `--json`, `mail status` and MCP show it. */
14
15
  export const mailMark = v.strictObject({
@@ -67,3 +68,70 @@ export const acknowledgeCommand = (mail) => answerEntity(proposesOnly(annotate(n
67
68
  const mark = await mail.read(this, (store, mailbox) => acknowledgeMark(store, mailbox, holder, cursor));
68
69
  renderAnswer(this, outputFor(this), itemOf(markItem(mark)), () => markText(mark));
69
70
  });
71
+ const mailMarkReset = v.strictObject({
72
+ mark: mailMark,
73
+ /** Where the mark was before. */
74
+ from_id: v.number(),
75
+ /** Emails the holder reads whole again. */
76
+ again: v.number(),
77
+ /** Bulk emails it gets again as excerpts, unless they are surely newsletters. */
78
+ bulk: v.number(),
79
+ /** Emails between the two marks it skips again: drafts, envelopes, suspicious senders, partial mail. */
80
+ skipped: v.number(),
81
+ /** Nothing moved: `--dry-run` only counts. */
82
+ dry_run: v.boolean(),
83
+ });
84
+ const wholeNumber = (value) => {
85
+ const parsed = Number(value);
86
+ if (!Number.isSafeInteger(parsed) || parsed < 1)
87
+ throw new CliError("validation_error", `--last takes a whole number of emails, 1 or more — got ${value}`);
88
+ return parsed;
89
+ };
90
+ /**
91
+ * `messages reset`: the owner moves a holder's mark back, so its next reviews give it that mail again — after its
92
+ * rules changed, say. An agent never does: a holder that could move its own mark back could be talked by an email
93
+ * into reading the whole mailbox again, so this is no proposal and the read-only lock refuses it.
94
+ */
95
+ export const resetMarkCommand = (mail) => answerEntity(annotate(new Command("reset"), {
96
+ effects: LOCAL_WRITE,
97
+ examples: [
98
+ `${mail.app.command} personal messages reset --holder mail-triage --last 8 --dry-run`,
99
+ `${mail.app.command} personal messages reset --holder mail-triage --since-time 2026-11-04T00:00:00Z`,
100
+ ],
101
+ }), "mail_mark_reset", mailMarkReset)
102
+ .summary("move a holder's processing mark back, so it reads that mail again (owner only)")
103
+ .description("the owner moves a holder's mark back past the mail received since --since-time, or past the last --last " +
104
+ "emails it was given; its next reviews give that mail again. The answer says how many emails it reads " +
105
+ "again. An agent cannot: a holder only moves its own mark forward, with `messages acknowledge`")
106
+ .requiredOption("--holder <name>", "whose mark: lower-case letters, digits and dashes, such as mail-triage")
107
+ .option("--since-time <time>", "read again what was received since this ISO 8601 time, or 2d ago")
108
+ .option("--last <n>", "read again the last n emails the holder was given", wholeNumber)
109
+ .option("--dry-run", "count what would be read again, and move nothing")
110
+ .action(async function () {
111
+ const { holder, sinceTime, last, dryRun } = this.opts();
112
+ if ((sinceTime === undefined) === (last === undefined))
113
+ throw new CliError("validation_error", "give --since-time or --last, one of them");
114
+ const env = environmentOf(this).env ?? process.env;
115
+ if (env[mailHolderVariable(mail.app)]?.trim() || actorOf(this) !== "owner" || readOnlyLockOf(mail.app, env))
116
+ throw new CliError("permission_error", "only the owner moves a holder's mark back; an agent only acknowledges", {
117
+ reason: "mail.reset-owner-only",
118
+ });
119
+ const now = Date.now();
120
+ const to = last === undefined ? { since: momentOf(sinceTime, "--since-time", now) } : { last };
121
+ const reset = await mail.read(this, (store, mailbox) => rewindMark(store, mailbox, holder, to, { now, dry: dryRun === true }));
122
+ const item = {
123
+ mark: markItem(reset.mark),
124
+ from_id: reset.fromId,
125
+ again: reset.again,
126
+ bulk: reset.bulk,
127
+ skipped: reset.skipped,
128
+ dry_run: reset.dry,
129
+ };
130
+ renderAnswer(this, outputFor(this), itemOf(item), () => {
131
+ const mark = reset.mark.throughId === reset.fromId
132
+ ? `the mark stays at ${reset.fromId}`
133
+ : `the mark ${reset.dry ? "would move" : "moved"} from ${reset.fromId} to ${reset.mark.throughId}`;
134
+ return (`${holder} ${reset.dry ? "would read" : "reads"} ${reset.again} emails again, and ${reset.bulk} bulk ` +
135
+ `ones as excerpts unless they are newsletters; ${reset.skipped} are skipped again; ${mark}\n`);
136
+ });
137
+ });
@@ -99,6 +99,8 @@ export interface MailOrderFinding extends Cited {
99
99
  invoice?: string;
100
100
  payment?: string;
101
101
  project?: string;
102
+ /** The order's page at the shop, http or https. */
103
+ sourceUrl?: string;
102
104
  reason: string;
103
105
  }
104
106
  /** A later mail about a stored order, found by its shop and number: shipped, delivered, cancelled, returned. */
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { EXTERNAL_ID_CHARS, externalIdOf, SOURCE_URL_CHARS, sourceUrlOf } from "../domain/external-links.js";
2
3
  import { INVOICE_DIRECTIONS, MONEY_DIRECTIONS } from "../domain/finance.js";
3
4
  import { MEMORY_KINDS, MEMORY_SCOPES } from "../domain/knowledge.js";
4
5
  import { formatLocator, parseLocator } from "../domain/locator.js";
@@ -59,8 +60,19 @@ const optional = (key, value, read) => (value === undefined ? {} : { [key]: read
59
60
  const one = (raw, ids) => {
60
61
  if (!raw || typeof raw !== "object")
61
62
  return invalid("a finding is an object");
62
- if (raw.kind === "task" || raw.kind === "fact")
63
+ if (raw.kind === "fact")
63
64
  return validateFinding(raw, RULES, ids);
65
+ if (raw.kind === "task") {
66
+ const { externalId, sourceUrl, ...task } = raw;
67
+ return {
68
+ ...validateFinding(task, RULES, ids),
69
+ ...optional("externalId", externalId, (value) => {
70
+ const { provider, id } = externalIdOf(findingText(value, EXTERNAL_ID_CHARS + 40));
71
+ return `${provider}:${id}`;
72
+ }),
73
+ ...optional("sourceUrl", sourceUrl, (value) => sourceUrlOf(findingText(value, SOURCE_URL_CHARS))),
74
+ };
75
+ }
64
76
  const id = findingId(raw.id, ids);
65
77
  const evidence = validEvidence(raw.evidence, RULES);
66
78
  const reference = (value, pattern, what) => {
@@ -247,6 +259,7 @@ const one = (raw, ids) => {
247
259
  "invoice",
248
260
  "payment",
249
261
  "project",
262
+ "sourceUrl",
250
263
  "reason",
251
264
  "evidence",
252
265
  ], RULES.singular);
@@ -266,6 +279,7 @@ const one = (raw, ids) => {
266
279
  ...optional("invoice", raw.invoice, (value) => reference(value, INVOICE, "an order's invoice")),
267
280
  ...optional("payment", raw.payment, (value) => reference(value, PAYMENT, "an order's payment")),
268
281
  ...optional("project", raw.project, (value) => reference(value, PROJECT, "a project")),
282
+ ...optional("sourceUrl", raw.sourceUrl, (value) => sourceUrlOf(findingText(value, SOURCE_URL_CHARS), "order.source-url")),
269
283
  reason: findingText(raw.reason, 2000),
270
284
  evidence,
271
285
  };
@@ -9,7 +9,7 @@ export { dropMissing, type Gone, tooManyGone } from "./gone.js";
9
9
  export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
10
10
  export { type MailIdsInput, mailIds } from "./ids.js";
11
11
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
12
- export { acknowledgeMark, advancePastSkipped, type BulkExcerpt, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, type MailMark, type MailWaiting, type MarkRead, mailWaiting, markOf, marksOf, readPastMark, type SkipReason, } from "./marks.js";
12
+ export { acknowledgeMark, advancePastSkipped, type BulkExcerpt, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, type MailMark, type MailWaiting, type MarkRead, type MarkRewind, mailWaiting, markOf, marksOf, type RewoundMark, readPastMark, rewindMark, type SkipReason, } from "./marks.js";
13
13
  export { BULK_TRIAGE_DEFAULTS, type BulkSorted, type BulkTriage, excerptOf, NEWSLETTER_DEFAULTS, NEWSLETTER_SENDERS, NEWSLETTER_WORDS, type NewsletterCues, newsletterSorter, ORDER_WORDS, } from "./newsletter.js";
14
14
  export { type MailTurn, mailTurn } from "./owed.js";
15
15
  export { type Address, htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
@@ -9,7 +9,7 @@ export { dropMissing, tooManyGone } from "./gone.js";
9
9
  export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
10
10
  export { mailIds } from "./ids.js";
11
11
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
12
- export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, mailWaiting, markOf, marksOf, readPastMark, } from "./marks.js";
12
+ export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, mailWaiting, markOf, marksOf, readPastMark, rewindMark, } from "./marks.js";
13
13
  export { BULK_TRIAGE_DEFAULTS, excerptOf, NEWSLETTER_DEFAULTS, NEWSLETTER_SENDERS, NEWSLETTER_WORDS, newsletterSorter, ORDER_WORDS, } from "./newsletter.js";
14
14
  export { mailTurn } from "./owed.js";
15
15
  export { htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, parseMail, } from "./parse.js";
@@ -89,3 +89,32 @@ export declare const advancePastSkipped: (store: MessageStore, holder: string, n
89
89
  * The same cursor twice changes nothing more.
90
90
  */
91
91
  export declare const acknowledgeMark: (store: MessageStore, mailbox: AccountKey, holder: string, cursor: string, now?: number) => Promise<MailMark>;
92
+ /** Where the owner moves a holder's mark back to: the emails received since a time, or the last n it was given. */
93
+ export type MarkRewind = {
94
+ since: number;
95
+ } | {
96
+ last: number;
97
+ };
98
+ export interface RewoundMark {
99
+ mark: MailMark;
100
+ /** Where the mark was before. */
101
+ fromId: number;
102
+ /** The emails between the new mark and the old one the holder reads whole again. */
103
+ again: number;
104
+ /** Bulk emails between them: excerpts again, unless they are surely newsletters. */
105
+ bulk: number;
106
+ /** Emails between them the holder skips again: drafts, envelopes, suspicious senders, partial mail. */
107
+ skipped: number;
108
+ /** The mark is moved only when not dry. */
109
+ dry: boolean;
110
+ }
111
+ /**
112
+ * Moves the holder's mark back, so its next reviews give it that mail again: past every email received since
113
+ * `since`, or past the last `last` emails it was given. The owner's alone: a holder only ever moves its own mark
114
+ * forward. The id is the store's insert order, so an older email stored after the earliest one picked is read again too;
115
+ * the answer counts them all.
116
+ */
117
+ export declare const rewindMark: (store: MessageStore, mailbox: AccountKey, holder: string, to: MarkRewind, { now, dry }?: {
118
+ now?: number;
119
+ dry?: boolean;
120
+ }) => Promise<RewoundMark>;
@@ -316,3 +316,89 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
316
316
  await store.sync.setSyncState(mailbox, key, JSON.stringify(next));
317
317
  return { holder, ...next, updatedAt: now };
318
318
  };
319
+ /**
320
+ * Moves the holder's mark back, so its next reviews give it that mail again: past every email received since
321
+ * `since`, or past the last `last` emails it was given. The owner's alone: a holder only ever moves its own mark
322
+ * forward. The id is the store's insert order, so an older email stored after the earliest one picked is read again too;
323
+ * the answer counts them all.
324
+ */
325
+ export const rewindMark = async (store, mailbox, holder, to, { now = Date.now(), dry = false } = {}) => {
326
+ const key = keyOf(holder);
327
+ const account = await accountOf(store, mailbox);
328
+ if (!account)
329
+ throw new CliError("not_found", `no mail of ${mailbox.account} is stored`);
330
+ const mark = await markOf(store, mailbox, holder);
331
+ if (!mark)
332
+ throw new CliError("not_found", `${holder} has no mark in ${mailbox.account}: its first review starts it`, {
333
+ reason: "mail.no-mark",
334
+ });
335
+ if ("last" in to && (!Number.isSafeInteger(to.last) || to.last < 1))
336
+ throw new CliError("validation_error", "--last takes a whole number of emails, 1 or more");
337
+ if ("since" in to && !Number.isFinite(to.since))
338
+ throw new CliError("validation_error", "--since-time takes a time");
339
+ const given = (email) => {
340
+ const said = verdict(email, now);
341
+ return said === "read" || said === "held" || said === "bulk";
342
+ };
343
+ const between = [];
344
+ let through = mark.throughId;
345
+ let before = mark.throughId + 1;
346
+ for (let counted = 0;;) {
347
+ const page = await store.mail.emailsBefore(account.id, before, MARK_BATCH);
348
+ if (page.length === 0) {
349
+ if ("last" in to && between.length > 0)
350
+ through = 0;
351
+ break;
352
+ }
353
+ let done = false;
354
+ for (const email of page) {
355
+ if ("last" in to) {
356
+ between.push(email);
357
+ if (given(email))
358
+ counted += 1;
359
+ if (counted === to.last) {
360
+ through = email.id - 1;
361
+ done = true;
362
+ break;
363
+ }
364
+ }
365
+ else {
366
+ // An email is stored after it arrives, so once stored before the time, none further back arrived after it.
367
+ if (email.storedAt < to.since - CLOCK_SLACK_MS) {
368
+ done = true;
369
+ break;
370
+ }
371
+ between.push(email);
372
+ if ((email.at ?? email.storedAt) >= to.since)
373
+ through = email.id - 1;
374
+ }
375
+ }
376
+ if (done)
377
+ break;
378
+ before = page.at(-1)?.id ?? 0;
379
+ }
380
+ const moved = between.filter((email) => email.id > through);
381
+ const count = (pick) => moved.filter((email) => pick(verdict(email, now))).length;
382
+ const answer = {
383
+ fromId: mark.throughId,
384
+ again: count((said) => said === "read" || said === "held"),
385
+ bulk: count((said) => said === "bulk"),
386
+ skipped: count((said) => said !== "read" && said !== "held" && said !== "bulk"),
387
+ dry,
388
+ };
389
+ if (through >= mark.throughId)
390
+ return { mark, ...answer };
391
+ const [last] = through > 0 ? await store.mail.emailsBefore(account.id, through + 1, 1) : [];
392
+ const at = last?.id === through ? last.at : null;
393
+ const next = {
394
+ throughId: through,
395
+ throughAt: at,
396
+ held: mark.held.filter((id) => id <= through),
397
+ suspicious: mark.suspicious.filter((id) => id <= through),
398
+ read: mark.read,
399
+ skipped: mark.skipped,
400
+ };
401
+ if (!dry)
402
+ await store.sync.setSyncState(mailbox, key, JSON.stringify(next));
403
+ return { mark: { holder, ...next, updatedAt: dry ? mark.updatedAt : now }, ...answer };
404
+ };
@@ -39,6 +39,10 @@ export interface TaskFinding extends FindingBase {
39
39
  dueEvidence?: string[];
40
40
  assignee?: string;
41
41
  assigneeEvidence?: string[];
42
+ /** Mail only: `<service>:<id>` in the outside service the mail came from, such as `avito:4512345678`. */
43
+ externalId?: string;
44
+ /** Mail only: the http or https page the task is about, such as the conversation the mail links to. */
45
+ sourceUrl?: string;
42
46
  }
43
47
  export type Finding = FactFinding | TaskFinding | PaymentFinding;
44
48
  export interface FindingsInput {
@@ -67,6 +67,8 @@ export interface MailActivityStore {
67
67
  * processing mark reads next. The id is the store's insert order, so mail stored late is never behind the mark.
68
68
  */
69
69
  emailsAfter(accountId: number, afterId: number, limit: number, ids?: readonly number[]): Promise<MarkedEmail[]>;
70
+ /** The account's live emails stored before `beforeId`, newest first, at most `limit`: what a mark moved back reads. */
71
+ emailsBefore(accountId: number, beforeId: number, limit: number): Promise<MarkedEmail[]>;
70
72
  /** The account's newest store id; 0 with no mail. */
71
73
  newestEmailId(accountId: number): Promise<number>;
72
74
  /** Every `sync_cursors` value of the account whose key starts with `prefix`. */
@@ -125,6 +125,10 @@ export const emailsAfter = (database, accountId, afterId, limit, ids = []) => {
125
125
  };
126
126
  export const mailActivity = (database) => ({
127
127
  emailsAfter: async (accountId, afterId, limit, ids) => emailsAfter(database, accountId, afterId, limit, ids),
128
+ emailsBefore: async (accountId, beforeId, limit) => database
129
+ .prepare(`${MARKED} WHERE e.id < ? AND e.account_id = ? AND e.deleted_at IS NULL ORDER BY e.id DESC LIMIT ?`)
130
+ .all(beforeId, accountId, limit)
131
+ .map(markedOf),
128
132
  newestEmailId: async (accountId) => Number(database.prepare("SELECT max(id) AS id FROM emails WHERE account_id = ?").get(accountId)?.id ?? 0),
129
133
  statesLike: async (accountId, prefix) => database
130
134
  .prepare("SELECT key, value, updated_at FROM sync_cursors WHERE account_id = ? AND substr(key, 1, ?) = ?")
@@ -93,6 +93,8 @@ export const findingsCoreOver = (context, kind) => {
93
93
  type: finding.type,
94
94
  description: `${finding.commitment}: ${finding.reason}`,
95
95
  ...(finding.dueAt ? { dueAt: new Date(finding.dueAt) } : {}),
96
+ ...(finding.externalId ? { externalId: finding.externalId } : {}),
97
+ ...(finding.sourceUrl ? { sourceUrl: finding.sourceUrl } : {}),
96
98
  }, agent);
97
99
  const id = taskRowOf(database, task.id);
98
100
  if (id === undefined)
@@ -67,6 +67,7 @@ export const MANIFEST = [
67
67
  { name: "20261104232153_version-49-relation-amounts", version: 49, minCompatible: 34 },
68
68
  { name: "20261105125314_version-50-orders", version: 50, minCompatible: 34 },
69
69
  { name: "20261105125315_version-50-orders-rules", version: 50, minCompatible: 34 },
70
+ { name: "20261105144549_version-51-task-links", version: 51, minCompatible: 34 },
70
71
  ];
71
72
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
72
73
  const migrations = [];
@@ -974,5 +974,15 @@ export const GENERATED = [
974
974
  "-- The store's code checks these first and says why; the triggers keep a stray write from another build or a hand\n-- edit from storing an order with an unknown status or purpose, or an amount without its currency.\nCREATE TRIGGER orders_valid_bi BEFORE INSERT ON orders\n WHEN new.customer_type NOT IN ('person', 'organization')\n OR new.purpose NOT IN ('personal', 'business', 'client', 'other')\n OR new.status NOT IN ('placed', 'shipped', 'delivered', 'cancelled', 'returned')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.total IS NULL) <> (new.currency IS NULL) OR new.total < 0 BEGIN\n SELECT RAISE(ABORT, 'orders: a person or organization customer, a known purpose, status and review, an amount with its currency');\nEND;",
975
975
  "CREATE TRIGGER orders_valid_bu BEFORE UPDATE ON orders\n WHEN new.customer_type NOT IN ('person', 'organization')\n OR new.purpose NOT IN ('personal', 'business', 'client', 'other')\n OR new.status NOT IN ('placed', 'shipped', 'delivered', 'cancelled', 'returned')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.total IS NULL) <> (new.currency IS NULL) OR new.total < 0 BEGIN\n SELECT RAISE(ABORT, 'orders: a person or organization customer, a known purpose, status and review, an amount with its currency');\nEND;"
976
976
  ]
977
+ },
978
+ {
979
+ "name": "20261105144549_version-51-task-links",
980
+ "statements": [
981
+ "ALTER TABLE `orders` ADD `source_url` text;",
982
+ "ALTER TABLE `tasks` ADD `provider` text;",
983
+ "ALTER TABLE `tasks` ADD `external_id` text;",
984
+ "ALTER TABLE `tasks` ADD `source_url` text;",
985
+ "CREATE INDEX `tasks_by_provider_external_id` ON `tasks` (`provider`,`external_id`) WHERE external_id IS NOT NULL;"
986
+ ]
977
987
  }
978
988
  ];
@@ -26,6 +26,8 @@ export interface Order extends Reviewed {
26
26
  fulfilledDate: string | null;
27
27
  invoice: string | null;
28
28
  payment: string | null;
29
+ /** The order's page at the shop, http or https. */
30
+ sourceUrl: string | null;
29
31
  }
30
32
  export interface OrderInput extends Written {
31
33
  /** `person:` or `organization:`; the owner's own person when left out. */
@@ -43,6 +45,8 @@ export interface OrderInput extends Written {
43
45
  fulfilledDate?: string;
44
46
  invoice?: string;
45
47
  payment?: string;
48
+ /** The order's page at the shop: an http or https address. */
49
+ sourceUrl?: string;
46
50
  }
47
51
  export interface OrderFilter {
48
52
  status?: OrderStatus;
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { sourceUrlOf } from "../../domain/external-links.js";
2
3
  import { FINANCE_REVIEWS } from "../../domain/finance.js";
3
4
  import { currencyOf, formatDecimal, minorUnits, scaleOf } from "../../domain/money.js";
4
5
  import { ORDER_PURPOSES, ORDER_STATUSES, orderTitle } from "../../domain/orders.js";
@@ -42,6 +43,7 @@ export const orderOperationsOver = (context) => {
42
43
  fulfilledDate: row.fulfilled_date == null ? null : String(row.fulfilled_date),
43
44
  invoice: row.invoice_id == null ? null : `invoice:${row.invoice_id}`,
44
45
  payment: row.payment_id == null ? null : `payment:${row.payment_id}`,
46
+ sourceUrl: row.source_url == null ? null : String(row.source_url),
45
47
  ...reviewedOf(row, "order"),
46
48
  };
47
49
  };
@@ -96,6 +98,7 @@ export const orderOperationsOver = (context) => {
96
98
  refuse("an order's amount is 0 or more", "money.amount");
97
99
  const orderDate = isoDate(input.orderDate, "an order's date");
98
100
  const fulfilledDate = isoDate(input.fulfilledDate, "an order's delivery date");
101
+ const sourceUrl = input.sourceUrl === undefined ? null : sourceUrlOf(input.sourceUrl, "order.source-url");
99
102
  const id = write(database, () => {
100
103
  if (number !== null && ordersOfShop(database, supplierName, number).length)
101
104
  refuse(`order ${number} from ${supplierName} is already stored; change its status instead`, "order.duplicate");
@@ -108,9 +111,9 @@ export const orderOperationsOver = (context) => {
108
111
  const made = Number(database
109
112
  .prepare("INSERT INTO orders (customer_type, customer_id, purpose, supplier_type, supplier_id, supplier_name, number, " +
110
113
  "currency, total, status, order_date, fulfilled_date, invoice_id, payment_id, project_id, review, " +
111
- "author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, metadata, created_at, updated_at) " +
112
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
113
- .get(customer.type, customer.id, purpose, supplier?.type ?? null, supplier?.id ?? null, supplierName, number, currency, total, status, orderDate, fulfilledDate, invoice, payment, by.project, by.review, by.actor.type, by.actor.id, by.model, by.confirmedType, by.confirmedId, by.source, by.metadata, at, at)?.id);
114
+ "author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, metadata, created_at, updated_at, " +
115
+ "source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
116
+ .get(customer.type, customer.id, purpose, supplier?.type ?? null, supplier?.id ?? null, supplierName, number, currency, total, status, orderDate, fulfilledDate, invoice, payment, by.project, by.review, by.actor.type, by.actor.id, by.model, by.confirmedType, by.confirmedId, by.source, by.metadata, at, at, sourceUrl)?.id);
114
117
  keepEvidence("order", made, by);
115
118
  return made;
116
119
  });
@@ -12747,6 +12747,54 @@ export declare const tasks: import("drizzle-orm/sqlite-core").SQLiteTableWithCol
12747
12747
  identity: undefined;
12748
12748
  generated: undefined;
12749
12749
  }, {}>;
12750
+ provider: import("drizzle-orm/sqlite-core").SQLiteColumn<{
12751
+ name: string;
12752
+ tableName: "tasks";
12753
+ dataType: "string";
12754
+ data: string;
12755
+ driverParam: string;
12756
+ notNull: false;
12757
+ hasDefault: false;
12758
+ isPrimaryKey: false;
12759
+ isAutoincrement: false;
12760
+ hasRuntimeDefault: false;
12761
+ enumValues: [string, ...string[]];
12762
+ baseColumn: never;
12763
+ identity: undefined;
12764
+ generated: undefined;
12765
+ }, {}>;
12766
+ externalId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
12767
+ name: string;
12768
+ tableName: "tasks";
12769
+ dataType: "string";
12770
+ data: string;
12771
+ driverParam: string;
12772
+ notNull: false;
12773
+ hasDefault: false;
12774
+ isPrimaryKey: false;
12775
+ isAutoincrement: false;
12776
+ hasRuntimeDefault: false;
12777
+ enumValues: [string, ...string[]];
12778
+ baseColumn: never;
12779
+ identity: undefined;
12780
+ generated: undefined;
12781
+ }, {}>;
12782
+ sourceUrl: import("drizzle-orm/sqlite-core").SQLiteColumn<{
12783
+ name: string;
12784
+ tableName: "tasks";
12785
+ dataType: "string";
12786
+ data: string;
12787
+ driverParam: string;
12788
+ notNull: false;
12789
+ hasDefault: false;
12790
+ isPrimaryKey: false;
12791
+ isAutoincrement: false;
12792
+ hasRuntimeDefault: false;
12793
+ enumValues: [string, ...string[]];
12794
+ baseColumn: never;
12795
+ identity: undefined;
12796
+ generated: undefined;
12797
+ }, {}>;
12750
12798
  };
12751
12799
  dialect: 'sqlite';
12752
12800
  isAlias: false;
@@ -15326,6 +15374,22 @@ export declare const orders: import("drizzle-orm/sqlite-core").SQLiteTableWithCo
15326
15374
  identity: undefined;
15327
15375
  generated: undefined;
15328
15376
  }, {}>;
15377
+ sourceUrl: import("drizzle-orm/sqlite-core").SQLiteColumn<{
15378
+ name: string;
15379
+ tableName: "orders";
15380
+ dataType: "string";
15381
+ data: string;
15382
+ driverParam: string;
15383
+ notNull: false;
15384
+ hasDefault: false;
15385
+ isPrimaryKey: false;
15386
+ isAutoincrement: false;
15387
+ hasRuntimeDefault: false;
15388
+ enumValues: [string, ...string[]];
15389
+ baseColumn: never;
15390
+ identity: undefined;
15391
+ generated: undefined;
15392
+ }, {}>;
15329
15393
  };
15330
15394
  dialect: 'sqlite';
15331
15395
  isAlias: false;
@@ -1271,6 +1271,9 @@ export const tasks = sqliteTable("tasks", {
1271
1271
  priorityScore: real("priority_score"),
1272
1272
  agent: text("agent"),
1273
1273
  revision: integer("revision").notNull().default(1),
1274
+ provider: text("provider"),
1275
+ externalId: text("external_id"),
1276
+ sourceUrl: text("source_url"),
1274
1277
  }, (table) => [
1275
1278
  unique().on(table.projectId, table.number),
1276
1279
  index("tasks_by_title_folded").on(table.titleFolded),
@@ -1283,6 +1286,7 @@ export const tasks = sqliteTable("tasks", {
1283
1286
  index("tasks_by_project_id_status_next_check_at").on(table.projectId, table.status, table.nextCheckAt),
1284
1287
  index("tasks_by_project_id_source_locator").on(table.projectId, table.sourceLocator),
1285
1288
  index("tasks_by_source_group").on(table.sourceGroup),
1289
+ index("tasks_by_provider_external_id").on(table.provider, table.externalId).where(sql `external_id IS NOT NULL`),
1286
1290
  index("tasks_by_parent_id").on(table.parentId),
1287
1291
  index("tasks_by_closed_by_type_closed_by_id").on(table.closedByType, table.closedById),
1288
1292
  index("tasks_by_author_type_author_id").on(table.authorType, table.authorId),
@@ -1521,6 +1525,7 @@ export const orders = sqliteTable("orders", {
1521
1525
  createdAt: integer("created_at").notNull(),
1522
1526
  updatedAt: integer("updated_at").notNull(),
1523
1527
  deletedAt: integer("deleted_at"),
1528
+ sourceUrl: text("source_url"),
1524
1529
  }, (table) => [
1525
1530
  index("orders_by_customer_type_customer_id").on(table.customerType, table.customerId),
1526
1531
  index("orders_by_supplier_type_supplier_id").on(table.supplierType, table.supplierId),
@@ -39,6 +39,10 @@ export interface TaskFields {
39
39
  project?: string;
40
40
  /** A project task's kind, bug, feature or chore. */
41
41
  type?: WorkTaskType;
42
+ /** `<service>:<id>`, the task's id in an outside service, such as `avito:4512345678`; `null` clears it. */
43
+ externalId?: string | null;
44
+ /** The http or https page the task is about, such as an Avito ad; `null` clears it. */
45
+ sourceUrl?: string | null;
42
46
  }
43
47
  export interface NewWorkTask extends Omit<TaskFields, "reason" | "project" | "type"> {
44
48
  /** `project:<id>` or the project's key. */
@@ -75,6 +79,9 @@ export interface TaskDetails {
75
79
  parent: string | null;
76
80
  /** What a task found in a message points at; null for a task made here. */
77
81
  source: string | null;
82
+ /** `<service>:<id>` in an outside service, such as `avito:4512345678`. */
83
+ externalId: string | null;
84
+ sourceUrl: string | null;
78
85
  dueAt: string | null;
79
86
  nextCheckAt: string | null;
80
87
  closedAt: string | null;
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { timezoneOf } from "@wirecat/cli-base/query";
3
+ import { externalIdOf, externalIdText, sourceUrlOf } from "../../domain/external-links.js";
3
4
  import { TASK_TYPES, WORK_TASK_TYPES } from "../../domain/knowledge.js";
4
5
  import { checkedScore, isClosedState, legacyPriority, PRIORITY_LABELS, priorityBand, priorityLabel, TASK_STATES, } from "../../tasks/index.js";
5
6
  import { folded } from "./folded.js";
@@ -136,6 +137,8 @@ export const workTaskOperationsOver = (context) => {
136
137
  revision: Number(row.revision),
137
138
  parent: row.parent_id == null ? null : reference(Number(row.parent_id)),
138
139
  source: row.source_locator == null ? null : String(row.source_locator),
140
+ externalId: externalIdText(row.provider, row.external_id),
141
+ sourceUrl: row.source_url == null ? null : String(row.source_url),
139
142
  dueAt: iso(row.due_at),
140
143
  nextCheckAt: iso(row.next_check_at),
141
144
  closedAt: iso(row.closed_at),
@@ -282,6 +285,13 @@ export const workTaskOperationsOver = (context) => {
282
285
  change("close_reason", reason);
283
286
  change("close_reason_folded", folded(reason));
284
287
  }
288
+ if (fields.externalId !== undefined) {
289
+ const external = fields.externalId === null ? null : externalIdOf(fields.externalId);
290
+ change("provider", external?.provider ?? null);
291
+ change("external_id", external?.id ?? null);
292
+ }
293
+ if (fields.sourceUrl !== undefined)
294
+ change("source_url", fields.sourceUrl === null ? null : sourceUrlOf(fields.sourceUrl));
285
295
  if (fields.category !== undefined)
286
296
  change("category", fields.category, "category");
287
297
  if (fields.type !== undefined && fields.type !== before.type) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.576.0",
3
+ "version": "0.577.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",