@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/package.json +7 -4
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +39 -0
- package/src/commands/pulse/doctor.js +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +19 -12
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +16 -2
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +61 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +140 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { channelFiles, normRole, parsePosts } from '../hub/lib/channels.mjs';
|
|
4
|
+
import { isTerminal } from '../hub/lib/closure.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pulse 1.1.2: the delivery-discipline rules as checks in the runner, from the Cursor hook and a sweep.
|
|
8
|
+
* - E2: on `sessionEnd`, a channel whose newest post by that session is not terminal gets an automatic STOPPED.
|
|
9
|
+
* - E3: a channel whose newest post is not terminal gets `STATUS?` to its owner at 30 min, and an escalation to its
|
|
10
|
+
* architect and to you at 60 min, once per stale episode.
|
|
11
|
+
* - E4: a header stamped more than 5 min ahead of the clock is flagged back to the session that wrote it.
|
|
12
|
+
* - E5: `preToolUse` on Task is denied unless the brief names a `.agents/<task>/channel.md` that holds a KICKOFF.
|
|
13
|
+
* - E6: `preToolUse` on Shell with `required_permissions` posts a BLOCKER to the session's channel and to you, and the
|
|
14
|
+
* call's end clears it. The call itself is never blocked.
|
|
15
|
+
* Which posts a session wrote is learned from its own postToolUse hooks (the channel it named and the header lines it
|
|
16
|
+
* wrote). These tables are runner.db's own: no capture trigger, never sent to the cloud.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** E3: a channel whose newest post is not terminal is asked for a STATUS after this long, and escalated after twice that. */
|
|
20
|
+
export const STALE_ASK_MS = 30 * 60e3;
|
|
21
|
+
export const STALE_ESCALATE_MS = 60 * 60e3;
|
|
22
|
+
/** E3 watches channels written this recently; older ones are history, not stale work. */
|
|
23
|
+
export const STALE_WATCH_MS = 12 * 3600e3;
|
|
24
|
+
/** E4: a header this far ahead of the writer's clock is future-dated. */
|
|
25
|
+
export const FUTURE_MS = 5 * 60e3;
|
|
26
|
+
export const BLOCKER_HEAD = 'BLOCKER — waiting on the user to approve a permission prompt';
|
|
27
|
+
/**
|
|
28
|
+
* E5 (architect's 11:56:47 ruling): read-only subagents own no deliverable, so they need no channel of their own; their
|
|
29
|
+
* work is reported in the parent's. A Task with `readonly: true` is exempt too. Anything that can write is gated.
|
|
30
|
+
*/
|
|
31
|
+
export const READ_ONLY_SUBAGENTS = Object.freeze(new Set([
|
|
32
|
+
'explore', 'cursor-guide', 'ci-investigator', 'researcher', 'innerloop-expert', 'innerloop-code-reviewer', 'innerloop-domain-reviewer',
|
|
33
|
+
'innerloop-security-reviewer',
|
|
34
|
+
]));
|
|
35
|
+
const STOPPED_HEAD = 'STOPPED — session ended without a terminal post (auto)';
|
|
36
|
+
const CLEARED_HEAD = 'STATUS — permission prompt answered, carrying on (auto)';
|
|
37
|
+
const CHANNEL_MAX_BYTES = 4e6;
|
|
38
|
+
const DAY_S = 86400;
|
|
39
|
+
const HALF_DAY_S = DAY_S / 2;
|
|
40
|
+
/** A header line as an agent writes it into a tool call: the time may still be a shell variable (`$T`). */
|
|
41
|
+
const HEADER_LINE = /^## \[([A-Z][A-Z0-9 -]*?)\] (\S+) (?:(?:IST|UTC|GMT) )?([^\n]*)$/;
|
|
42
|
+
const CLOCK = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
|
|
43
|
+
const NAME = '[A-Za-z0-9._-]+';
|
|
44
|
+
const CHANNEL_REF = new RegExp(`\\.agents/(${NAME})/channel\\.md`, 'g');
|
|
45
|
+
/** A tool call that writes a channel may name only its folder (`cd <repo>/.agents/<task> && cat >> channel.md`). */
|
|
46
|
+
const CHANNEL_DIR = new RegExp(`\\.agents/(${NAME})(?=/channel\\.md|/?["'\\s;&|)]|/?$)`, 'gm');
|
|
47
|
+
|
|
48
|
+
const SCHEMA = [
|
|
49
|
+
`CREATE TABLE IF NOT EXISTS pulse_posts (
|
|
50
|
+
session TEXT NOT NULL, key TEXT NOT NULL, file TEXT NOT NULL, role TEXT NOT NULL, head TEXT NOT NULL, at INTEGER NOT NULL,
|
|
51
|
+
terminal INTEGER NOT NULL, auto INTEGER NOT NULL DEFAULT 0, seen_at INTEGER NOT NULL, PRIMARY KEY (session, key, role, head, at))`,
|
|
52
|
+
'CREATE INDEX IF NOT EXISTS pulse_posts_key ON pulse_posts (key, seen_at)',
|
|
53
|
+
`CREATE TABLE IF NOT EXISTS pulse_prompts (
|
|
54
|
+
session TEXT PRIMARY KEY, tool_use_id TEXT, key TEXT, file TEXT, role TEXT, message_id TEXT, at INTEGER NOT NULL)`,
|
|
55
|
+
`CREATE TABLE IF NOT EXISTS pulse_stale (
|
|
56
|
+
key TEXT PRIMARY KEY, file TEXT NOT NULL, sig TEXT NOT NULL, first_seen INTEGER NOT NULL, owner TEXT, asked_at INTEGER, escalated_at INTEGER)`,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export function installChannelDiscipline(db) {
|
|
60
|
+
for (const sql of SCHEMA) db.prepare(sql).run();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
64
|
+
const clockOf = (at) => { const d = new Date(at); return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`; };
|
|
65
|
+
const autoPost = (role, head, body, at) => `\n## [${role}] ${clockOf(at)} ${head}\n${body}\n`;
|
|
66
|
+
const strings = (value) => (Array.isArray(value) ? value : [value]).filter((v) => typeof v === 'string');
|
|
67
|
+
|
|
68
|
+
/** The header lines a tool call wrote, as `{ role, clock, head }`; the clock may be a shell variable. */
|
|
69
|
+
export function headerLines(lines) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const line of strings(lines)) {
|
|
72
|
+
const m = line.trim().match(HEADER_LINE);
|
|
73
|
+
if (m) out.push({ role: normRole(m[1]), clock: m[2], head: m[3].trim() });
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* E4: how far a header's `HH:MM[:SS]` runs ahead of the writer's clock at `at`, in ms, across midnight within
|
|
80
|
+
* (-12 h, +12 h]; null when the time is not a literal clock (a shell variable). A `UTC` or `GMT` header is converted first.
|
|
81
|
+
*/
|
|
82
|
+
export function headerSkewMs(line, at) {
|
|
83
|
+
const m = String(line || '').trim().match(/^## \[[A-Z][A-Z0-9 -]*?\] (\S+)(?: (IST|UTC|GMT))? /);
|
|
84
|
+
const c = m && m[1].match(CLOCK);
|
|
85
|
+
if (!c) return null;
|
|
86
|
+
const d = new Date(at);
|
|
87
|
+
let header = +c[1] * 3600 + +c[2] * 60 + +(c[3] || 0);
|
|
88
|
+
if (m[2] === 'UTC' || m[2] === 'GMT') header -= d.getTimezoneOffset() * 60;
|
|
89
|
+
let skew = header - (d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds());
|
|
90
|
+
skew = ((skew % DAY_S) + DAY_S) % DAY_S;
|
|
91
|
+
if (skew > HALF_DAY_S) skew -= DAY_S;
|
|
92
|
+
return skew * 1000;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Every `.agents/<task>/channel.md` a text names (with `folders`, also a bare `.agents/<task>` folder):
|
|
97
|
+
* `{ named, repo, task, absolute }`. `repo` is the folder before `.agents` when it is a plain name; `absolute` lists
|
|
98
|
+
* the absolute paths it could be, since a path may hold spaces.
|
|
99
|
+
*/
|
|
100
|
+
export function channelRefs(text, { folders = false } = {}) {
|
|
101
|
+
const out = [];
|
|
102
|
+
const s = String(text || '');
|
|
103
|
+
for (const m of s.matchAll(folders ? CHANNEL_DIR : CHANNEL_REF)) {
|
|
104
|
+
const lineStart = s.lastIndexOf('\n', m.index) + 1;
|
|
105
|
+
const before = s.slice(lineStart, m.index);
|
|
106
|
+
const repo = (before.match(new RegExp(`(?:^|[^A-Za-z0-9._-])(${NAME})/$`)) || [])[1] || null;
|
|
107
|
+
const absolute = [];
|
|
108
|
+
for (let j = 0; j < before.length; j++) {
|
|
109
|
+
if (before[j] === '/' && (j === 0 || /[\s`'"(\[<:=]/.test(before[j - 1]))) absolute.push(`${before.slice(j)}.agents/${m[1]}/channel.md`);
|
|
110
|
+
}
|
|
111
|
+
const named = absolute[absolute.length - 1] || `${repo ? `${repo}/` : ''}.agents/${m[1]}/channel.md`;
|
|
112
|
+
if (!out.some((r) => r.named === named)) out.push({ named, repo, task: m[1], absolute });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A regular file (never a symlink) at `file`, or null. */
|
|
118
|
+
function regularFile(file) {
|
|
119
|
+
try { return fs.lstatSync(file).isFile() ? file : null; } catch { return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readPosts(file) {
|
|
123
|
+
let fd;
|
|
124
|
+
try {
|
|
125
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
126
|
+
const st = fs.fstatSync(fd);
|
|
127
|
+
if (!st.isFile() || st.size > CHANNEL_MAX_BYTES) return null;
|
|
128
|
+
return { posts: parsePosts(fs.readFileSync(fd, 'utf8'), st.mtimeMs), mtime: st.mtimeMs };
|
|
129
|
+
} catch { return null; } finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const keyOf = (file) => {
|
|
133
|
+
const m = file.match(new RegExp(`(?:^|/)(${NAME})/\\.agents/(${NAME})/channel\\.md$`));
|
|
134
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export function createChannelDiscipline(hub, {
|
|
138
|
+
channelRoots = [], workspaceRoot = null, append, publish, now: clock = Date.now, warn = (message) => console.error(message),
|
|
139
|
+
staleAskMs = STALE_ASK_MS, staleEscalateMs = STALE_ESCALATE_MS,
|
|
140
|
+
} = {}) {
|
|
141
|
+
const { db, registry } = hub;
|
|
142
|
+
installChannelDiscipline(db);
|
|
143
|
+
const started = clock();
|
|
144
|
+
const q = {
|
|
145
|
+
record: db.prepare(`INSERT INTO pulse_posts (session, key, file, role, head, at, terminal, auto, seen_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
146
|
+
ON CONFLICT (session, key, role, head, at) DO UPDATE SET seen_at = excluded.seen_at`),
|
|
147
|
+
channelsOf: db.prepare('SELECT DISTINCT key FROM pulse_posts WHERE session = ?'),
|
|
148
|
+
lastOf: db.prepare('SELECT * FROM pulse_posts WHERE session = ? AND key = ? ORDER BY seen_at DESC, rowid DESC LIMIT 1'),
|
|
149
|
+
latestChannel: db.prepare('SELECT * FROM pulse_posts WHERE session = ? ORDER BY seen_at DESC, rowid DESC LIMIT 1'),
|
|
150
|
+
author: db.prepare('SELECT session FROM pulse_posts WHERE key = ? AND role = ? AND head = ? ORDER BY seen_at DESC LIMIT 1'),
|
|
151
|
+
prompt: db.prepare('SELECT * FROM pulse_prompts WHERE session = ?'),
|
|
152
|
+
openPrompt: db.prepare('INSERT INTO pulse_prompts (session, tool_use_id, key, file, role, message_id, at) VALUES (?, ?, ?, ?, ?, ?, ?)'),
|
|
153
|
+
closePrompt: db.prepare('DELETE FROM pulse_prompts WHERE session = ?'),
|
|
154
|
+
stale: db.prepare('SELECT * FROM pulse_stale WHERE key = ?'),
|
|
155
|
+
staleAll: db.prepare('SELECT key FROM pulse_stale'),
|
|
156
|
+
staleSet: db.prepare(`INSERT INTO pulse_stale (key, file, sig, first_seen, owner, asked_at, escalated_at) VALUES (?, ?, ?, ?, NULL, NULL, NULL)
|
|
157
|
+
ON CONFLICT (key) DO UPDATE SET file = excluded.file, sig = excluded.sig, first_seen = excluded.first_seen, owner = NULL, asked_at = NULL, escalated_at = NULL`),
|
|
158
|
+
staleAsked: db.prepare('UPDATE pulse_stale SET asked_at = ?, owner = ? WHERE key = ?'),
|
|
159
|
+
staleEscalated: db.prepare('UPDATE pulse_stale SET escalated_at = ? WHERE key = ?'),
|
|
160
|
+
staleDrop: db.prepare('DELETE FROM pulse_stale WHERE key = ?'),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** E5, E6 apply only to sessions in this workspace: `~/.cursor/hooks.json` is global, other projects have no channels. */
|
|
164
|
+
const inWorkspace = (input) => !!workspaceRoot && (Array.isArray(input.workspace_roots) ? input.workspace_roots : [])
|
|
165
|
+
.some((r) => typeof r === 'string' && (r === workspaceRoot || r.startsWith(`${workspaceRoot}/`)));
|
|
166
|
+
|
|
167
|
+
/** The roots a channel named in a tool call is looked for under: the channel roots, the session's folders, its cwd. */
|
|
168
|
+
const rootsFor = (input) => {
|
|
169
|
+
const tool = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
|
|
170
|
+
return [...new Set([...channelRoots, ...strings(input.workspace_roots), ...strings([tool.cwd, tool.working_directory, input.cwd])].filter((r) => path.isAbsolute(r)))];
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** The channel file a reference names, if it exists. */
|
|
174
|
+
function resolve(ref, roots) {
|
|
175
|
+
for (const p of ref.absolute) if (path.isAbsolute(p) && regularFile(p)) return p;
|
|
176
|
+
for (const root of roots) {
|
|
177
|
+
const candidates = ref.repo
|
|
178
|
+
? [path.join(root, ref.repo, '.agents', ref.task, 'channel.md'), path.basename(root) === ref.repo ? path.join(root, '.agents', ref.task, 'channel.md') : null]
|
|
179
|
+
: [path.join(root, '.agents', ref.task, 'channel.md')];
|
|
180
|
+
for (const c of candidates) if (c && regularFile(c)) return c;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const roleOf = (session) => { const a = registry.get(session); return a && a.role ? normRole(a.role) : null; };
|
|
186
|
+
|
|
187
|
+
/** E5: a Task launch whose brief names no channel with a KICKOFF is denied; anything else, or too little to tell, is allowed (null). */
|
|
188
|
+
function taskGate(input) {
|
|
189
|
+
if (!inWorkspace(input)) return null;
|
|
190
|
+
const tool = input.tool_input;
|
|
191
|
+
if (!tool || typeof tool !== 'object' || (!('prompt' in tool) && !('description' in tool))) {
|
|
192
|
+
warn(`E5 channel gate skipped for ${input.conversation_id}: the hook sent no Task brief (a hook client older than 1.1.2?)`);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (typeof tool.resume === 'string' && tool.resume.trim()) return null;
|
|
196
|
+
if (tool.readonly === true || (typeof tool.subagent_type === 'string' && READ_ONLY_SUBAGENTS.has(tool.subagent_type))) return null;
|
|
197
|
+
const refs = channelRefs(strings([tool.prompt, tool.description]).join('\n'));
|
|
198
|
+
const roots = rootsFor(input);
|
|
199
|
+
const checked = refs.map((ref) => {
|
|
200
|
+
const file = resolve(ref, roots);
|
|
201
|
+
const read = file ? readPosts(file) : null;
|
|
202
|
+
return { ref, file, kickoff: !!read && read.posts.some((p) => /^KICKOFF\b/.test(p.type.toUpperCase())) };
|
|
203
|
+
});
|
|
204
|
+
if (checked.some((c) => c.kickoff)) return null;
|
|
205
|
+
const why = checked.length
|
|
206
|
+
? checked.map((c) => (c.file ? `\`${c.ref.named}\` has no KICKOFF post` : `\`${c.ref.named}\` does not exist`)).join('; ')
|
|
207
|
+
: 'the brief names no `.agents/<task>/channel.md`';
|
|
208
|
+
return {
|
|
209
|
+
permission: 'deny',
|
|
210
|
+
user_message: `Pulse stopped a subagent launch: ${why}.`,
|
|
211
|
+
agent_message: `Pulse denied this subagent launch: ${why}. Every agent you start needs its own task channel with a KICKOFF before it starts work. `
|
|
212
|
+
+ 'Create `<repo>/.agents/<task>/channel.md`, append `## [ARCHITECT] HH:MM:SS KICKOFF — <task>` with the goal, scope, repos, acceptance and reviewer, '
|
|
213
|
+
+ 'then launch again with that path in the brief. Resuming an existing agent is not gated.',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The channel a session works in: the one it posted to last, else its registered stream's. */
|
|
218
|
+
function channelOf(session) {
|
|
219
|
+
const last = q.latestChannel.get(session);
|
|
220
|
+
if (last && regularFile(last.file)) return { key: last.key, file: last.file, role: last.role };
|
|
221
|
+
const actor = registry.get(session);
|
|
222
|
+
const key = actor && actor.stream_key;
|
|
223
|
+
if (!key || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(key)) return null;
|
|
224
|
+
const [repo, task] = key.split('/');
|
|
225
|
+
const file = resolve({ absolute: [], repo, task }, channelRoots);
|
|
226
|
+
return file ? { key, file, role: roleOf(session) } : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** E6: a Shell call asking for permissions: one BLOCKER per session, to its channel and to you. Never blocks the call. */
|
|
230
|
+
function promptOpened(input, at) {
|
|
231
|
+
const tool = input.tool_input;
|
|
232
|
+
const asked = tool && Array.isArray(tool.required_permissions) ? tool.required_permissions.filter((p) => typeof p === 'string' && p) : [];
|
|
233
|
+
if (!asked.length || !inWorkspace(input)) return;
|
|
234
|
+
const session = input.conversation_id;
|
|
235
|
+
if (q.prompt.get(session)) return;
|
|
236
|
+
const channel = channelOf(session);
|
|
237
|
+
const role = (channel && channel.role) || roleOf(session) || 'AGENT';
|
|
238
|
+
const perms = asked.slice(0, 10).map((p) => `\`${p.slice(0, 40)}\``).join(', ');
|
|
239
|
+
if (channel) {
|
|
240
|
+
append(channel.file, autoPost(role, BLOCKER_HEAD, `Pulse posted this for session \`${session}\`: a Shell call asked for ${perms} and waits for you to approve the prompt in Cursor. The session's next step clears it.`, at));
|
|
241
|
+
record(session, channel.key, channel.file, { role, head: BLOCKER_HEAD, at, terminal: false, auto: true }, at);
|
|
242
|
+
}
|
|
243
|
+
const env = publish({
|
|
244
|
+
from: 'manager', fromName: 'Pulse', topic: 'user', type: 'BLOCKER',
|
|
245
|
+
body: `Session \`${session}\`${channel ? ` (${channel.key})` : ''} is waiting on you to approve a permission prompt in Cursor: a Shell call asked for ${perms}.`,
|
|
246
|
+
meta: { session, channel: channel ? channel.key : null, prompt: true },
|
|
247
|
+
});
|
|
248
|
+
q.openPrompt.run(session, typeof input.tool_use_id === 'string' ? input.tool_use_id : null, channel ? channel.key : null, channel ? channel.file : null, role, env && !env.error ? env.id : null, at);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** E6: the prompt's call ended (or the turn or session did): the channel's BLOCKER is answered, unless the agent already posted since. */
|
|
252
|
+
function promptClosed(input, at) {
|
|
253
|
+
const session = input.conversation_id;
|
|
254
|
+
const open = q.prompt.get(session);
|
|
255
|
+
if (!open) return;
|
|
256
|
+
const event = input.hook_event_name;
|
|
257
|
+
if (event === 'postToolUse' || event === 'postToolUseFailure') {
|
|
258
|
+
const id = typeof input.tool_use_id === 'string' ? input.tool_use_id : null;
|
|
259
|
+
if (open.tool_use_id && id ? open.tool_use_id !== id : input.tool_name !== 'Shell') return;
|
|
260
|
+
} else if (event !== 'stop' && event !== 'sessionEnd') return;
|
|
261
|
+
q.closePrompt.run(session);
|
|
262
|
+
if (open.file && regularFile(open.file)) {
|
|
263
|
+
const read = readPosts(open.file);
|
|
264
|
+
const last = read && read.posts[read.posts.length - 1];
|
|
265
|
+
if (last && last.role === open.role && last.head === BLOCKER_HEAD) {
|
|
266
|
+
append(open.file, autoPost(open.role, CLEARED_HEAD, `Pulse posted this for session \`${session}\`: the permission prompt was answered.`, at));
|
|
267
|
+
record(session, open.key, open.file, { role: open.role, head: CLEARED_HEAD, at, terminal: false, auto: true }, at);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
publish({
|
|
271
|
+
from: 'manager', fromName: 'Pulse', topic: 'user', type: 'STATUS',
|
|
272
|
+
body: `Session \`${session}\` is past its permission prompt.`, meta: { session, ...(open.message_id ? { supersedes: open.message_id } : {}) },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function record(session, key, file, post, at) {
|
|
277
|
+
q.record.run(session, key, file, post.role, post.head, Math.round(post.at), post.terminal ? 1 : 0, post.auto ? 1 : 0, at);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The posts a postToolUse shows the session wrote: each header line of the call, found in a channel the call named,
|
|
282
|
+
* recorded as that session's. Returns E4's receipts for headers stamped ahead of the clock.
|
|
283
|
+
*/
|
|
284
|
+
function sawPosts(input, at) {
|
|
285
|
+
const tool = input.tool_input;
|
|
286
|
+
if (!tool || typeof tool !== 'object') return [];
|
|
287
|
+
const headers = strings(tool.headers);
|
|
288
|
+
const wrote = headerLines(headers);
|
|
289
|
+
if (!wrote.length) return [];
|
|
290
|
+
const roots = rootsFor(input);
|
|
291
|
+
const files = new Map();
|
|
292
|
+
for (const ref of strings(tool.channels).flatMap((c) => channelRefs(c, { folders: true }))) {
|
|
293
|
+
const file = resolve(ref, roots);
|
|
294
|
+
if (file) files.set(file, keyOf(file) || ref.task);
|
|
295
|
+
}
|
|
296
|
+
const receipts = [];
|
|
297
|
+
for (const [file, key] of files) {
|
|
298
|
+
const read = readPosts(file);
|
|
299
|
+
if (!read) continue;
|
|
300
|
+
for (const w of wrote) {
|
|
301
|
+
const mine = read.posts.filter((p) => p.role === w.role);
|
|
302
|
+
const post = mine.filter((p) => p.head === w.head).pop() || mine.pop();
|
|
303
|
+
if (post) record(input.conversation_id, key, file, { role: post.role, head: post.head, at: post.at, terminal: isTerminal(post), auto: false }, at);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (files.size) {
|
|
307
|
+
for (const line of headers) {
|
|
308
|
+
const skew = headerSkewMs(line, at);
|
|
309
|
+
if (skew !== null && skew > FUTURE_MS) {
|
|
310
|
+
receipts.push(`Pulse: your post \`${line.trim().slice(0, 120)}\` is stamped ${Math.round(skew / 60e3)} min ahead of this machine's clock (${clockOf(at)}). Write header times with \`date +%H:%M:%S\` when you post.`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return receipts;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** E2: the session ended; each channel where its own newest post is not terminal gets an automatic STOPPED. */
|
|
318
|
+
function sessionEnded(session, reason, at) {
|
|
319
|
+
let n = 0;
|
|
320
|
+
for (const { key } of q.channelsOf.all(session)) {
|
|
321
|
+
const own = q.lastOf.get(session, key);
|
|
322
|
+
if (!own || own.terminal || !regularFile(own.file)) continue;
|
|
323
|
+
const read = readPosts(own.file);
|
|
324
|
+
if (!read) continue;
|
|
325
|
+
const at0 = read.posts.findLastIndex((p) => p.role === own.role && p.head === own.head);
|
|
326
|
+
if (read.posts.slice(at0 + 1).some((p) => p.role === own.role && isTerminal(p))) continue;
|
|
327
|
+
append(own.file, autoPost(own.role, STOPPED_HEAD, `Pulse appended this when session \`${session}\` ended${reason ? ` (${String(reason).slice(0, 40)})` : ''}. Its last post here was \`${own.head.slice(0, 160)}\`.`, at));
|
|
328
|
+
record(session, key, own.file, { role: own.role, head: STOPPED_HEAD, at, terminal: true, auto: true }, at);
|
|
329
|
+
n++;
|
|
330
|
+
}
|
|
331
|
+
return n;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* One hook event. A `preToolUse` answer is the permission decision only (E5's deny, else nothing); a postToolUse
|
|
336
|
+
* gets E4's receipts as `notices`. The other effects are side effects: E2 on sessionEnd, E6 on Shell calls.
|
|
337
|
+
*/
|
|
338
|
+
function onHook(input, at = clock()) {
|
|
339
|
+
const event = input.hook_event_name;
|
|
340
|
+
if (event === 'preToolUse') {
|
|
341
|
+
if (input.tool_name === 'Task') return { deny: taskGate(input), notices: [] };
|
|
342
|
+
if (input.tool_name === 'Shell') promptOpened(input, at);
|
|
343
|
+
return { deny: null, notices: [] };
|
|
344
|
+
}
|
|
345
|
+
promptClosed(input, at);
|
|
346
|
+
if (event === 'sessionEnd') {
|
|
347
|
+
sessionEnded(input.conversation_id, input.reason, at);
|
|
348
|
+
return { deny: null, notices: [] };
|
|
349
|
+
}
|
|
350
|
+
if (event === 'postToolUse' || event === 'postToolUseFailure') return { deny: null, notices: sawPosts(input, at) };
|
|
351
|
+
return { deny: null, notices: [] };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** E3's owner of a channel's newest post: the session that wrote it, else the agent of that stream and role, else its architect. */
|
|
355
|
+
function ownerOf(key, last) {
|
|
356
|
+
const wrote = q.author.get(key, last.role, last.head);
|
|
357
|
+
if (wrote) return wrote.session;
|
|
358
|
+
const dir = registry.directory();
|
|
359
|
+
const crew = dir.filter((a) => a.streamKey === key && normRole(a.role) === last.role).sort((a, b) => (b.seenAt || 0) - (a.seenAt || 0));
|
|
360
|
+
if (crew.length) return crew[0].id;
|
|
361
|
+
const arch = dir.find((a) => a.isArchitect && (a.streams || []).includes(key));
|
|
362
|
+
return last.role === 'ARCHITECT' && arch ? arch.id : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function architectOf(key, owner) {
|
|
366
|
+
const dir = registry.directory();
|
|
367
|
+
const a = owner ? dir.find((d) => d.id === owner) : null;
|
|
368
|
+
if (a && a.deliveryId) return a.deliveryId;
|
|
369
|
+
const arch = dir.find((d) => d.isArchitect && (d.streams || []).includes(key));
|
|
370
|
+
return arch ? arch.deliveryId || arch.id : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* E3, from the lease holder's sweep: each channel written in the last STALE_WATCH_MS whose newest post is not
|
|
375
|
+
* terminal. Its age counts from when this runner first saw that post (never before the runner started), not from
|
|
376
|
+
* the header, whose zone and date are unknown. Returns the messages it sent.
|
|
377
|
+
*/
|
|
378
|
+
function sweep(now = clock()) {
|
|
379
|
+
const sent = [];
|
|
380
|
+
const live = new Set();
|
|
381
|
+
for (const c of channelFiles(channelRoots, now - STALE_WATCH_MS)) {
|
|
382
|
+
const read = readPosts(c.file);
|
|
383
|
+
const last = read && read.posts[read.posts.length - 1];
|
|
384
|
+
if (!last) continue;
|
|
385
|
+
live.add(c.key);
|
|
386
|
+
if (isTerminal(last)) { q.staleDrop.run(c.key); continue; }
|
|
387
|
+
const sig = `${last.role}\0${last.head}\0${read.posts.length}`;
|
|
388
|
+
let row = q.stale.get(c.key);
|
|
389
|
+
if (!row || row.sig !== sig) {
|
|
390
|
+
q.staleSet.run(c.key, c.file, sig, Math.max(started, Math.min(now, read.mtime)));
|
|
391
|
+
row = q.stale.get(c.key);
|
|
392
|
+
}
|
|
393
|
+
const age = now - row.first_seen;
|
|
394
|
+
const quiet = `${Math.floor(age / 60e3)} min`;
|
|
395
|
+
const lastLine = `\`## [${last.role}] … ${last.head.slice(0, 120)}\``;
|
|
396
|
+
if (age >= staleAskMs && !row.asked_at) {
|
|
397
|
+
const owner = ownerOf(c.key, last);
|
|
398
|
+
q.staleAsked.run(now, owner, c.key);
|
|
399
|
+
row.owner = owner;
|
|
400
|
+
if (owner) {
|
|
401
|
+
// A bus type is [A-Z0-9 _-] only, so `STATUS?` goes as a NUDGE that says it, as the manager's own check-ins do.
|
|
402
|
+
const env = publish({
|
|
403
|
+
from: 'manager', fromName: 'Pulse', topic: `agent/${owner}`, type: 'NUDGE', needsAck: true,
|
|
404
|
+
body: `STATUS? Your channel \`${c.key}\` has had no post for ${quiet}, and its last post ${lastLine} is not terminal. Post STATUS (what you are doing, or what you wait for and from whom), BLOCKER, or your terminal post.`,
|
|
405
|
+
meta: { stale: c.key },
|
|
406
|
+
});
|
|
407
|
+
if (env && !env.error) sent.push({ key: c.key, to: owner, type: 'STATUS?' });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (age >= staleEscalateMs && !row.escalated_at) {
|
|
411
|
+
q.staleEscalated.run(now, c.key);
|
|
412
|
+
const body = `\`${c.key}\` has had no post for ${quiet}; its last post ${lastLine} is not terminal.${row.owner ? ` STATUS? went to \`${row.owner}\` with no post since.` : ' Pulse found no owner to ask.'}`;
|
|
413
|
+
const architect = architectOf(c.key, row.owner);
|
|
414
|
+
for (const topic of [...(architect && architect !== row.owner ? [`delivery/${architect}`] : []), 'user']) {
|
|
415
|
+
const env = publish({ from: 'manager', fromName: 'Pulse', topic, type: 'STALE', body, meta: { stale: c.key } });
|
|
416
|
+
if (env && !env.error) sent.push({ key: c.key, to: topic, type: 'STALE' });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const { key } of q.staleAll.all()) if (!live.has(key)) q.staleDrop.run(key);
|
|
421
|
+
return sent;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return { onHook, sweep };
|
|
425
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { DEFAULT_TTL_MS, MESSAGE_ID } from '../hub/core/ids.mjs';
|
|
2
|
+
import { edgeId } from '../hub/core/edgeIds.mjs';
|
|
3
|
+
import { parseJson } from '../hub/core/db.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pulse 1.1.1 honest delivery (F2, F2+, F8). Each recipient of a message is `undeliverable`, `queued`, `delivered`,
|
|
7
|
+
* `read`, `skipped`, `acked` or `expired`. The hub's own deliveries rows keep their 6b2fb0b states; `read` (a reader
|
|
8
|
+
* saw it) and `skipped` (settled with no reader) are recorded beside them in runner.db and never sent up: the cloud
|
|
9
|
+
* keeps delivered, acked and expired (option A), so nothing auto-handled ever shows there as acked.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Mail sent without a delivery report (web, spool, the old backlog) is checked this long after it was sent. */
|
|
13
|
+
export const BOUNCE_GRACE_MS = 2 * 60e3;
|
|
14
|
+
const BOUNCE_BATCH = 200;
|
|
15
|
+
/** The manager records these into its status: read, with no agent run. */
|
|
16
|
+
const RECORDED = new Set(['DONE', 'STATUS']);
|
|
17
|
+
/** Shown to a session by its hook: read at that session's next hook event, when a step has run with it in context. */
|
|
18
|
+
const SHOWN_BY_HOOK = new Set(['postToolUse', 'side', 'stop', 'retried']);
|
|
19
|
+
const REPORTS = 20;
|
|
20
|
+
|
|
21
|
+
const SCHEMA = [
|
|
22
|
+
`CREATE TABLE IF NOT EXISTS pulse_reads (
|
|
23
|
+
message_id TEXT NOT NULL, recipient TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('read', 'skipped')), at INTEGER NOT NULL, via TEXT,
|
|
24
|
+
PRIMARY KEY (message_id, recipient))`,
|
|
25
|
+
'CREATE TABLE IF NOT EXISTS pulse_reported (message_id TEXT PRIMARY KEY, at INTEGER NOT NULL)',
|
|
26
|
+
'CREATE TABLE IF NOT EXISTS pulse_bounced (message_id TEXT NOT NULL, recipient TEXT NOT NULL, notice TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (message_id, recipient))',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function installDeliveryStates(db) {
|
|
30
|
+
for (const sql of SCHEMA) db.prepare(sql).run();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const topicOf = (sender) => (sender === 'user' ? 'user' : `agent/${sender}`);
|
|
34
|
+
|
|
35
|
+
export function createDeliveryStates(hub, bus, { now: clock = Date.now } = {}) {
|
|
36
|
+
const { db, registry, messaging } = hub;
|
|
37
|
+
installDeliveryStates(db);
|
|
38
|
+
const q = {
|
|
39
|
+
record: db.prepare('INSERT OR IGNORE INTO pulse_reads (message_id, recipient, state, at, via) VALUES (?, ?, ?, ?, ?)'),
|
|
40
|
+
readsOf: db.prepare('SELECT recipient, state, at, via FROM pulse_reads WHERE message_id = ?'),
|
|
41
|
+
reported: db.prepare('INSERT OR IGNORE INTO pulse_reported (message_id, at) VALUES (?, ?)'),
|
|
42
|
+
bounced: db.prepare('INSERT OR IGNORE INTO pulse_bounced (message_id, recipient, notice, at) VALUES (?, ?, ?, ?)'),
|
|
43
|
+
reports: db.prepare(`SELECT m.id, m.created_at AS at, m.sender AS "from", m.from_name AS fromName, m.type, m.topic, m.body, r.at AS readAt
|
|
44
|
+
FROM pulse_reads r JOIN messages m ON m.id = r.message_id
|
|
45
|
+
WHERE r.recipient = 'manager' AND r.state = 'read' ORDER BY r.at DESC, m.created_at DESC LIMIT ?`),
|
|
46
|
+
expire: db.prepare(`UPDATE deliveries SET state = 'expired', lease_token = NULL, lease_until = NULL
|
|
47
|
+
WHERE message_id = ? AND recipient = ? AND state IN ('queued', 'leased', 'delivered')`),
|
|
48
|
+
message: db.prepare('SELECT * FROM messages WHERE id = ?'),
|
|
49
|
+
superseder: db.prepare("SELECT id FROM messages WHERE json_valid(meta) AND json_extract(meta, '$.supersedes') = ? ORDER BY created_at DESC LIMIT 1"),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A hook event from a session: what its earlier hooks showed it is now read, since a step ran with it in context. A
|
|
54
|
+
* `bus listen` delivery needs one hook more: the listen call's own postToolUse fires before the model saw the output.
|
|
55
|
+
*/
|
|
56
|
+
function readOnHook(addresses, { at = clock(), previousHook = null } = {}) {
|
|
57
|
+
if (!addresses.length) return 0;
|
|
58
|
+
const rows = db.prepare(`SELECT d.message_id, d.recipient, d.delivered_at, d.delivered_via FROM deliveries d
|
|
59
|
+
WHERE d.recipient IN (${addresses.map(() => '?').join(',')}) AND d.state = 'delivered' AND d.delivered_at > ?
|
|
60
|
+
AND NOT EXISTS (SELECT 1 FROM pulse_reads r WHERE r.message_id = d.message_id AND r.recipient = d.recipient)`).all(...addresses, at - DEFAULT_TTL_MS);
|
|
61
|
+
let n = 0;
|
|
62
|
+
for (const r of rows) {
|
|
63
|
+
const viaListen = r.delivered_via === 'listen';
|
|
64
|
+
const seen = viaListen ? previousHook !== null && r.delivered_at <= previousHook : SHOWN_BY_HOOK.has(r.delivered_via) && r.delivered_at <= at;
|
|
65
|
+
if (seen && q.record.run(r.message_id, r.recipient, 'read', at, viaListen ? 'listen' : 'hook').changes) n++;
|
|
66
|
+
}
|
|
67
|
+
return n;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The manager's FYIs (injected into createManager as `fyi`): taken off its queue with no agent run. A DONE or STATUS
|
|
72
|
+
* is recorded into its status, so read; anything else (a reply, an ack) is skipped. Never acked.
|
|
73
|
+
*/
|
|
74
|
+
function managerFyi(m, { at = clock() } = {}) {
|
|
75
|
+
messaging.markDelivered('manager', [m.id], { via: 'manager', now: at });
|
|
76
|
+
const state = RECORDED.has(String(m.type || '').toUpperCase()) ? 'read' : 'skipped';
|
|
77
|
+
q.record.run(m.id, 'manager', state, at, state === 'read' ? 'manager-status' : 'manager');
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The DONE and STATUS posts the manager has read lately, newest first: the `reports` of its status. */
|
|
82
|
+
const reports = (limit = REPORTS) => q.reports.all(limit).map((r) => ({
|
|
83
|
+
id: r.id, at: r.at, from: r.from, fromName: r.fromName, type: r.type, topic: r.topic, text: String(r.body || '').slice(0, 300), readAt: r.readAt,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
/** A message's recipients with the honest state of each (`delivery`), beside the hub's own fields. */
|
|
87
|
+
function honest(statuses, messageId, now = clock()) {
|
|
88
|
+
const reads = new Map(q.readsOf.all(messageId).map((r) => [r.recipient, r]));
|
|
89
|
+
return statuses.map((s) => {
|
|
90
|
+
const read = reads.get(s.to);
|
|
91
|
+
if (s.state === 'acked') return { ...s, delivery: 'acked', ...(read ? { readAt: read.at } : {}) };
|
|
92
|
+
if (read) return { ...s, delivery: read.state, readAt: read.at, ...(read.via === 'header' ? { reason: 'header-posted' } : {}) };
|
|
93
|
+
if (s.state === 'delivered' || s.state === 'unacked') return { ...s, delivery: 'delivered' };
|
|
94
|
+
if (s.state === 'expired') return { ...s, delivery: 'expired' };
|
|
95
|
+
const { state, ...why } = bus.reach(s.to, now);
|
|
96
|
+
return { ...s, delivery: state, ...why };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Bus envelopes (messaging.messages) with each recipient's honest state, and `supersededBy` on any message a later one
|
|
102
|
+
* in the list names in `meta.supersedes` (F7).
|
|
103
|
+
*/
|
|
104
|
+
function withDelivery(envelopes, now = clock()) {
|
|
105
|
+
const by = new Map();
|
|
106
|
+
for (const m of envelopes) if (m.meta && MESSAGE_ID.test(String(m.meta.supersedes || ''))) by.set(m.meta.supersedes, m.id);
|
|
107
|
+
return envelopes.map((m) => ({ ...m, status: honest(m.status || [], m.id, now), ...(by.has(m.id) ? { supersededBy: by.get(m.id) } : {}) }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One message and the honest state of each recipient, for `bus status` (F2+); null when there is no such message. */
|
|
111
|
+
function status(id, now = clock()) {
|
|
112
|
+
const m = q.message.get(id);
|
|
113
|
+
if (!m) return null;
|
|
114
|
+
const meta = parseJson(m.meta, {});
|
|
115
|
+
const later = q.superseder.get(id);
|
|
116
|
+
return {
|
|
117
|
+
id: m.id, at: m.created_at, from: m.sender, fromName: m.from_name, topic: m.topic, type: m.type, needsAck: !!m.needs_reply,
|
|
118
|
+
expiresAt: m.expires_at, replyTo: m.reply_to, ...(MESSAGE_ID.test(String(meta.supersedes || '')) ? { supersedes: meta.supersedes } : {}),
|
|
119
|
+
...(later ? { supersededBy: later.id } : {}), status: honest(messaging.statusOf(id, now), id, now),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Who sent a message, so a reply to it can be checked against the addresses the bus knows (E1); null when there is none. */
|
|
124
|
+
const senderOf = (id) => (MESSAGE_ID.test(String(id || '')) ? q.message.get(id)?.sender ?? null : null);
|
|
125
|
+
|
|
126
|
+
/** The delivery report `bus publish` and `bus ask` print: each recipient, queued or undeliverable, and why. */
|
|
127
|
+
function report(env, now = clock()) {
|
|
128
|
+
if (MESSAGE_ID.test(String(env.id || ''))) q.reported.run(env.id, now);
|
|
129
|
+
return (env.to || []).map((to) => ({ to, ...bus.reach(to, now) }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Mail that never had a delivery report (sent from the web, imported from a spool, or queued before 1.1.1) and has
|
|
134
|
+
* waited BOUNCE_GRACE_MS for a recipient nobody can read: its sender gets one UNDELIVERABLE notice per recipient,
|
|
135
|
+
* from the manager, with an id derived from the message and recipient. A notice is never itself bounced.
|
|
136
|
+
*/
|
|
137
|
+
function bounce(now = clock()) {
|
|
138
|
+
const rows = db.prepare(`SELECT d.message_id AS id, d.recipient, m.sender, m.type, m.body FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
139
|
+
WHERE d.state = 'queued' AND m.expires_at > ? AND m.created_at <= ? AND m.created_at > ?
|
|
140
|
+
AND m.sender <> 'manager' AND m.type <> 'UNDELIVERABLE' AND d.recipient NOT IN ('user', 'manager')
|
|
141
|
+
AND NOT EXISTS (SELECT 1 FROM pulse_reported r WHERE r.message_id = m.id)
|
|
142
|
+
AND NOT EXISTS (SELECT 1 FROM pulse_bounced b WHERE b.message_id = d.message_id AND b.recipient = d.recipient)
|
|
143
|
+
ORDER BY m.created_at LIMIT ?`).all(now, now - BOUNCE_GRACE_MS, now - DEFAULT_TTL_MS, BOUNCE_BATCH);
|
|
144
|
+
const sent = [];
|
|
145
|
+
let dir = null;
|
|
146
|
+
for (const r of rows) {
|
|
147
|
+
const why = bus.reach(r.recipient, now);
|
|
148
|
+
if (why.state !== 'undeliverable') continue;
|
|
149
|
+
dir ??= registry.directory(now);
|
|
150
|
+
const id = edgeId('u', r.id, r.recipient);
|
|
151
|
+
const reason = why.reason === 'stale' ? `its session ${why.session} has had no activity for ${why.idleMin} min`
|
|
152
|
+
: why.reason === 'offline' ? `it has been offline since ${new Date(why.offlineSince).toISOString()}` : 'no session has bound it and nobody is listening';
|
|
153
|
+
const env = messaging.publish({
|
|
154
|
+
id, from: 'manager', fromName: 'Manager', topic: topicOf(r.sender), type: 'UNDELIVERABLE', replyTo: r.id,
|
|
155
|
+
body: `Your ${String(r.type).toLowerCase()} \`${r.id}\` to \`${r.recipient}\` is undeliverable: ${reason}. It stays queued until it expires and is delivered if a session binds or listens as \`${r.recipient}\`.`,
|
|
156
|
+
meta: { about: r.id, to: r.recipient, reason: why.reason },
|
|
157
|
+
}, dir);
|
|
158
|
+
q.bounced.run(r.id, r.recipient, env.error ? `error:${env.error}` : id, now);
|
|
159
|
+
if (!env.error) sent.push({ id: r.id, to: r.recipient, notice: id });
|
|
160
|
+
}
|
|
161
|
+
return sent;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* When each agent last posted a channel header, by the hub's facts: an agent row's own latest post, and for an
|
|
166
|
+
* architect the newest ARCHITECT post among its delivery's streams.
|
|
167
|
+
*/
|
|
168
|
+
function headerTimes() {
|
|
169
|
+
const out = new Map();
|
|
170
|
+
for (const row of registry.facts('agent-row')) {
|
|
171
|
+
if (!row.isArchitect && row.doing && Number.isFinite(row.doing.at)) out.set(row.key, row.doing.at);
|
|
172
|
+
}
|
|
173
|
+
for (const d of registry.facts('delivery')) {
|
|
174
|
+
const at = Math.max(0, ...(d.streams || []).map((s) => (s.lastPost && s.lastPost.role === 'ARCHITECT' ? Number(s.lastPost.at) || 0 : 0)));
|
|
175
|
+
if (at) out.set(d.key, Math.max(out.get(d.key) || 0, at));
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** F8: agents that posted a header since the last scheduled nudge are not silent, so they are not nudged. */
|
|
181
|
+
function postedSince(last) {
|
|
182
|
+
if (!Number.isFinite(last)) return [];
|
|
183
|
+
return [...headerTimes()].filter(([, at]) => at > last).map(([id]) => id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* One pending scheduled nudge, answered by a header. Shown: read, and its row stays delivered (the cloud and
|
|
188
|
+
* reconcile expire only from queued). Never shown: skipped, and off the queue as expired, from queued.
|
|
189
|
+
*/
|
|
190
|
+
function settleNudge(row, at) {
|
|
191
|
+
if (row.state === 'delivered') return q.record.run(row.message_id, row.recipient, 'read', at, 'header').changes;
|
|
192
|
+
q.record.run(row.message_id, row.recipient, 'skipped', at, 'header');
|
|
193
|
+
return q.expire.run(row.message_id, row.recipient).changes;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Nudges that ask for an ack and still count as pending in scheduledNudge (wake.mjs), with whether each is settled. */
|
|
197
|
+
const pendingNudges = (now) => db.prepare(`SELECT d.message_id, d.recipient, d.state, m.created_at, m.meta,
|
|
198
|
+
EXISTS (SELECT 1 FROM pulse_reads r WHERE r.message_id = d.message_id AND r.recipient = d.recipient) AS settled
|
|
199
|
+
FROM messages m JOIN deliveries d ON d.message_id = m.id
|
|
200
|
+
WHERE m.sender = 'manager' AND m.type = 'NUDGE' AND m.needs_reply = 1 AND d.state IN ('queued', 'leased', 'delivered') AND m.expires_at > ?`).all(now);
|
|
201
|
+
|
|
202
|
+
/** F8: every pending scheduled nudge whose agent posted a header after it was sent is settled, never acked. */
|
|
203
|
+
function settleNudges(now = clock()) {
|
|
204
|
+
const rows = pendingNudges(now).filter((r) => !r.settled && parseJson(r.meta, {}).scheduled);
|
|
205
|
+
if (!rows.length) return 0;
|
|
206
|
+
const headers = headerTimes();
|
|
207
|
+
let n = 0;
|
|
208
|
+
for (const r of rows) if ((headers.get(r.recipient) || 0) > r.created_at) n += settleNudge(r, now);
|
|
209
|
+
return n;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Agents whose every pending nudge is settled: a read nudge must not hold back the next one when they go quiet again. */
|
|
213
|
+
function settledNudgeRecipients(now = clock()) {
|
|
214
|
+
const open = new Map();
|
|
215
|
+
for (const r of pendingNudges(now)) open.set(r.recipient, (open.get(r.recipient) ?? true) && !!r.settled);
|
|
216
|
+
return [...open].filter(([, settled]) => settled).map(([id]) => id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { readOnHook, managerFyi, reports, honest, withDelivery, status, senderOf, report, bounce, postedSince, settleNudges, settledNudgeRecipients };
|
|
220
|
+
}
|