@detergent-software/conductor 1.0.0-dev.2 → 1.0.0-dev.21
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 +170 -0
- package/README.md +361 -11
- package/build/broker-cli.js +125 -0
- package/build/broker-cli.js.map +1 -0
- package/build/broker.js +154 -0
- package/build/broker.js.map +1 -0
- package/build/cli.js +610 -44
- package/build/cli.js.map +1 -1
- package/build/clock.js +17 -0
- package/build/clock.js.map +1 -0
- package/build/conditions.js +19 -13
- package/build/conditions.js.map +1 -1
- package/build/config.js +94 -1
- package/build/config.js.map +1 -1
- package/build/console.js +99 -0
- package/build/console.js.map +1 -0
- package/build/credential.js +52 -0
- package/build/credential.js.map +1 -0
- package/build/daemon.js +153 -2
- package/build/daemon.js.map +1 -1
- package/build/events.js +106 -0
- package/build/events.js.map +1 -0
- package/build/history.js +630 -0
- package/build/history.js.map +1 -0
- package/build/inbox.js +386 -0
- package/build/inbox.js.map +1 -0
- package/build/manager.js +94 -16
- package/build/manager.js.map +1 -1
- package/build/mod.js +51 -0
- package/build/mod.js.map +1 -0
- package/build/note.js +71 -0
- package/build/note.js.map +1 -0
- package/build/notes.js +172 -0
- package/build/notes.js.map +1 -0
- package/build/notify.js +422 -0
- package/build/notify.js.map +1 -0
- package/build/overrides.js +83 -0
- package/build/overrides.js.map +1 -0
- package/build/paths.js +10 -1
- package/build/paths.js.map +1 -1
- package/build/profile.js +68 -2
- package/build/profile.js.map +1 -1
- package/build/pushover.js +67 -0
- package/build/pushover.js.map +1 -0
- package/build/queue.js +183 -0
- package/build/queue.js.map +1 -0
- package/build/registry.js +17 -10
- package/build/registry.js.map +1 -1
- package/build/rule-actions.js +444 -0
- package/build/rule-actions.js.map +1 -0
- package/build/rule-input.js +17 -0
- package/build/rule-input.js.map +1 -0
- package/build/rules-service.js +402 -0
- package/build/rules-service.js.map +1 -0
- package/build/rules.js +850 -0
- package/build/rules.js.map +1 -0
- package/build/scheduler.js +274 -0
- package/build/scheduler.js.map +1 -0
- package/build/server.js +294 -0
- package/build/server.js.map +1 -1
- package/build/spend.js +50 -0
- package/build/spend.js.map +1 -1
- package/build/store.js +452 -4
- package/build/store.js.map +1 -1
- package/build/trackers.js +12 -0
- package/build/trackers.js.map +1 -0
- package/build/upgrade.js +36 -22
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +35 -41
- package/build/wait.js.map +1 -1
- package/build/watchers/ado-api.js +55 -0
- package/build/watchers/ado-api.js.map +1 -0
- package/build/watchers/ado.js +37 -49
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/boards-queue.js +120 -0
- package/build/watchers/boards-queue.js.map +1 -0
- package/build/watchers/boards-writes.js +83 -0
- package/build/watchers/boards-writes.js.map +1 -0
- package/build/watchers/boards.js +267 -0
- package/build/watchers/boards.js.map +1 -0
- package/build/watchers/github.js +5 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/index.js +1 -0
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-api.js +55 -0
- package/build/watchers/linear-api.js.map +1 -0
- package/build/watchers/linear-key.js +6 -45
- package/build/watchers/linear-key.js.map +1 -1
- package/build/watchers/linear-labels.js +43 -0
- package/build/watchers/linear-labels.js.map +1 -0
- package/build/watchers/linear-queue.js +96 -0
- package/build/watchers/linear-queue.js.map +1 -0
- package/build/watchers/linear.js +29 -18
- package/build/watchers/linear.js.map +1 -1
- package/build/why.js +78 -0
- package/build/why.js.map +1 -0
- package/mod/.claude-plugin/marketplace.json +6 -0
- package/mod/.claude-plugin/plugin.json +7 -0
- package/mod/README.md +17 -0
- package/mod/hooks/hooks.json +1 -0
- package/mod/hooks/model.ts +457 -0
- package/mod/hooks/register.tsx +543 -0
- package/mod/types/index.d.ts +57 -0
- package/package.json +6 -1
package/build/notes.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ConductorError } from "./errors.js";
|
|
3
|
+
import { notePrefixes, trackerKey } from "./profile.js";
|
|
4
|
+
import { ACTIVE_STATUSES } from "./store.js";
|
|
5
|
+
import { linearApi, LinearRequestError } from "./watchers/linear-api.js";
|
|
6
|
+
import { noteHeading } from "./watchers/linear.js";
|
|
7
|
+
// A note the tracker didn't take. `posted` says whether the worker may post it itself: `no` when
|
|
8
|
+
// the tracker refused it or was never asked, `unknown` when the request to write it may have landed.
|
|
9
|
+
export class NotePostError extends ConductorError {
|
|
10
|
+
posted;
|
|
11
|
+
constructor(message, posted, details = []) {
|
|
12
|
+
super(message, details);
|
|
13
|
+
this.name = 'NotePostError';
|
|
14
|
+
this.posted = posted;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const ITEM = /^[A-Za-z]+-\d+$/;
|
|
18
|
+
// Worker protocol v1's write-through note (design note, section 4): post it, then record the phase
|
|
19
|
+
// on the worker and the item at once, so the fleet shows it before any watcher pass.
|
|
20
|
+
export function noteService({ store, tracker }) {
|
|
21
|
+
return {
|
|
22
|
+
post: async ({ body, item, worker: workerId }) => {
|
|
23
|
+
if (!ITEM.test(item))
|
|
24
|
+
throw new ConductorError(`"${item}" is not a tracker item, such as ATK-311`);
|
|
25
|
+
const named = workerId === undefined ? null : store.getWorker(workerId);
|
|
26
|
+
if (workerId !== undefined && (!named || !ACTIVE_STATUSES.includes(named.status))) {
|
|
27
|
+
throw new ConductorError(`no worker ${workerId} in flight`);
|
|
28
|
+
}
|
|
29
|
+
const project = named ? projectNamed(store, named.project) : projectFor(store, item);
|
|
30
|
+
const { aliases, work } = project.profile.skills;
|
|
31
|
+
const heading = noteHeading(body, notePrefixes(project.profile));
|
|
32
|
+
if (heading === null) {
|
|
33
|
+
throw new ConductorError(`a note starts with its heading, such as "## ${work} · Progress"`, [
|
|
34
|
+
`${project.name}'s work skill is ${work}${aliases.length > 0 ? ` (also read: ${aliases.join(', ')})` : ''}`,
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
const { kind, line, phase } = heading;
|
|
38
|
+
const key = item.toUpperCase();
|
|
39
|
+
const comment = await tracker.comment(key, body, project.profile);
|
|
40
|
+
// The phase is the item's worker's: the one named when it works this item, else the project's
|
|
41
|
+
// live worker on it (a probe works no item of its own).
|
|
42
|
+
const works = (w) => w.item.toUpperCase() === key;
|
|
43
|
+
const worker = named
|
|
44
|
+
? works(named) ? named : null
|
|
45
|
+
: (store.listWorkers(ACTIVE_STATUSES).find((w) => !w.probe && w.project === project.name && works(w)) ?? null);
|
|
46
|
+
const result = { commentId: comment.id, item: key, phase, project: project.name, url: comment.url };
|
|
47
|
+
// The note is on the tracker now: a failure to record it must not read as "not posted".
|
|
48
|
+
try {
|
|
49
|
+
const recorded = store.transaction(() => {
|
|
50
|
+
store.saveItemNote({ item: key, phase, project: project.name });
|
|
51
|
+
const updated = worker ? store.updateWorker(worker.id, { phase }) : null;
|
|
52
|
+
// The heading's kind and the note's commands and links, for the inbox.
|
|
53
|
+
store.appendEvent({
|
|
54
|
+
data: {
|
|
55
|
+
commands: noteCommands(body),
|
|
56
|
+
comment: comment.id,
|
|
57
|
+
kind,
|
|
58
|
+
line,
|
|
59
|
+
links: noteLinks(body),
|
|
60
|
+
phase,
|
|
61
|
+
...(phase === 'Plan' && planTier(body) !== null ? { tier: planTier(body) } : {}),
|
|
62
|
+
url: comment.url,
|
|
63
|
+
...(updated ? { name: updated.name, worker: updated.id } : {}),
|
|
64
|
+
},
|
|
65
|
+
kind: 'note.posted',
|
|
66
|
+
project: project.name,
|
|
67
|
+
subject: key,
|
|
68
|
+
});
|
|
69
|
+
return updated;
|
|
70
|
+
});
|
|
71
|
+
return { ...result, worker: recorded };
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return { ...result, recordError: error instanceof Error ? error.message : String(error), worker: null };
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const LOOKUP = 'query NoteIssue($id: String!) { issue(id: $id) { id } }';
|
|
80
|
+
const CREATE = 'mutation Note($input: CommentCreateInput!) { commentCreate(input: $input) { success comment { id url } } }';
|
|
81
|
+
const lookupAnswer = z.object({ issue: z.object({ id: z.string() }).nullable() });
|
|
82
|
+
const createAnswer = z.object({
|
|
83
|
+
commentCreate: z.object({ comment: z.object({ id: z.string(), url: z.string().nullable().optional() }).nullable(), success: z.boolean() }),
|
|
84
|
+
});
|
|
85
|
+
// Linear: the issue's id from its identifier, then `commentCreate`. The key needs Create comments
|
|
86
|
+
// as well as Read; a read-only key is refused, and nothing is posted.
|
|
87
|
+
export function linearNotes(options = {}) {
|
|
88
|
+
const request = linearApi(options);
|
|
89
|
+
// `writes` marks the request that creates the comment: when its answer is lost, it may have
|
|
90
|
+
// landed. Only a refusal (or no key) says it didn't.
|
|
91
|
+
const ask = async (query, variables, writes) => {
|
|
92
|
+
try {
|
|
93
|
+
return await request(query, variables);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (!(error instanceof LinearRequestError))
|
|
97
|
+
throw error;
|
|
98
|
+
const sure = error.kind === 'no-key' || error.kind === 'refused';
|
|
99
|
+
throw new NotePostError(error.message, writes && !sure ? 'unknown' : 'no', error.details);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
// An issue's id never changes, so each item is looked up once.
|
|
103
|
+
const ids = new Map();
|
|
104
|
+
return {
|
|
105
|
+
comment: async (item, text) => {
|
|
106
|
+
let id = ids.get(item);
|
|
107
|
+
if (id === undefined) {
|
|
108
|
+
const { issue } = lookupAnswer.parse(await ask(LOOKUP, { id: item }, false));
|
|
109
|
+
if (!issue)
|
|
110
|
+
throw new NotePostError(`Linear has no issue ${item}`, 'no');
|
|
111
|
+
id = issue.id;
|
|
112
|
+
ids.set(item, id);
|
|
113
|
+
}
|
|
114
|
+
const created = createAnswer.safeParse(await ask(CREATE, { input: { body: text, issueId: id } }, true));
|
|
115
|
+
if (!created.success)
|
|
116
|
+
throw new NotePostError(`Linear's answer to the comment on ${item} didn't read`, 'unknown');
|
|
117
|
+
const { commentCreate } = created.data;
|
|
118
|
+
if (!commentCreate.success || !commentCreate.comment)
|
|
119
|
+
throw new NotePostError(`Linear didn't create the comment on ${item}`, 'no');
|
|
120
|
+
return { id: commentCreate.comment.id, url: commentCreate.comment.url ?? null };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Fences whose lines are commands to run. An untagged or other block is output, a log or a diff.
|
|
125
|
+
const SHELL_FENCES = new Set(['bash', 'cmd', 'console', 'powershell', 'ps1', 'pwsh', 'sh', 'shell']);
|
|
126
|
+
// The exact commands a note hands over: the inline code on its heading (such as `npm stage approve
|
|
127
|
+
// <id> --otp=<code>` on an npm-otp Handoff), and each line of its shell-tagged fenced code blocks.
|
|
128
|
+
export function noteCommands(body) {
|
|
129
|
+
const lines = body.split(/\r?\n/);
|
|
130
|
+
const heading = lines.find((l) => l.trim() !== '') ?? '';
|
|
131
|
+
const commands = [...heading.matchAll(/`([^`]+)`/g)].map((m) => m[1]?.trim() ?? '');
|
|
132
|
+
// The open fence (``` or ~~~, three or more) and whether its block holds commands.
|
|
133
|
+
let fence = null;
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const marker = /^\s*(`{3,}|~{3,})\s*([\w-]*)/.exec(line);
|
|
136
|
+
if (fence === null && marker?.[1])
|
|
137
|
+
fence = { marker: marker[1], shell: SHELL_FENCES.has((marker[2] ?? '').toLowerCase()) };
|
|
138
|
+
else if (fence !== null && marker?.[1]?.startsWith(fence.marker) && marker[2] === '')
|
|
139
|
+
fence = null;
|
|
140
|
+
else if (fence?.shell)
|
|
141
|
+
commands.push(line.trim());
|
|
142
|
+
}
|
|
143
|
+
return [...new Set(commands.filter((c) => c !== ''))];
|
|
144
|
+
}
|
|
145
|
+
// Every http(s) URL in a note, once each, without trailing punctuation.
|
|
146
|
+
export function noteLinks(body) {
|
|
147
|
+
return [...new Set([...body.matchAll(/https?:\/\/[^\s<>()[\]`"']+/g)].map((m) => m[0].replace(/[.,;:!?*_]+$/, '')))];
|
|
148
|
+
}
|
|
149
|
+
// A Plan note's size tier, as work-issue writes it ("**Size tier: S.**", "Tier: M"), for the rules'
|
|
150
|
+
// `plan.tier` and `item.tier`; null when it names none.
|
|
151
|
+
export function planTier(body) {
|
|
152
|
+
return /\b(?:size\s+)?tier\b[*_\s:]*([SML])\b/i.exec(body)?.[1]?.toUpperCase() ?? null;
|
|
153
|
+
}
|
|
154
|
+
// The registered project whose tracker key is the item's key prefix (ATK-311 is team ATK).
|
|
155
|
+
function projectFor(store, item) {
|
|
156
|
+
const team = item.split('-')[0]?.toUpperCase();
|
|
157
|
+
const found = store.listProjects().filter((p) => p.profile && trackerKey(p.profile).toUpperCase() === team);
|
|
158
|
+
const [only] = found;
|
|
159
|
+
if (found.length !== 1 || !only?.profile) {
|
|
160
|
+
throw new ConductorError(found.length === 0 ? `no registered project tracks ${item}` : `several projects track ${item}`, [
|
|
161
|
+
'pass --worker <worker> to name the worker it is for',
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
return { name: only.name, profile: only.profile };
|
|
165
|
+
}
|
|
166
|
+
function projectNamed(store, name) {
|
|
167
|
+
const found = store.listProjects().find((p) => p.name === name);
|
|
168
|
+
if (!found?.profile)
|
|
169
|
+
throw new ConductorError(`project "${name}" has a problem: ${found?.problem ?? 'not registered'}`);
|
|
170
|
+
return { name: found.name, profile: found.profile };
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=notes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notes.js","sourceRoot":"","sources":["../src/notes.ts"],"names":[],"mappings":"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"}
|
package/build/notify.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { parseLocalTime } from "./clock.js";
|
|
3
|
+
import { BUILT_IN_PROFILES, CHANNELS, DAYS } from "./config.js";
|
|
4
|
+
import { ConductorError } from "./errors.js";
|
|
5
|
+
import { pushMessage } from "./inbox.js";
|
|
6
|
+
import { NO_EXPIRY } from "./store.js";
|
|
7
|
+
export const PROFILE_OVERRIDE = 'notify.profile';
|
|
8
|
+
// An override's value that hands the profile back to config.json's `notify.profile`.
|
|
9
|
+
const CONFIG_PROFILE = '';
|
|
10
|
+
// A channel as the notifier last saw it work: Pushover by its own answers, Remote Control by what
|
|
11
|
+
// the conductor mod reports back from PushNotification.
|
|
12
|
+
export const channelStateSchema = z.object({
|
|
13
|
+
detail: z.string().nullable(),
|
|
14
|
+
since: z.string().nullable(),
|
|
15
|
+
// skipped: the last push wasn't sent to the phone on purpose (Jason was at the terminal).
|
|
16
|
+
state: z.enum(['failing', 'inactive', 'ok', 'skipped', 'unknown']),
|
|
17
|
+
});
|
|
18
|
+
// The states the conductor mod reports for a Remote Control push.
|
|
19
|
+
export const REPORTED_STATES = ['failing', 'inactive', 'ok', 'skipped'];
|
|
20
|
+
// GET /notify.
|
|
21
|
+
export const notifyStatusSchema = z.object({
|
|
22
|
+
channels: z.object({ pushover: channelStateSchema, 'remote-control': channelStateSchema }),
|
|
23
|
+
// Held by quiet hours, waiting for the summary.
|
|
24
|
+
held: z.number(),
|
|
25
|
+
profile: z.string(),
|
|
26
|
+
profiles: z.array(z.string()),
|
|
27
|
+
// `conduct notify profile` set it, until this time (null: until replaced); null when it's config's.
|
|
28
|
+
profileSetBy: z.enum(['config', 'conduct notify profile']),
|
|
29
|
+
profileUntil: z.string().nullable(),
|
|
30
|
+
quietHours: z.object({ from: z.string(), to: z.string() }).nullable(),
|
|
31
|
+
quietNow: z.boolean(),
|
|
32
|
+
});
|
|
33
|
+
// POST /notify/test.
|
|
34
|
+
export const testResultSchema = z.object({
|
|
35
|
+
channels: z.array(z.enum(CHANNELS)),
|
|
36
|
+
profile: z.string(),
|
|
37
|
+
quietNow: z.boolean(),
|
|
38
|
+
results: z.array(z.object({ channel: z.enum(CHANNELS), detail: z.string(), ok: z.boolean() })),
|
|
39
|
+
});
|
|
40
|
+
const raisedData = z.looseObject({ id: z.number() });
|
|
41
|
+
// True when the local time falls in quiet hours, which may run past midnight.
|
|
42
|
+
export function isQuiet(quietHours, at) {
|
|
43
|
+
return quietHours !== null && inRange(quietHours.from, quietHours.to, at);
|
|
44
|
+
}
|
|
45
|
+
// The notifier (design note, section 10): routes each new inbox item by the active profile's rules
|
|
46
|
+
// to Remote Control (a `notify.requested` event the conductor mod pushes) and Pushover (sent from
|
|
47
|
+
// here). Quiet hours hold pushes and send one summary when they end; a push of the same item, kind
|
|
48
|
+
// and text within dedupeMinutes is skipped; an open item is reminded until it resolves.
|
|
49
|
+
export function notifierService({ config, log, now = () => new Date(), pushover, store }) {
|
|
50
|
+
const { notify, quietHours } = config;
|
|
51
|
+
const profiles = { ...BUILT_IN_PROFILES, ...notify.profiles };
|
|
52
|
+
const channels = {
|
|
53
|
+
pushover: { detail: null, since: null, state: 'unknown' },
|
|
54
|
+
'remote-control': { detail: null, since: null, state: 'unknown' },
|
|
55
|
+
};
|
|
56
|
+
const inFlight = new Set();
|
|
57
|
+
let timer;
|
|
58
|
+
let ticking = null;
|
|
59
|
+
let lastProfile = null;
|
|
60
|
+
let stopped = false;
|
|
61
|
+
const track = (work) => {
|
|
62
|
+
inFlight.add(work);
|
|
63
|
+
void work.finally(() => inFlight.delete(work));
|
|
64
|
+
return work;
|
|
65
|
+
};
|
|
66
|
+
// Own names only: `constructor` or `toString` would otherwise pass as a profile.
|
|
67
|
+
const isProfile = (name) => Object.hasOwn(profiles, name);
|
|
68
|
+
const active = () => {
|
|
69
|
+
const override = store.liveOverride(PROFILE_OVERRIDE);
|
|
70
|
+
if (override && override.value !== CONFIG_PROFILE && isProfile(override.value)) {
|
|
71
|
+
return { name: override.value, setBy: 'conduct notify profile', until: override.until === NO_EXPIRY ? null : override.until };
|
|
72
|
+
}
|
|
73
|
+
return { name: notify.profile, setBy: 'config', until: null };
|
|
74
|
+
};
|
|
75
|
+
const route = (item, at) => {
|
|
76
|
+
const { name } = active();
|
|
77
|
+
const rules = isProfile(name) ? (profiles[name] ?? []) : [];
|
|
78
|
+
const index = rules.findIndex((r) => matches(r, item, at));
|
|
79
|
+
const rule = rules[index];
|
|
80
|
+
return rule ? { channels: unique(rule.to), remind: rule.remind ?? notify.remind, rule: index } : { channels: [], remind: notify.remind, rule: null };
|
|
81
|
+
};
|
|
82
|
+
const setChannel = (channel, state, detail) => {
|
|
83
|
+
const current = channels[channel];
|
|
84
|
+
if (current.state !== state) {
|
|
85
|
+
channels[channel] = { detail, since: now().toISOString(), state };
|
|
86
|
+
store.appendEvent({ data: { channel, detail, state }, kind: 'notify.channel' });
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
current.detail = detail;
|
|
90
|
+
}
|
|
91
|
+
return channels[channel];
|
|
92
|
+
};
|
|
93
|
+
// One push on each channel. Remote Control is a request the conductor mod carries out; Pushover
|
|
94
|
+
// goes out from here, and its answer is the event.
|
|
95
|
+
const deliver = async (to, message, extra = {}) => Promise.all(to.map(async (channel) => {
|
|
96
|
+
const base = { channel, inbox: message.inbox, kind: message.kind, ...extra };
|
|
97
|
+
if (channel === 'remote-control') {
|
|
98
|
+
store.appendEvent({ data: { ...base, message: message.text }, kind: 'notify.requested', project: message.project, subject: message.item });
|
|
99
|
+
return { channel, result: { ok: true } };
|
|
100
|
+
}
|
|
101
|
+
const result = await track(pushover({
|
|
102
|
+
message: [message.text, ...message.commands.map((c) => `$ ${c}`)].join('\n'),
|
|
103
|
+
priority: notify.pushover.priority,
|
|
104
|
+
title: message.title,
|
|
105
|
+
...(message.link ? { url: message.link, urlTitle: 'open' } : {}),
|
|
106
|
+
}));
|
|
107
|
+
if (result.ok) {
|
|
108
|
+
setChannel(channel, 'ok', null);
|
|
109
|
+
store.appendEvent({ data: base, kind: 'notify.sent', project: message.project, subject: message.item });
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
setChannel(channel, 'failing', result.error);
|
|
113
|
+
log.log('warn', 'a Pushover push failed', { error: result.error, inbox: message.inbox });
|
|
114
|
+
store.appendEvent({ data: { ...base, error: result.error }, kind: 'notify.failed', project: message.project, subject: message.item });
|
|
115
|
+
}
|
|
116
|
+
return { channel, result };
|
|
117
|
+
}));
|
|
118
|
+
const onRaised = async (event) => {
|
|
119
|
+
const data = raisedData.safeParse(event.data);
|
|
120
|
+
if (!data.success)
|
|
121
|
+
return;
|
|
122
|
+
const item = store.getInbox(data.data.id);
|
|
123
|
+
if (!item || store.getNotifyState(item.id))
|
|
124
|
+
return;
|
|
125
|
+
const at = now();
|
|
126
|
+
const routed = route(item, at);
|
|
127
|
+
const state = {
|
|
128
|
+
channels: routed.channels,
|
|
129
|
+
dedupeKey: JSON.stringify([item.project, item.item?.toUpperCase() ?? null, item.kind, item.text]),
|
|
130
|
+
heldAt: null,
|
|
131
|
+
inbox: item.id,
|
|
132
|
+
lastSentAt: null,
|
|
133
|
+
remindAfterMinutes: routed.remind.afterMinutes,
|
|
134
|
+
remindTimes: routed.remind.times,
|
|
135
|
+
sends: 0,
|
|
136
|
+
};
|
|
137
|
+
const earlier = notify.dedupeMinutes > 0 ? store.lastNotified(state.dedupeKey, new Date(at.getTime() - notify.dedupeMinutes * 60_000).toISOString()) : null;
|
|
138
|
+
// An earlier push stands for this one only where it went: after a switch to away, the same ask
|
|
139
|
+
// still goes to Pushover.
|
|
140
|
+
const prior = earlier && routed.channels.every((c) => earlier.channels.includes(c)) ? earlier : null;
|
|
141
|
+
const outcome = routed.channels.length === 0 ? 'none'
|
|
142
|
+
: isQuiet(quietHours, at) ? 'held'
|
|
143
|
+
: prior ? 'deduped'
|
|
144
|
+
: 'sent';
|
|
145
|
+
if (outcome === 'held')
|
|
146
|
+
state.heldAt = at.toISOString();
|
|
147
|
+
if (outcome === 'held' || outcome === 'sent') {
|
|
148
|
+
// An older copy that never reached the phone (held for the summary, or a first push that
|
|
149
|
+
// failed) gives way to this one, so the ask is summarized or tried again once.
|
|
150
|
+
for (const older of store.liveNotifyStates()) {
|
|
151
|
+
const unsent = older.heldAt !== null || (older.sends === 0 && older.lastSentAt !== null);
|
|
152
|
+
if (older.dedupeKey === state.dedupeKey && unsent)
|
|
153
|
+
store.saveNotifyState({ ...older, channels: [], heldAt: null });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (outcome === 'deduped' && prior) {
|
|
157
|
+
// The earlier push stands for this one, and its reminders carry on here alone: the earlier
|
|
158
|
+
// item, if still open, would otherwise remind too, each reminder arriving twice.
|
|
159
|
+
state.sends = prior.sends;
|
|
160
|
+
state.lastSentAt = prior.lastSentAt;
|
|
161
|
+
store.saveNotifyState({ ...prior, channels: [] });
|
|
162
|
+
}
|
|
163
|
+
if (outcome === 'sent') {
|
|
164
|
+
state.sends = 1;
|
|
165
|
+
state.lastSentAt = at.toISOString();
|
|
166
|
+
}
|
|
167
|
+
store.saveNotifyState(state);
|
|
168
|
+
store.appendEvent({
|
|
169
|
+
data: { channels: routed.channels, inbox: item.id, outcome, profile: active().name, rule: routed.rule, ...(prior && outcome === 'deduped' ? { dedupedOf: prior.inbox } : {}) },
|
|
170
|
+
kind: 'notify.routed',
|
|
171
|
+
project: item.project,
|
|
172
|
+
subject: item.item,
|
|
173
|
+
});
|
|
174
|
+
if (outcome !== 'sent')
|
|
175
|
+
return;
|
|
176
|
+
const results = await deliver(routed.channels, messageOf(item));
|
|
177
|
+
// Nothing arrived: it isn't a push to dedupe against, and the reminder pass tries it again.
|
|
178
|
+
if (results.every((r) => !r.result.ok))
|
|
179
|
+
store.saveNotifyState({ ...state, sends: 0 });
|
|
180
|
+
};
|
|
181
|
+
// Quiet hours are over: one push per channel naming the held items still open, which are then
|
|
182
|
+
// reminded as if pushed now.
|
|
183
|
+
const summarize = async (held, at) => {
|
|
184
|
+
const byChannel = new Map();
|
|
185
|
+
let resolved = 0;
|
|
186
|
+
for (const state of held) {
|
|
187
|
+
const item = store.getInbox(state.inbox);
|
|
188
|
+
if (!item || item.resolvedAt !== null) {
|
|
189
|
+
resolved++;
|
|
190
|
+
store.saveNotifyState({ ...state, heldAt: null });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const routed = route(item, at);
|
|
194
|
+
for (const channel of routed.channels)
|
|
195
|
+
byChannel.set(channel, [...(byChannel.get(channel) ?? []), item]);
|
|
196
|
+
store.saveNotifyState({
|
|
197
|
+
...state,
|
|
198
|
+
channels: routed.channels,
|
|
199
|
+
heldAt: null,
|
|
200
|
+
lastSentAt: routed.channels.length > 0 ? at.toISOString() : state.lastSentAt,
|
|
201
|
+
remindAfterMinutes: routed.remind.afterMinutes,
|
|
202
|
+
remindTimes: routed.remind.times,
|
|
203
|
+
sends: routed.channels.length > 0 ? 1 : state.sends,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
await Promise.all([...byChannel.entries()].map(([channel, items]) => {
|
|
207
|
+
const text = summaryText(items, resolved);
|
|
208
|
+
return deliver([channel], { commands: [], inbox: null, item: null, kind: 'summary', link: null, project: null, text, title: 'conductor · since quiet hours' }, { held: items.map((i) => i.id), resolved });
|
|
209
|
+
}));
|
|
210
|
+
};
|
|
211
|
+
// Reminders, on the channels the profile in force now gives the item. A first push that reached
|
|
212
|
+
// no channel (sends 0) is tried again at the same interval, without using up a reminder.
|
|
213
|
+
const remind = async (states, at) => {
|
|
214
|
+
for (const state of states) {
|
|
215
|
+
if (state.heldAt !== null || state.lastSentAt === null || state.channels.length === 0)
|
|
216
|
+
continue;
|
|
217
|
+
const retry = state.sends === 0;
|
|
218
|
+
const reminder = state.sends; // the first push is send 1, so reminder n follows send n
|
|
219
|
+
if (!retry && reminder > state.remindTimes)
|
|
220
|
+
continue;
|
|
221
|
+
if (at.getTime() - Date.parse(state.lastSentAt) < state.remindAfterMinutes * 60_000)
|
|
222
|
+
continue;
|
|
223
|
+
const item = store.getInbox(state.inbox);
|
|
224
|
+
if (!item || item.resolvedAt !== null)
|
|
225
|
+
continue;
|
|
226
|
+
const to = route(item, at).channels;
|
|
227
|
+
if (to.length === 0)
|
|
228
|
+
continue;
|
|
229
|
+
const message = messageOf(item);
|
|
230
|
+
const prefix = `reminder ${reminder}/${state.remindTimes}: `;
|
|
231
|
+
const results = await deliver(to, retry ? message : { ...message, text: pushMessage({ item: item.item, text: `${prefix}${item.text}` }), title: `${prefix}${message.title}` }, retry ? { retry: true } : { reminder });
|
|
232
|
+
const arrived = results.some((r) => r.result.ok);
|
|
233
|
+
store.saveNotifyState({ ...state, channels: to, lastSentAt: at.toISOString(), sends: retry && !arrived ? 0 : state.sends + 1 });
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const tick = async () => {
|
|
237
|
+
const at = now();
|
|
238
|
+
const profile = active();
|
|
239
|
+
if (lastProfile !== null && lastProfile !== profile.name) {
|
|
240
|
+
store.appendEvent({ data: { from: lastProfile, profile: profile.name, reason: 'the timed profile ran out' }, kind: 'notify.profile' });
|
|
241
|
+
}
|
|
242
|
+
lastProfile = profile.name;
|
|
243
|
+
if (isQuiet(quietHours, at))
|
|
244
|
+
return;
|
|
245
|
+
const live = store.liveNotifyStates();
|
|
246
|
+
const held = live.filter((s) => s.heldAt !== null);
|
|
247
|
+
if (held.length > 0)
|
|
248
|
+
await summarize(held, at);
|
|
249
|
+
await remind(live.filter((s) => s.heldAt === null), at);
|
|
250
|
+
};
|
|
251
|
+
const status = () => {
|
|
252
|
+
const profile = active();
|
|
253
|
+
return {
|
|
254
|
+
channels: { pushover: { ...channels.pushover }, 'remote-control': { ...channels['remote-control'] } },
|
|
255
|
+
held: store.liveNotifyStates().filter((s) => s.heldAt !== null).length,
|
|
256
|
+
profile: profile.name,
|
|
257
|
+
profiles: Object.keys(profiles).sort(),
|
|
258
|
+
profileSetBy: profile.setBy,
|
|
259
|
+
profileUntil: profile.until,
|
|
260
|
+
quietHours,
|
|
261
|
+
quietNow: isQuiet(quietHours, now()),
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
announce: async (to, { item, project, text }, by) => {
|
|
266
|
+
if (isQuiet(quietHours, now()))
|
|
267
|
+
return 'quiet hours';
|
|
268
|
+
const channels = typeof to === 'string' ? [to] : route({ kind: to.kind, project }, now()).channels;
|
|
269
|
+
if (channels.length === 0)
|
|
270
|
+
return 'its profile sends it nowhere';
|
|
271
|
+
const results = await deliver(channels, { commands: [], inbox: null, item, kind: 'rule', link: null, project, text: pushMessage({ item, text }), title: `conductor · ${item ?? 'rule'}` }, { by });
|
|
272
|
+
return deliveryProblem(results);
|
|
273
|
+
},
|
|
274
|
+
// Nothing after stop: the store closes behind it.
|
|
275
|
+
observe: (event) => (event.kind === 'inbox.raised' && !stopped ? track(onRaised(event)) : Promise.resolve()),
|
|
276
|
+
remindNow: async (id, by) => {
|
|
277
|
+
const item = store.getInbox(id);
|
|
278
|
+
if (!item || item.resolvedAt !== null)
|
|
279
|
+
return 'the inbox item is resolved';
|
|
280
|
+
if (isQuiet(quietHours, now()))
|
|
281
|
+
return 'quiet hours';
|
|
282
|
+
const to = route(item, now()).channels;
|
|
283
|
+
if (to.length === 0)
|
|
284
|
+
return 'its profile sends it nowhere';
|
|
285
|
+
const message = messageOf(item);
|
|
286
|
+
const results = await deliver(to, { ...message, text: pushMessage({ item: item.item, text: `reminder: ${item.text}` }), title: `reminder: ${message.title}` }, { by, reminder: 'rule' });
|
|
287
|
+
return deliveryProblem(results);
|
|
288
|
+
},
|
|
289
|
+
report: (channel, result, state) => {
|
|
290
|
+
// PushNotification says "Mobile push not sent (Remote Control inactive)" when no phone is connected.
|
|
291
|
+
const seen = state ?? (/remote control inactive/i.test(result) ? 'inactive' : 'ok');
|
|
292
|
+
return setChannel(channel, seen, seen === 'ok' ? null : result);
|
|
293
|
+
},
|
|
294
|
+
setProfile: (name, until, by = 'conduct notify profile') => {
|
|
295
|
+
if (!isProfile(name)) {
|
|
296
|
+
throw new ConductorError(`no notification profile "${name}"`, [`profiles: ${Object.keys(profiles).sort().join(', ')}`]);
|
|
297
|
+
}
|
|
298
|
+
if (until !== null && until.getTime() <= now().getTime())
|
|
299
|
+
throw new ConductorError(`--until ${until.toISOString()} is in the past`);
|
|
300
|
+
const from = active().name;
|
|
301
|
+
// Config's own profile with no end hands the choice back to config.json.
|
|
302
|
+
const value = until === null && name === notify.profile ? CONFIG_PROFILE : name;
|
|
303
|
+
store.setOverride({ kind: PROFILE_OVERRIDE, project: null, setBy: by, until: until?.toISOString() ?? NO_EXPIRY, value });
|
|
304
|
+
lastProfile = name;
|
|
305
|
+
store.appendEvent({ data: { by, from, profile: name, until: until?.toISOString() ?? null }, kind: 'notify.profile', rule: by });
|
|
306
|
+
return status();
|
|
307
|
+
},
|
|
308
|
+
start: () => {
|
|
309
|
+
lastProfile = active().name;
|
|
310
|
+
timer = setInterval(() => {
|
|
311
|
+
if (ticking)
|
|
312
|
+
return;
|
|
313
|
+
ticking = track(tick()
|
|
314
|
+
.catch((error) => log.log('error', 'the notifier tick failed', { error: String(error) }))
|
|
315
|
+
.finally(() => (ticking = null)));
|
|
316
|
+
}, notify.tickSeconds * 1000);
|
|
317
|
+
timer.unref();
|
|
318
|
+
},
|
|
319
|
+
status,
|
|
320
|
+
stop: async () => {
|
|
321
|
+
stopped = true;
|
|
322
|
+
clearInterval(timer);
|
|
323
|
+
await Promise.allSettled([...inFlight]);
|
|
324
|
+
},
|
|
325
|
+
test: async (kind, channel) => {
|
|
326
|
+
const at = now();
|
|
327
|
+
const sample = { kind, project: null };
|
|
328
|
+
const to = channel ? [channel] : route(sample, at).channels;
|
|
329
|
+
const text = `test: a sample ${kind} item (conduct notify test)`;
|
|
330
|
+
const sent = await deliver(to, { commands: [], inbox: null, item: null, kind, link: null, project: null, text, title: `conductor · test ${kind}` }, { test: true });
|
|
331
|
+
return {
|
|
332
|
+
channels: to,
|
|
333
|
+
profile: active().name,
|
|
334
|
+
quietNow: isQuiet(quietHours, at),
|
|
335
|
+
results: sent.map(({ channel: c, result }) => ({
|
|
336
|
+
channel: c,
|
|
337
|
+
detail: result.ok ? (c === 'remote-control' ? 'requested: the conductor mod in the console pushes it' : 'sent') : result.error,
|
|
338
|
+
ok: result.ok,
|
|
339
|
+
})),
|
|
340
|
+
};
|
|
341
|
+
},
|
|
342
|
+
tick,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
// `conduct notify`: the profile, quiet hours and each channel's state.
|
|
346
|
+
export function notifyLines(status) {
|
|
347
|
+
const until = status.profileUntil === null ? '' : ` until ${new Date(status.profileUntil).toLocaleString()}`;
|
|
348
|
+
const quiet = status.quietHours === null ? 'none' : `${status.quietHours.from}-${status.quietHours.to}${status.quietNow ? ' (now)' : ''}`;
|
|
349
|
+
const channel = (name) => {
|
|
350
|
+
const c = status.channels[name];
|
|
351
|
+
const since = c.since === null ? '' : ` since ${new Date(c.since).toLocaleString()}`;
|
|
352
|
+
return ` ${name}: ${c.state}${since}${c.detail ? ` (${c.detail})` : ''}`;
|
|
353
|
+
};
|
|
354
|
+
return [
|
|
355
|
+
`profile: ${status.profile}${until} (set by ${status.profileSetBy}; profiles: ${status.profiles.join(', ')})`,
|
|
356
|
+
`quiet hours: ${quiet}${status.held > 0 ? `; ${status.held} held for the summary` : ''}`,
|
|
357
|
+
'channels:',
|
|
358
|
+
channel('remote-control'),
|
|
359
|
+
channel('pushover'),
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
// `conduct notify profile … --until`: a local time of day (the next one), or a date and time.
|
|
363
|
+
export function parseUntil(text, now) {
|
|
364
|
+
const at = parseLocalTime(text, now);
|
|
365
|
+
if (!at) {
|
|
366
|
+
throw new ConductorError(`--until "${text}" is not a time`, ['a local time such as 17:00 (the next one), or a date and time such as 2026-10-12T09:00']);
|
|
367
|
+
}
|
|
368
|
+
return at;
|
|
369
|
+
}
|
|
370
|
+
// A rule's push as one line: null when every channel took it, else which didn't.
|
|
371
|
+
function deliveryProblem(results) {
|
|
372
|
+
const failed = results.filter((r) => !r.result.ok).map((r) => r.channel);
|
|
373
|
+
if (failed.length === 0)
|
|
374
|
+
return null;
|
|
375
|
+
return failed.length === results.length ? 'the push failed' : `pushed, but not on ${failed.join(', ')}`;
|
|
376
|
+
}
|
|
377
|
+
function inRange(from, to, at) {
|
|
378
|
+
const minute = at.getHours() * 60 + at.getMinutes();
|
|
379
|
+
const start = minutesOf(from);
|
|
380
|
+
const end = minutesOf(to);
|
|
381
|
+
return start < end ? minute >= start && minute < end : minute >= start || minute < end;
|
|
382
|
+
}
|
|
383
|
+
function matches(rule, item, at) {
|
|
384
|
+
if (rule.kinds && !rule.kinds.includes(item.kind))
|
|
385
|
+
return false;
|
|
386
|
+
if (rule.projects && (item.project === null || !rule.projects.includes(item.project)))
|
|
387
|
+
return false;
|
|
388
|
+
if (rule.days && !rule.days.includes(DAYS[at.getDay()] ?? 'sun'))
|
|
389
|
+
return false;
|
|
390
|
+
if (rule.hours) {
|
|
391
|
+
const [from = '', to = ''] = rule.hours.split('-');
|
|
392
|
+
if (!inRange(from, to, at))
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
function messageOf(item) {
|
|
398
|
+
return {
|
|
399
|
+
commands: item.commands,
|
|
400
|
+
inbox: item.id,
|
|
401
|
+
item: item.item,
|
|
402
|
+
kind: item.kind,
|
|
403
|
+
link: item.links[0] ?? null,
|
|
404
|
+
project: item.project,
|
|
405
|
+
text: pushMessage(item),
|
|
406
|
+
title: `conductor · ${[item.item, item.kind].filter((x) => x !== null).join(' ')}`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function minutesOf(clock) {
|
|
410
|
+
const [hours = 0, minutes = 0] = clock.split(':').map(Number);
|
|
411
|
+
return hours * 60 + minutes;
|
|
412
|
+
}
|
|
413
|
+
// "3 waiting since quiet hours: ATK-1 plan, ATK-2 npm-otp, #7 failed; 1 resolved meanwhile".
|
|
414
|
+
function summaryText(items, resolved) {
|
|
415
|
+
const names = items.map((i) => `${i.item ?? `#${i.id}`} ${i.kind}`).join(', ');
|
|
416
|
+
const text = `${items.length} waiting since quiet hours: ${names}${resolved > 0 ? `; ${resolved} resolved meanwhile` : ''}`;
|
|
417
|
+
return pushMessage({ item: null, text });
|
|
418
|
+
}
|
|
419
|
+
function unique(values) {
|
|
420
|
+
return [...new Set(values)];
|
|
421
|
+
}
|
|
422
|
+
//# sourceMappingURL=notify.js.map
|