@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.0.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/README.md +69 -32
- package/package.json +10 -18
- package/src/commands/pulse/doctor.js +14 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/start.js +6 -18
- package/src/commands/pulse/status.js +6 -6
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +36 -0
- package/src/lib/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +656 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +55 -0
- package/src/lib/daemonLoop.js +69 -0
- package/src/lib/daemonProcess.js +366 -0
- package/src/lib/daemonRuntime.js +820 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +19 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -0
- package/src/lib/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +358 -0
- package/src/lib/mirrorSource.js +455 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +358 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +97 -0
- package/src/lib/transport.js +286 -0
- package/src/lib/transportContract.js +37 -0
- package/LICENSE +0 -21
- package/src/commands/pulse/gate.js +0 -25
- package/src/commands/pulse/next.js +0 -14
- package/src/commands/pulse/submit.js +0 -21
- package/src/commands/pulse/verify.js +0 -21
- package/src/runPulse.js +0 -58
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { HubError } from './errors.js';
|
|
4
|
+
import { redactText } from './redaction.js';
|
|
5
|
+
|
|
6
|
+
function parseJsonOutput(stdout, provider) {
|
|
7
|
+
const lines = String(stdout).trim().split('\n').reverse();
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(line);
|
|
11
|
+
} catch {
|
|
12
|
+
// Continue to the prior line.
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
throw new HubError('HUB_PROVIDER_OUTPUT_INVALID', `${provider} returned no JSON result`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function textFrom(value) {
|
|
19
|
+
if (typeof value === 'string') return value;
|
|
20
|
+
if (!value || typeof value !== 'object') return '';
|
|
21
|
+
for (const key of ['result', 'text', 'output', 'response']) {
|
|
22
|
+
const text = textFrom(value[key]);
|
|
23
|
+
if (text) return text;
|
|
24
|
+
}
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function providerFailureCode(message, signal) {
|
|
29
|
+
if (signal) return 'HUB_PROVIDER_CRASHED';
|
|
30
|
+
const text = String(message).toLowerCase();
|
|
31
|
+
if (/\b(?:login|logged in|authentication|unauthorized|credential)\b/.test(text)) {
|
|
32
|
+
return 'HUB_PROVIDER_AUTH';
|
|
33
|
+
}
|
|
34
|
+
if (/\b(?:429|rate limit|throttl)\w*/.test(text)) {
|
|
35
|
+
return 'HUB_PROVIDER_THROTTLED';
|
|
36
|
+
}
|
|
37
|
+
if (/\b(?:resume|session)\b.*\b(?:missing|not found|invalid|expired)\b/.test(text)) {
|
|
38
|
+
return 'HUB_PROVIDER_UNRESUMABLE';
|
|
39
|
+
}
|
|
40
|
+
if (/\b(?:unsupported|minimum|required)\b.*\bversion\b|\bversion\b.*\btoo old\b/.test(text)) {
|
|
41
|
+
return 'HUB_PROVIDER_VERSION_UNSUPPORTED';
|
|
42
|
+
}
|
|
43
|
+
return 'HUB_PROVIDER_FAILED';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function providerEnvironment(provider, source = process.env) {
|
|
47
|
+
const common = new Set(['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL', 'TERM']);
|
|
48
|
+
const allowed = new Set(common);
|
|
49
|
+
if (provider === 'cursor') {
|
|
50
|
+
for (const key of Object.keys(source)) {
|
|
51
|
+
if (key.startsWith('CURSOR_')) allowed.add(key);
|
|
52
|
+
}
|
|
53
|
+
} else if (provider === 'claude') {
|
|
54
|
+
for (const key of Object.keys(source)) {
|
|
55
|
+
if (key.startsWith('ANTHROPIC_') || key.startsWith('CLAUDE_')) allowed.add(key);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return Object.fromEntries(
|
|
59
|
+
[...allowed].filter((key) => source[key] !== undefined).map((key) => [key, source[key]])
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeResult(provider, processResult, existingSessionId = null) {
|
|
64
|
+
if (processResult.exitCode !== 0) {
|
|
65
|
+
const detail = processResult.stderr || processResult.stdout || processResult.signal || processResult.exitCode;
|
|
66
|
+
const safeDetail = redactText(String(detail), { maxBytes: 300 }).text;
|
|
67
|
+
throw new HubError(
|
|
68
|
+
providerFailureCode(detail, processResult.signal),
|
|
69
|
+
`${provider} exited with ${processResult.signal || processResult.exitCode}: ${safeDetail}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const output = parseJsonOutput(processResult.stdout, provider);
|
|
73
|
+
if (output.is_error || output.terminal_reason === 'api_error') {
|
|
74
|
+
const detail = textFrom(output).slice(0, 300) || 'unknown error';
|
|
75
|
+
const safeDetail = redactText(detail, { maxBytes: 300 }).text;
|
|
76
|
+
throw new HubError(
|
|
77
|
+
providerFailureCode(detail),
|
|
78
|
+
`${provider} failed: ${safeDetail}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
provider,
|
|
83
|
+
processRef: processResult.processRef,
|
|
84
|
+
processIdentity: processResult.processIdentity ?? null,
|
|
85
|
+
sessionId: output.session_id || output.sessionId || existingSessionId,
|
|
86
|
+
text: textFrom(output),
|
|
87
|
+
usage: {
|
|
88
|
+
tokens: output.usage?.total_tokens ?? output.usage?.tokens ?? null,
|
|
89
|
+
costUsd: output.total_cost_usd ?? output.usage?.costUsd ?? null,
|
|
90
|
+
turns: output.num_turns ?? output.usage?.turns ?? null,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function neutralDriver({ id, bin, supervisor, buildArgs }) {
|
|
96
|
+
const execute = async (request, sessionId = null) => {
|
|
97
|
+
const processResult = await supervisor.run({
|
|
98
|
+
bin,
|
|
99
|
+
args: buildArgs(request, sessionId),
|
|
100
|
+
cwd: request.workspace,
|
|
101
|
+
timeoutMs: request.budget.maxWallMs,
|
|
102
|
+
maxOutputBytes: request.budget.maxOutputBytes,
|
|
103
|
+
signal: request.signal,
|
|
104
|
+
onStart: request.onStart,
|
|
105
|
+
input: request.prompt,
|
|
106
|
+
env: providerEnvironment(id),
|
|
107
|
+
provider: id,
|
|
108
|
+
attempt: request.attempt,
|
|
109
|
+
shell: false,
|
|
110
|
+
});
|
|
111
|
+
const result = normalizeResult(id, processResult, sessionId);
|
|
112
|
+
if (Number.isFinite(request.budget.maxTokens)) {
|
|
113
|
+
if (!Number.isFinite(result.usage.tokens) || result.usage.tokens < 0) {
|
|
114
|
+
throw new HubError('HUB_BUDGET_USAGE_UNKNOWN', 'Provider did not report valid token usage');
|
|
115
|
+
}
|
|
116
|
+
if (result.usage.tokens > request.budget.maxTokens) {
|
|
117
|
+
throw new HubError('HUB_BUDGET_TOKENS', 'Provider token budget exceeded');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (Number.isFinite(request.budget.maxCostUsd)) {
|
|
121
|
+
if (!Number.isFinite(result.usage.costUsd) || result.usage.costUsd < 0) {
|
|
122
|
+
throw new HubError('HUB_BUDGET_USAGE_UNKNOWN', 'Provider did not report valid cost usage');
|
|
123
|
+
}
|
|
124
|
+
if (result.usage.costUsd > request.budget.maxCostUsd) {
|
|
125
|
+
throw new HubError('HUB_BUDGET_COST', 'Provider cost budget exceeded');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
};
|
|
130
|
+
return Object.freeze({
|
|
131
|
+
id,
|
|
132
|
+
probe: () => supervisor.probe(bin, ['--version']),
|
|
133
|
+
start: (request) => execute(request),
|
|
134
|
+
resume: (request) => execute(request, request.sessionId),
|
|
135
|
+
send: (request) => execute(request, request.sessionId),
|
|
136
|
+
cancel: (processIdentity) => supervisor.cancel(processIdentity),
|
|
137
|
+
status: (processRef) => supervisor.status(processRef),
|
|
138
|
+
collectSummary(result) {
|
|
139
|
+
const redacted = redactText(result.text, { maxBytes: 4_000 });
|
|
140
|
+
return {
|
|
141
|
+
text: redacted.text,
|
|
142
|
+
usage: result.usage ?? null,
|
|
143
|
+
redacted: redacted.redactions > 0 || redacted.truncated,
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function createCursorDriver({
|
|
150
|
+
supervisor,
|
|
151
|
+
bin = process.env.CURSOR_AGENT || path.join(os.homedir(), '.local', 'bin', 'cursor-agent'),
|
|
152
|
+
} = {}) {
|
|
153
|
+
return neutralDriver({
|
|
154
|
+
id: 'cursor',
|
|
155
|
+
bin,
|
|
156
|
+
supervisor,
|
|
157
|
+
buildArgs(request, sessionId) {
|
|
158
|
+
const args = ['-p', '--output-format', 'json', '--workspace', request.workspace];
|
|
159
|
+
if (sessionId) args.push('--resume', sessionId);
|
|
160
|
+
args.push('--model', request.model);
|
|
161
|
+
if (request.mode === 'read') args.push('--mode', 'ask');
|
|
162
|
+
else args.push('--sandbox', 'enabled');
|
|
163
|
+
return args;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function createClaudeDriver({
|
|
169
|
+
supervisor,
|
|
170
|
+
bin = process.env.CLAUDE_BIN || path.join(os.homedir(), '.local', 'bin', 'claude'),
|
|
171
|
+
} = {}) {
|
|
172
|
+
return neutralDriver({
|
|
173
|
+
id: 'claude',
|
|
174
|
+
bin,
|
|
175
|
+
supervisor,
|
|
176
|
+
buildArgs(request, sessionId) {
|
|
177
|
+
const args = [
|
|
178
|
+
'-p',
|
|
179
|
+
'--output-format',
|
|
180
|
+
'json',
|
|
181
|
+
'--model',
|
|
182
|
+
request.model,
|
|
183
|
+
'--settings',
|
|
184
|
+
JSON.stringify({ disableAllHooks: true }),
|
|
185
|
+
];
|
|
186
|
+
if (sessionId) args.push('--resume', sessionId);
|
|
187
|
+
if (request.mode === 'read') {
|
|
188
|
+
args.push('--permission-mode', 'plan');
|
|
189
|
+
} else {
|
|
190
|
+
args.push(
|
|
191
|
+
'--permission-mode',
|
|
192
|
+
'acceptEdits',
|
|
193
|
+
'--allowedTools',
|
|
194
|
+
'Read,Glob,Grep,Edit,Write'
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return args;
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export class DriverRegistry {
|
|
203
|
+
constructor({ drivers, policy }) {
|
|
204
|
+
this.drivers = new Map(drivers.map((driver) => [driver.id, driver]));
|
|
205
|
+
this.policy = policy;
|
|
206
|
+
this.active = 0;
|
|
207
|
+
this.wrappers = new Map();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
driver(provider, model) {
|
|
211
|
+
const allowedModels = this.policy.providers[provider];
|
|
212
|
+
if (!allowedModels || !this.drivers.has(provider)) {
|
|
213
|
+
throw new HubError('HUB_PROVIDER_DENIED', `Provider is not allowed: ${provider}`);
|
|
214
|
+
}
|
|
215
|
+
if (!allowedModels.includes(model)) {
|
|
216
|
+
throw new HubError(
|
|
217
|
+
'HUB_PROVIDER_MODEL_DENIED',
|
|
218
|
+
`Model ${model} is not allowed for provider ${provider}`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const key = `${provider}\0${model}`;
|
|
222
|
+
if (this.wrappers.has(key)) return this.wrappers.get(key);
|
|
223
|
+
const driver = this.drivers.get(provider);
|
|
224
|
+
const limits = this.policy.limits || {};
|
|
225
|
+
const run = async (method, request) => {
|
|
226
|
+
if (request.model !== model) {
|
|
227
|
+
throw new HubError(
|
|
228
|
+
'HUB_PROVIDER_MODEL_DENIED',
|
|
229
|
+
`Request model ${String(request.model)} does not match validated model ${model}`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (Number.isFinite(limits.maxConcurrency) && this.active >= limits.maxConcurrency) {
|
|
233
|
+
throw new HubError('HUB_BUDGET_CONCURRENCY', 'Machine provider concurrency exceeded');
|
|
234
|
+
}
|
|
235
|
+
if (!['read', 'write'].includes(request.mode)) {
|
|
236
|
+
throw new HubError('HUB_PROVIDER_MODE_DENIED', 'Provider mode must be read or write');
|
|
237
|
+
}
|
|
238
|
+
const roleModes = this.policy.roleModes?.[request.role];
|
|
239
|
+
if (!Array.isArray(roleModes) || !roleModes.includes(request.mode)) {
|
|
240
|
+
throw new HubError(
|
|
241
|
+
'HUB_PROVIDER_ROLE_DENIED',
|
|
242
|
+
`Role ${String(request.role)} is not allowed in ${request.mode} mode`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const comparisons = [
|
|
246
|
+
['maxTokens', 'maxTokensPerRun', 'HUB_BUDGET_TOKENS'],
|
|
247
|
+
['maxCostUsd', 'maxCostUsd', 'HUB_BUDGET_COST'],
|
|
248
|
+
['maxWallMs', 'maxWallMs', 'HUB_BUDGET_TIME'],
|
|
249
|
+
['maxOutputBytes', 'maxOutputBytes', 'HUB_BUDGET_OUTPUT'],
|
|
250
|
+
];
|
|
251
|
+
for (const [requestKey, policyKey, code] of comparisons) {
|
|
252
|
+
if (
|
|
253
|
+
Number.isFinite(limits[policyKey]) &&
|
|
254
|
+
(!Number.isFinite(request.budget?.[requestKey]) ||
|
|
255
|
+
request.budget[requestKey] > limits[policyKey])
|
|
256
|
+
) {
|
|
257
|
+
throw new HubError(code, `Requested ${requestKey} exceeds local provider policy`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
this.active += 1;
|
|
261
|
+
try {
|
|
262
|
+
return await driver[method](request);
|
|
263
|
+
} finally {
|
|
264
|
+
this.active = Math.max(0, this.active - 1);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
const wrapper = Object.freeze({
|
|
268
|
+
id: driver.id,
|
|
269
|
+
probe: () => driver.probe(),
|
|
270
|
+
start: (request) => run('start', request),
|
|
271
|
+
resume: (request) => run('resume', request),
|
|
272
|
+
send: (request) => run('send', request),
|
|
273
|
+
cancel: (processIdentity) => driver.cancel(processIdentity),
|
|
274
|
+
status: (processRef) => driver.status(processRef),
|
|
275
|
+
collectSummary: (result) => driver.collectSummary(result),
|
|
276
|
+
});
|
|
277
|
+
this.wrappers.set(key, wrapper);
|
|
278
|
+
return wrapper;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class HubError extends Error {
|
|
2
|
+
constructor(code, message, options = {}) {
|
|
3
|
+
super(message, options);
|
|
4
|
+
this.name = 'HubError';
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.exit = options.exit ?? 1;
|
|
7
|
+
this.details = options.details;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assertNever(value, label = 'value') {
|
|
12
|
+
throw new HubError('HUB_INTERNAL_UNHANDLED_VARIANT', `Unhandled ${label}: ${String(value)}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function requireCondition(condition, code, message, details) {
|
|
16
|
+
if (!condition) {
|
|
17
|
+
throw new HubError(code, message, { details });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { MIRROR_CONTRACT, MIRROR_LIMITS, itemBytes, sanitizePart } from './mirrorContract.js';
|
|
3
|
+
|
|
4
|
+
const SCHEMA = MIRROR_CONTRACT.kinds.graph_window.properties;
|
|
5
|
+
export const GRAPH_HOURS = Object.freeze([...SCHEMA.windows.items.enum]);
|
|
6
|
+
export const GRAPH_SECTIONS = Object.freeze(SCHEMA.section.enum.filter((section) => section !== 'meta'));
|
|
7
|
+
const ITEM_HEADROOM = 1024;
|
|
8
|
+
|
|
9
|
+
function canonical(value) {
|
|
10
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
11
|
+
if (value && typeof value === 'object') {
|
|
12
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
13
|
+
}
|
|
14
|
+
return JSON.stringify(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const digest = (value) => createHash('sha256').update(canonical(value)).digest('hex').slice(0, 16);
|
|
18
|
+
const list = (value) => (Array.isArray(value) ? value : []);
|
|
19
|
+
|
|
20
|
+
/** The local projection's sections; a delivery's streams are rows of their own so a delivery item stays small. */
|
|
21
|
+
function sectionRows(graph) {
|
|
22
|
+
const deliveries = list(graph.deliveries);
|
|
23
|
+
return {
|
|
24
|
+
deliveries: deliveries.map(({ streams: _streams, ...delivery }) => delivery),
|
|
25
|
+
streams: deliveries.flatMap((delivery) => list(delivery.streams).map((stream) => ({ ...stream, deliveryId: delivery.id }))),
|
|
26
|
+
agents: list(graph.agents),
|
|
27
|
+
nodes: list(graph.nodes),
|
|
28
|
+
links: list(graph.links),
|
|
29
|
+
events: list(graph.events),
|
|
30
|
+
requests: list(graph.requests),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Events have no id on the local hub and never change once written, so their content is their key.
|
|
35
|
+
const ROW_KEY = {
|
|
36
|
+
deliveries: (row) => row.id,
|
|
37
|
+
streams: (row) => row.key,
|
|
38
|
+
agents: (row) => row.id,
|
|
39
|
+
nodes: (row) => row.id,
|
|
40
|
+
links: (row) => `${row.source}>${row.target}:${row.kind}`,
|
|
41
|
+
requests: (row) => row.id,
|
|
42
|
+
};
|
|
43
|
+
function keyOf(section, row) {
|
|
44
|
+
const key = ROW_KEY[section]?.(row);
|
|
45
|
+
return typeof key === 'string' && key ? key : digest(row);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The /api/graph?hours= bodies of every window as graph_window items: one item per row, shared by the windows whose
|
|
50
|
+
* projection holds exactly that row, and a meta item per window. A row that is unchanged keeps its bytes, so the mirror
|
|
51
|
+
* uploads only rows that moved. Every row is redacted and checked on its own; one that still fails is left out and
|
|
52
|
+
* counted in `dropped`, never silently.
|
|
53
|
+
*/
|
|
54
|
+
export function graphWindowItems(windows, options = {}) {
|
|
55
|
+
return mergeWindows(windows.map(({ hours, body }) => windowRows(hours, body, options)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What redacting and checking each row gave, kept from one pass to the next. Most rows are in several windows and
|
|
60
|
+
* unchanged since the last pass, so each distinct row is redacted and checked once, and again only once it moves.
|
|
61
|
+
* The results hold for one redactor: begin() with another signature (a new repo root, workspace or secret) drops them.
|
|
62
|
+
*/
|
|
63
|
+
export class RowMemo {
|
|
64
|
+
constructor() {
|
|
65
|
+
this.signature = null;
|
|
66
|
+
this.previous = new Map();
|
|
67
|
+
this.current = new Map();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Starts a pass. Only what this pass and the last one used is kept, so rows that left every window are let go. */
|
|
71
|
+
begin(signature) {
|
|
72
|
+
this.previous = signature === this.signature ? this.current : new Map();
|
|
73
|
+
this.current = new Map();
|
|
74
|
+
this.signature = signature;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
take(key, compute) {
|
|
78
|
+
const value = this.current.get(key) ?? this.previous.get(key) ?? compute();
|
|
79
|
+
this.current.set(key, value);
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkedRow(section, row, { redact, secrets, maxBytes }) {
|
|
85
|
+
const result = sanitizePart(SCHEMA[section], redact(row), { secrets });
|
|
86
|
+
const error = result.error || (itemBytes({ section, windows: GRAPH_HOURS, [section]: result.data }) > maxBytes ? 'item_too_large' : null);
|
|
87
|
+
if (error) return { error };
|
|
88
|
+
return { key: keyOf(section, result.data), text: canonical(result.data), data: result.data };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One window's redacted, checked rows and its meta item: the costly half of graphWindowItems, which a caller may keep
|
|
93
|
+
* and merge again with fresher windows. With a RowMemo, rows already checked under the same redactor are not again.
|
|
94
|
+
*/
|
|
95
|
+
export function windowRows(hours, body, { redact = (value) => value, secrets = [], maxBytes = MIRROR_LIMITS.maxItemBytes - ITEM_HEADROOM, memo = null } = {}) {
|
|
96
|
+
const reasons = {};
|
|
97
|
+
const counts = {};
|
|
98
|
+
const droppedRows = {};
|
|
99
|
+
const rows = [];
|
|
100
|
+
const check = { redact, secrets, maxBytes };
|
|
101
|
+
for (const [section, raw] of Object.entries(sectionRows(body))) {
|
|
102
|
+
const seen = new Map();
|
|
103
|
+
counts[section] = 0;
|
|
104
|
+
for (const row of raw) {
|
|
105
|
+
const checked = memo
|
|
106
|
+
? memo.take(`${section}\0${JSON.stringify(row)}`, () => checkedRow(section, row, check))
|
|
107
|
+
: checkedRow(section, row, check);
|
|
108
|
+
if (checked.error) {
|
|
109
|
+
droppedRows[section] = (droppedRows[section] || 0) + 1;
|
|
110
|
+
reasons[section] ||= {};
|
|
111
|
+
reasons[section][checked.error] = (reasons[section][checked.error] || 0) + 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const nth = (seen.get(checked.key) || 0) + 1;
|
|
115
|
+
seen.set(checked.key, nth);
|
|
116
|
+
rows.push({ id: `${section}/${nth > 1 ? `${checked.key}~${nth}` : checked.key}`, text: checked.text, section, row: checked.data });
|
|
117
|
+
counts[section] += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const totals = sanitizePart(SCHEMA.totals, body.totals ?? null, { secrets, depth: 2 });
|
|
121
|
+
const manager = sanitizePart(SCHEMA.manager, body.manager ? redact(body.manager) : null, { secrets, depth: 2 });
|
|
122
|
+
const meta = {
|
|
123
|
+
externalId: `meta/${hours}h`,
|
|
124
|
+
data: { section: 'meta', hours, totals: totals.error ? null : totals.data, manager: manager.error ? null : manager.data, counts, dropped: droppedRows },
|
|
125
|
+
};
|
|
126
|
+
return { hours, rows, meta, reasons };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Rows that are identical in several windows become one item held by all of them. */
|
|
130
|
+
export function mergeWindows(perWindow) {
|
|
131
|
+
const variants = new Map();
|
|
132
|
+
const dropped = {};
|
|
133
|
+
const metas = [];
|
|
134
|
+
for (const { hours, rows, meta, reasons } of [...perWindow].sort((a, b) => a.hours - b.hours)) {
|
|
135
|
+
for (const { id, text, section, row } of rows) {
|
|
136
|
+
if (!variants.has(id)) variants.set(id, new Map());
|
|
137
|
+
const group = variants.get(id);
|
|
138
|
+
if (!group.has(text)) group.set(text, { section, row, windows: [] });
|
|
139
|
+
group.get(text).windows.push(hours);
|
|
140
|
+
}
|
|
141
|
+
metas.push(meta);
|
|
142
|
+
dropped[`${hours}h`] = reasons;
|
|
143
|
+
}
|
|
144
|
+
const items = [];
|
|
145
|
+
for (const [id, group] of variants) {
|
|
146
|
+
for (const { section, row, windows: held } of group.values()) items.push({ externalId: `${id}@${held.at(-1)}h`, data: { section, windows: held, [section]: row } });
|
|
147
|
+
}
|
|
148
|
+
return { items: [...metas, ...items], dropped };
|
|
149
|
+
}
|
package/src/lib/http.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { gzipSync } from 'node:zlib';
|
|
2
|
+
import { HubError } from './errors.js';
|
|
3
|
+
import { CONNECTOR_RESPONSE_MAX_BYTES } from './transportContract.js';
|
|
4
|
+
|
|
5
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
6
|
+
|
|
7
|
+
export function cleanOrigin(value, label = 'URL') {
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = new URL(value);
|
|
11
|
+
} catch {
|
|
12
|
+
throw new HubError('HUB_URL_INVALID', `${label} is not a valid URL`);
|
|
13
|
+
}
|
|
14
|
+
const loopbackHttp = parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname);
|
|
15
|
+
if (
|
|
16
|
+
(parsed.protocol !== 'https:' && !loopbackHttp) ||
|
|
17
|
+
parsed.username ||
|
|
18
|
+
parsed.password ||
|
|
19
|
+
parsed.search ||
|
|
20
|
+
parsed.hash ||
|
|
21
|
+
!['', '/'].includes(parsed.pathname)
|
|
22
|
+
) {
|
|
23
|
+
throw new HubError('HUB_URL_INVALID', `${label} must be a clean HTTPS origin`);
|
|
24
|
+
}
|
|
25
|
+
return parsed.origin;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function decodeJwtClaims(token) {
|
|
29
|
+
const parts = typeof token === 'string' ? token.split('.') : [];
|
|
30
|
+
if (parts.length !== 3) return null;
|
|
31
|
+
try {
|
|
32
|
+
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
33
|
+
return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function fetchJson({
|
|
40
|
+
fetch,
|
|
41
|
+
url,
|
|
42
|
+
method = 'POST',
|
|
43
|
+
headers = {},
|
|
44
|
+
body,
|
|
45
|
+
timeoutMs = 30_000,
|
|
46
|
+
maxBytes = CONNECTOR_RESPONSE_MAX_BYTES,
|
|
47
|
+
gzipAboveBytes = Infinity,
|
|
48
|
+
}) {
|
|
49
|
+
const json = body === undefined ? null : Buffer.from(JSON.stringify(body), 'utf8');
|
|
50
|
+
const gzip = json !== null && json.length > gzipAboveBytes;
|
|
51
|
+
const sent = gzip ? gzipSync(json) : json;
|
|
52
|
+
const response = await fetch(url, {
|
|
53
|
+
method,
|
|
54
|
+
headers: {
|
|
55
|
+
accept: 'application/json',
|
|
56
|
+
...(json === null ? {} : { 'content-type': 'application/json; charset=utf-8' }),
|
|
57
|
+
...(gzip ? { 'content-encoding': 'gzip' } : {}),
|
|
58
|
+
...headers,
|
|
59
|
+
},
|
|
60
|
+
...(sent === null ? {} : { body: sent }),
|
|
61
|
+
redirect: 'error',
|
|
62
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
63
|
+
});
|
|
64
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
65
|
+
if (bytes.length > maxBytes) {
|
|
66
|
+
throw new HubError('HUB_TRANSPORT_RESPONSE_TOO_LARGE', `Response exceeded ${maxBytes} bytes`);
|
|
67
|
+
}
|
|
68
|
+
let parsed = {};
|
|
69
|
+
try {
|
|
70
|
+
parsed = bytes.length ? JSON.parse(bytes.toString('utf8')) : {};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new HubError('HUB_TRANSPORT_INVALID_JSON', 'Server returned invalid JSON', { cause: error });
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
status: response.status,
|
|
76
|
+
ok: response.ok,
|
|
77
|
+
body: parsed && typeof parsed === 'object' ? parsed : {},
|
|
78
|
+
retryAfter: response.headers.has('retry-after') ? Number(response.headers.get('retry-after')) : Number.NaN,
|
|
79
|
+
serverTiming: response.headers.get('server-timing'),
|
|
80
|
+
contractAccepts: response.headers.get('x-pulse-contract-accepts'),
|
|
81
|
+
bytes: json?.length ?? 0,
|
|
82
|
+
sentBytes: sent?.length ?? 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { assertNever, HubError } from './errors.js';
|
|
2
|
+
import { HUB_JOB_TYPES } from './intents.js';
|
|
3
|
+
|
|
4
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
5
|
+
|
|
6
|
+
async function write(writer, type, args) {
|
|
7
|
+
switch (type) {
|
|
8
|
+
case 'hub.chat.post': {
|
|
9
|
+
const out = await writer.post('/api/chat/post', { ch: args.ch, text: args.text });
|
|
10
|
+
if (out.queued) return { outcome: 'queued', summary: `Queued for ${args.ch.slice(3)}; the agent reads it on its next turn.` };
|
|
11
|
+
return { outcome: 'posted', summary: `Posted to ${out.channel || args.ch}.` };
|
|
12
|
+
}
|
|
13
|
+
case 'hub.nudge': {
|
|
14
|
+
const out = await writer.post('/api/message', { to: args.to, type: args.type, text: args.text });
|
|
15
|
+
return { outcome: 'sent', summary: `Sent ${args.type} to ${plural(Number(out.sent) || args.to.length, 'agent')}.` };
|
|
16
|
+
}
|
|
17
|
+
case 'hub.decision':
|
|
18
|
+
await writer.post('/api/decision', { key: args.key, kind: args.kind, text: args.text, ...(args.ref ? { ref: args.ref } : {}) });
|
|
19
|
+
return { outcome: 'recorded', summary: `Recorded ${args.kind} on ${args.key}.` };
|
|
20
|
+
case 'hub.accept':
|
|
21
|
+
await writer.post('/api/accept', { delivery: args.delivery, note: args.note || '' });
|
|
22
|
+
return { outcome: 'accepted', summary: `Accepted ${args.delivery}.` };
|
|
23
|
+
case 'hub.bus.reply':
|
|
24
|
+
await writer.post('/bus/ack', { as: 'user', id: args.id, reply: args.text });
|
|
25
|
+
return { outcome: 'replied', summary: `Replied to ${args.id}.` };
|
|
26
|
+
case 'hub.status.set': {
|
|
27
|
+
const out = await writer.post('/api/status', args);
|
|
28
|
+
if (args.step) {
|
|
29
|
+
const step = args.step[0].toUpperCase() + args.step.slice(1);
|
|
30
|
+
if (out.unchanged) return { outcome: 'recorded', summary: `${args.target.id} was already in ${step}.` };
|
|
31
|
+
const noted = Array.isArray(out.posted) && out.posted.length ? '; noted in its channel' : '';
|
|
32
|
+
return { outcome: 'recorded', summary: out.cleared ? `Moved ${args.target.id} back to ${step}, the step its work is in${noted}.` : `Moved ${args.target.id} to ${step}${noted}.` };
|
|
33
|
+
}
|
|
34
|
+
const status = `${args.status.replace('-', ' ')}${args.to ? ` to ${args.to}` : ''}`;
|
|
35
|
+
if (out.unchanged) return { outcome: 'recorded', summary: `${args.target.id} was already ${status}.` };
|
|
36
|
+
const posted = Array.isArray(out.posted) ? out.posted.length : 0;
|
|
37
|
+
return { outcome: 'recorded', summary: `Set ${args.target.id} to ${status}${posted ? `; noted in ${plural(posted, 'channel')}` : ''}.` };
|
|
38
|
+
}
|
|
39
|
+
default:
|
|
40
|
+
return assertNever(type, 'hub job type');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What a requeued action's earlier attempt on this machine settles for it. A write that may have reached the hub is
|
|
46
|
+
* never repeated: the user sees `hub_write_unconfirmed` and checks the hub rather than getting a second post.
|
|
47
|
+
*/
|
|
48
|
+
export function replayPriorAttempt(prior) {
|
|
49
|
+
if (!prior) return null;
|
|
50
|
+
if (prior.state === 'succeeded' && prior.result) return { result: prior.result };
|
|
51
|
+
if (prior.state === 'failed' && prior.result?.code) {
|
|
52
|
+
return { error: new HubError(prior.result.code, prior.result.summary || 'The earlier attempt failed') };
|
|
53
|
+
}
|
|
54
|
+
if (prior.startedAt || ['running', 'cancel_requested'].includes(prior.state)) {
|
|
55
|
+
return {
|
|
56
|
+
error: new HubError(
|
|
57
|
+
'hub_write_unconfirmed',
|
|
58
|
+
'An earlier attempt reached the local hub but its result was not recorded; check the hub before sending again',
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Job handlers for the typed `hub.*` actions; `writerFor()` resolves the local hub per job so a moved hub is found. */
|
|
66
|
+
export function createHubActionHandlers({ writerFor }) {
|
|
67
|
+
const handle = (type) => async (job, context = {}) => {
|
|
68
|
+
const replay = replayPriorAttempt(context.priorAttempt);
|
|
69
|
+
if (replay?.error) throw replay.error;
|
|
70
|
+
if (replay) return replay.result;
|
|
71
|
+
return write(await writerFor(), type, job.arguments);
|
|
72
|
+
};
|
|
73
|
+
return Object.fromEntries(HUB_JOB_TYPES.map((type) => [type, handle(type)]));
|
|
74
|
+
}
|