@agentpulselabs/cli-plugin-pulse 0.1.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 -10
- 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 +8 -18
- package/src/commands/pulse/status.js +8 -7
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +36 -0
- package/src/lib/BaseCommand.js +37 -0
- 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/src/commands/pulse/gate.js +0 -23
- package/src/commands/pulse/next.js +0 -13
- package/src/commands/pulse/submit.js +0 -19
- package/src/commands/pulse/verify.js +0 -18
- package/src/runPulse.js +0 -58
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { CloudAuth, loginRequired } from './cloudAuth.js';
|
|
6
|
+
import { HubError } from './errors.js';
|
|
7
|
+
import { cleanOrigin, fetchJson } from './http.js';
|
|
8
|
+
import { captureRootGrant } from './rootPolicy.js';
|
|
9
|
+
import { writeOwnerOnlyJson } from './storage.js';
|
|
10
|
+
import { MACHINE_REGISTRATION_PATH } from './transportContract.js';
|
|
11
|
+
|
|
12
|
+
export const CONNECTOR_VERSION = JSON.parse(
|
|
13
|
+
readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
|
|
14
|
+
).version;
|
|
15
|
+
const SESSION_SKEW_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
async function freezeRoots({ roots, configDir }) {
|
|
18
|
+
const requested = new Map();
|
|
19
|
+
for (const entry of roots) {
|
|
20
|
+
const separator = entry.indexOf('=');
|
|
21
|
+
if (separator < 1 || separator === entry.length - 1) {
|
|
22
|
+
throw new HubError('HUB_ROOT_ARGUMENT_INVALID', 'Roots must use <rootId>=<path>');
|
|
23
|
+
}
|
|
24
|
+
requested.set(entry.slice(0, separator), entry.slice(separator + 1));
|
|
25
|
+
}
|
|
26
|
+
if (requested.size < 1 || requested.size > 32) {
|
|
27
|
+
throw new HubError('HUB_ROOT_GRANT_MISMATCH', 'Pairing requires 1 to 32 explicitly approved roots');
|
|
28
|
+
}
|
|
29
|
+
const localRoots = [];
|
|
30
|
+
const grantRoots = [];
|
|
31
|
+
for (const [rootId, localPath] of requested) {
|
|
32
|
+
const local = await captureRootGrant({
|
|
33
|
+
rootId,
|
|
34
|
+
root: localPath,
|
|
35
|
+
repoWrite: false,
|
|
36
|
+
worktreeBase: path.join(configDir, 'worktrees', rootId),
|
|
37
|
+
});
|
|
38
|
+
const canonicalPathHash = createHash('sha256').update(local.canonicalRoot, 'utf8').digest('hex');
|
|
39
|
+
localRoots.push({ ...local, grantCanonicalPathHash: canonicalPathHash });
|
|
40
|
+
grantRoots.push({ rootId, label: rootId, canonicalPathHash, displayPath: rootId, repoWrite: false });
|
|
41
|
+
}
|
|
42
|
+
return { localRoots, grantRoots };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function signedInSession({ tokenStore, platformUrl, login, now }) {
|
|
46
|
+
const stored = await tokenStore.load();
|
|
47
|
+
const fresh = stored?.platformUrl === platformUrl && Date.parse(stored.expiresAt || '') - now() > SESSION_SKEW_MS;
|
|
48
|
+
if (fresh) return stored;
|
|
49
|
+
if (!login) throw loginRequired('no valid sign-in for this platform');
|
|
50
|
+
const session = await login({ platformUrl });
|
|
51
|
+
await tokenStore.save(session);
|
|
52
|
+
return session;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function resolveDevOrg({ fetch, platformUrl, session, slug }) {
|
|
56
|
+
const listed = await fetchJson({
|
|
57
|
+
fetch,
|
|
58
|
+
url: `${platformUrl}/api/team/dev-orgs`,
|
|
59
|
+
method: 'GET',
|
|
60
|
+
headers: { authorization: `Bearer ${session.accessToken}` },
|
|
61
|
+
});
|
|
62
|
+
if ([401, 403].includes(listed.status)) throw loginRequired(`the platform refused to list dev-orgs (HTTP ${listed.status})`);
|
|
63
|
+
if (!listed.ok) throw new HubError('HUB_TARGET_UNAVAILABLE', `Could not list dev-orgs (HTTP ${listed.status})`);
|
|
64
|
+
const devOrg = (listed.body.devOrgs || []).find((candidate) => candidate.slug === slug);
|
|
65
|
+
if (!devOrg) throw new HubError('HUB_DEV_ORG_UNKNOWN', `Dev-org "${slug}" is not in your team. Run \`agentpulse org list\`.`);
|
|
66
|
+
return { id: devOrg.id, slug: devOrg.slug };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function registeredMachine(response, { machineId, session, registration }) {
|
|
70
|
+
const machine = response?.machine;
|
|
71
|
+
const grant = machine?.activeGrant;
|
|
72
|
+
if (
|
|
73
|
+
machine?.id !== machineId ||
|
|
74
|
+
machine?.paired_by_user_id !== session.userId ||
|
|
75
|
+
machine?.team_id !== session.teamId ||
|
|
76
|
+
!Number.isInteger(grant?.version) ||
|
|
77
|
+
grant.version < 1 ||
|
|
78
|
+
!isDeepStrictEqual(grant.roots, registration.roots) ||
|
|
79
|
+
!isDeepStrictEqual(grant.capabilities, registration.capabilities) ||
|
|
80
|
+
!isDeepStrictEqual(grant.providerPolicy, registration.providerPolicy)
|
|
81
|
+
) {
|
|
82
|
+
throw new HubError('HUB_PAIR_RESPONSE_INVALID', 'Cloud registration response does not match this request');
|
|
83
|
+
}
|
|
84
|
+
return { machineId: machine.id, policyVersion: grant.version };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function pairConnector({
|
|
88
|
+
config,
|
|
89
|
+
configDir,
|
|
90
|
+
configFile,
|
|
91
|
+
tokenStore,
|
|
92
|
+
fetch = globalThis.fetch,
|
|
93
|
+
login = null,
|
|
94
|
+
cliDefaults,
|
|
95
|
+
now = Date.now,
|
|
96
|
+
pairingPolicy = {
|
|
97
|
+
capabilities: ['workspace.read', 'git.read'],
|
|
98
|
+
providerPolicy: { providers: {}, maxConcurrency: 1, maxTokensPerRun: 1, maxCostUsd: 0 },
|
|
99
|
+
},
|
|
100
|
+
platformUrl,
|
|
101
|
+
org,
|
|
102
|
+
displayName,
|
|
103
|
+
roots = [],
|
|
104
|
+
}) {
|
|
105
|
+
if (config.paired) {
|
|
106
|
+
throw new HubError('HUB_ALREADY_PAIRED', 'This connector is already paired. Revoke it before pairing again.');
|
|
107
|
+
}
|
|
108
|
+
const defaults = (!platformUrl || org === undefined) ? await cliDefaults() : {};
|
|
109
|
+
const stored = platformUrl ? null : await tokenStore.load();
|
|
110
|
+
const platform = platformUrl || stored?.platformUrl || defaults.platformUrl;
|
|
111
|
+
if (!platform) {
|
|
112
|
+
throw new HubError('HUB_PLATFORM_REQUIRED', 'Pass --platform <url> or run `agentpulse login` first');
|
|
113
|
+
}
|
|
114
|
+
const origin = cleanOrigin(platform, 'Platform URL');
|
|
115
|
+
const devOrgSlug = org === undefined ? defaults.devOrgSlug || null : org;
|
|
116
|
+
const session = await signedInSession({ tokenStore, platformUrl: origin, login, now });
|
|
117
|
+
const devOrg = devOrgSlug ? await resolveDevOrg({ fetch, platformUrl: origin, session, slug: devOrgSlug }) : null;
|
|
118
|
+
const auth = new CloudAuth({
|
|
119
|
+
tokenStore,
|
|
120
|
+
fetch,
|
|
121
|
+
now,
|
|
122
|
+
target: { platformUrl: origin, devOrg, instanceUrl: null, userId: session.userId, teamId: session.teamId },
|
|
123
|
+
});
|
|
124
|
+
const credentials = await auth.credentials();
|
|
125
|
+
const { localRoots, grantRoots } = await freezeRoots({ roots, configDir });
|
|
126
|
+
const machineId = config.pairing?.state === 'registering' ? config.pairing.machineId : randomUUID();
|
|
127
|
+
const registration = {
|
|
128
|
+
machineId,
|
|
129
|
+
displayName,
|
|
130
|
+
platform: process.platform,
|
|
131
|
+
connectorVersion: CONNECTOR_VERSION,
|
|
132
|
+
protocolVersion: 1,
|
|
133
|
+
roots: grantRoots,
|
|
134
|
+
capabilities: structuredClone(pairingPolicy.capabilities),
|
|
135
|
+
providerPolicy: structuredClone(pairingPolicy.providerPolicy),
|
|
136
|
+
};
|
|
137
|
+
const pendingConfig = {
|
|
138
|
+
...config,
|
|
139
|
+
pairing: { state: 'registering', machineId, startedAt: config.pairing?.startedAt || new Date(now()).toISOString() },
|
|
140
|
+
};
|
|
141
|
+
await writeOwnerOnlyJson(configFile, pendingConfig);
|
|
142
|
+
const registered = await fetchJson({
|
|
143
|
+
fetch,
|
|
144
|
+
url: `${auth.instanceUrl}${MACHINE_REGISTRATION_PATH}`,
|
|
145
|
+
headers: credentials,
|
|
146
|
+
body: registration,
|
|
147
|
+
});
|
|
148
|
+
if (registered.status === 401) throw loginRequired('the dev-org refused the sign-in');
|
|
149
|
+
if (registered.status === 403 && registered.body.error === 'FORBIDDEN') {
|
|
150
|
+
throw new HubError('HUB_SCOPE_DENIED', 'Your Pulse tier does not allow registering machines');
|
|
151
|
+
}
|
|
152
|
+
if (!registered.ok) {
|
|
153
|
+
throw new HubError(registered.body.error || `HTTP_${registered.status}`, registered.body.message || 'Machine registration failed');
|
|
154
|
+
}
|
|
155
|
+
const { policyVersion } = registeredMachine(registered.body, { machineId, session, registration });
|
|
156
|
+
await writeOwnerOnlyJson(configFile, {
|
|
157
|
+
...pendingConfig,
|
|
158
|
+
pairing: null,
|
|
159
|
+
capabilities: registration.capabilities,
|
|
160
|
+
localRoots,
|
|
161
|
+
paired: {
|
|
162
|
+
machineId,
|
|
163
|
+
policyVersion,
|
|
164
|
+
state: 'active',
|
|
165
|
+
baseUrl: auth.instanceUrl,
|
|
166
|
+
target: { platformUrl: origin, devOrg },
|
|
167
|
+
userId: session.userId,
|
|
168
|
+
teamId: session.teamId,
|
|
169
|
+
pairedAt: new Date(now()).toISOString(),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
command: 'pair',
|
|
174
|
+
state: 'paired',
|
|
175
|
+
machineId,
|
|
176
|
+
target: devOrg ? devOrg.slug : origin,
|
|
177
|
+
message: `Pulse connector paired with ${devOrg ? `dev-org ${devOrg.slug}` : origin}.`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
|
|
3
|
+
const PATH_ONLY_FIELDS = new Set(['file', 'worktreePath', 'mainRepo', 'channelCheckout', 'cwd', 'path', 'transcriptPath']);
|
|
4
|
+
const PATH_TAIL = String.raw`[^\s"'\`<>()\[\]{},;]*`;
|
|
5
|
+
const PATH_START = String.raw`(?:~/|/(?:Users|Volumes|home|private|tmp|var|opt|mnt|root|etc|Library|Applications)/|[A-Za-z]:\\)`;
|
|
6
|
+
const ABSOLUTE = new RegExp(String.raw`(?<![\w./~\\-])${PATH_START}${PATH_TAIL}`, 'g');
|
|
7
|
+
const ABSOLUTE_PROBE = new RegExp(ABSOLUTE.source);
|
|
8
|
+
const DELIMITED = new RegExp(String.raw`(\`|\]\()(${PATH_START}[^\`)\n]*)(?=[\`)])`, 'g');
|
|
9
|
+
const FILE_URL = /file:\/\/(?=\/|[A-Za-z]:\\)/g;
|
|
10
|
+
const HOME_PARENTS = /^(?:Users|home)$/i;
|
|
11
|
+
const SEEN_MAX = 100_000;
|
|
12
|
+
|
|
13
|
+
function escapeRegExp(value) {
|
|
14
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function lastSegment(value) {
|
|
18
|
+
const parts = value.split(/[\\/]+/).filter((part) => part && part !== '~' && !/^[A-Za-z]:$/.test(part));
|
|
19
|
+
if (parts.length === 0 || (parts.length <= 2 && HOME_PARENTS.test(parts[0]))) return '~';
|
|
20
|
+
return parts.at(-1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function rootPattern(root) {
|
|
24
|
+
return new RegExp(`${escapeRegExp(root.replace(/\/+$/, ''))}(?=$|[^\\w.-])(/${PATH_TAIL})?`, 'g');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createPathRedactor({ repos = [], workspace = null, home = os.homedir() } = {}) {
|
|
28
|
+
const replacements = [
|
|
29
|
+
...repos
|
|
30
|
+
.filter((repo) => repo?.root && repo?.name)
|
|
31
|
+
.map((repo) => ({ root: repo.root, render: (rest) => (rest ? `${repo.name}${rest}` : repo.name) })),
|
|
32
|
+
...(workspace ? [{ root: workspace, render: (rest) => (rest ? rest.slice(1) : 'workspace') }] : []),
|
|
33
|
+
...(home ? [{ root: home, render: (rest) => (rest ? rest.slice(1) : '~') }] : []),
|
|
34
|
+
]
|
|
35
|
+
.sort((left, right) => right.root.length - left.root.length)
|
|
36
|
+
.map((entry) => ({ pattern: rootPattern(entry.root), render: entry.render }));
|
|
37
|
+
|
|
38
|
+
// Every pattern needs a slash or a backslash, and the graph windows repeat the same strings many times over.
|
|
39
|
+
const seen = new Map();
|
|
40
|
+
const redactString = (input) => {
|
|
41
|
+
const original = String(input ?? '');
|
|
42
|
+
if (!/[/\\]/.test(original)) return original;
|
|
43
|
+
const cached = seen.get(original);
|
|
44
|
+
if (cached !== undefined) return cached;
|
|
45
|
+
let text = original.replace(FILE_URL, '');
|
|
46
|
+
for (const { pattern, render } of replacements) {
|
|
47
|
+
text = text.replace(pattern, (_match, rest) => render(rest?.replace(/\/+$/, '') || ''));
|
|
48
|
+
}
|
|
49
|
+
text = text.replace(DELIMITED, (_match, open, value) => `${open}${lastSegment(value)}`);
|
|
50
|
+
text = text.replace(ABSOLUTE, (match) => lastSegment(match));
|
|
51
|
+
if (seen.size >= SEEN_MAX) seen.clear();
|
|
52
|
+
seen.set(original, text);
|
|
53
|
+
return text;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// `text` replaces redactString for callers that redact more than paths (the mirror's secret table) in one walk;
|
|
57
|
+
// `entry(key, value)` may return a whole replacement for a value (a secret-named key), or null to walk it.
|
|
58
|
+
const redactValue = (value, { keep = [], redactString: text = redactString, entry: replaceEntry = () => null } = {}) => {
|
|
59
|
+
const kept = new Set(keep);
|
|
60
|
+
const walk = (entry) => {
|
|
61
|
+
if (typeof entry === 'string') return text(entry);
|
|
62
|
+
if (Array.isArray(entry)) return entry.map(walk);
|
|
63
|
+
if (entry && typeof entry === 'object') {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [key, item] of Object.entries(entry)) {
|
|
66
|
+
if (PATH_ONLY_FIELDS.has(key) && !kept.has(key)) continue;
|
|
67
|
+
out[text(key)] = replaceEntry(key, item) ?? walk(item);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
return entry;
|
|
72
|
+
};
|
|
73
|
+
return walk(value);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return { redactString, redactValue };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function containsPath(value, { home = os.homedir(), workspace = null } = {}) {
|
|
80
|
+
const roots = [home, workspace].filter(Boolean);
|
|
81
|
+
const check = (text) => ABSOLUTE_PROBE.test(text) || roots.some((root) => text.includes(root));
|
|
82
|
+
const walk = (entry) => {
|
|
83
|
+
if (typeof entry === 'string') return check(entry);
|
|
84
|
+
if (Array.isArray(entry)) return entry.some(walk);
|
|
85
|
+
if (entry && typeof entry === 'object') return Object.entries(entry).some(([key, item]) => check(key) || walk(item));
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
return walk(value);
|
|
89
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { HubError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
const ROLES = new Set(['architect', 'coder', 'reviewer', 'auditor']);
|
|
4
|
+
const CHECKS = new Set(['unit', 'typecheck', 'lint', 'schema', 'api', 'browser']);
|
|
5
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
|
|
6
|
+
|
|
7
|
+
function planError(message) {
|
|
8
|
+
return new HubError('invalid_delivery_plan', message);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function requireText(value, name, maximum) {
|
|
12
|
+
if (typeof value !== 'string' || !value.trim() || value.length > maximum) {
|
|
13
|
+
throw planError(`${name} must be a bounded non-empty string`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function assertOnlyKeys(value, allowed, name) {
|
|
18
|
+
const extra = Object.keys(value || {}).find((key) => !allowed.includes(key));
|
|
19
|
+
if (extra) throw planError(`${name} contains unsupported field "${extra}"`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertAcyclic(streams) {
|
|
23
|
+
const dependencies = new Map(streams.map((stream) => [stream.name, stream.dependencies]));
|
|
24
|
+
const visiting = new Set();
|
|
25
|
+
const visited = new Set();
|
|
26
|
+
const visit = (name) => {
|
|
27
|
+
if (visiting.has(name)) throw planError(`dependency cycle includes "${name}"`);
|
|
28
|
+
if (visited.has(name)) return;
|
|
29
|
+
visiting.add(name);
|
|
30
|
+
for (const dependency of dependencies.get(name) || []) visit(dependency);
|
|
31
|
+
visiting.delete(name);
|
|
32
|
+
visited.add(name);
|
|
33
|
+
};
|
|
34
|
+
for (const name of dependencies.keys()) visit(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function canonicalJson(value) {
|
|
38
|
+
const normalize = (entry, seen) => {
|
|
39
|
+
if (entry === null || typeof entry === 'string' || typeof entry === 'boolean') return entry;
|
|
40
|
+
if (typeof entry === 'number') {
|
|
41
|
+
if (!Number.isFinite(entry)) throw new TypeError('canonical JSON rejects non-finite numbers');
|
|
42
|
+
return entry;
|
|
43
|
+
}
|
|
44
|
+
if (typeof entry !== 'object') throw new TypeError(`canonical JSON rejects ${typeof entry}`);
|
|
45
|
+
if (seen.has(entry)) throw new TypeError('canonical JSON rejects cyclic values');
|
|
46
|
+
seen.add(entry);
|
|
47
|
+
const output = Array.isArray(entry)
|
|
48
|
+
? entry.map((item) => normalize(item, seen))
|
|
49
|
+
: Object.fromEntries(
|
|
50
|
+
Object.keys(entry)
|
|
51
|
+
.sort()
|
|
52
|
+
.map((key) => [key, normalize(entry[key], seen)])
|
|
53
|
+
);
|
|
54
|
+
seen.delete(entry);
|
|
55
|
+
return output;
|
|
56
|
+
};
|
|
57
|
+
return JSON.stringify(normalize(value, new WeakSet()));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validateDeliveryPlan(plan, grant) {
|
|
61
|
+
if (
|
|
62
|
+
!plan ||
|
|
63
|
+
typeof plan !== 'object' ||
|
|
64
|
+
Array.isArray(plan) ||
|
|
65
|
+
Buffer.byteLength(JSON.stringify(plan), 'utf8') > 32 * 1024
|
|
66
|
+
) {
|
|
67
|
+
throw planError('plan must be a JSON object no larger than 32 KiB');
|
|
68
|
+
}
|
|
69
|
+
assertOnlyKeys(plan, ['streams', 'risks', 'estimate'], 'plan');
|
|
70
|
+
if (!Array.isArray(plan.streams) || plan.streams.length < 1 || plan.streams.length > 50) {
|
|
71
|
+
throw planError('streams must contain 1 to 50 entries');
|
|
72
|
+
}
|
|
73
|
+
const roots = new Map((grant.roots || []).map((root) => [root.rootId, root]));
|
|
74
|
+
const capabilities = new Set(grant.capabilities || []);
|
|
75
|
+
if (!capabilities.has('workspace.read') || !capabilities.has('worktree.create')) {
|
|
76
|
+
throw planError('grant lacks planning capabilities');
|
|
77
|
+
}
|
|
78
|
+
const names = new Set();
|
|
79
|
+
let agentCount = 0;
|
|
80
|
+
const streams = plan.streams.map((stream) => {
|
|
81
|
+
assertOnlyKeys(stream, ['name', 'rootId', 'repoDisplay', 'dependencies', 'agents', 'checks'], 'stream');
|
|
82
|
+
if (!NAME.test(String(stream.name || '')) || names.has(stream.name)) {
|
|
83
|
+
throw planError('stream name is invalid or duplicated');
|
|
84
|
+
}
|
|
85
|
+
names.add(stream.name);
|
|
86
|
+
const root = roots.get(stream.rootId);
|
|
87
|
+
if (!root?.repoWrite) throw planError(`root "${stream.rootId}" is not approved for worktrees`);
|
|
88
|
+
requireText(stream.repoDisplay, 'repoDisplay', 240);
|
|
89
|
+
if (!Array.isArray(stream.dependencies) || stream.dependencies.length > 50) {
|
|
90
|
+
throw planError('stream dependencies are invalid');
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(stream.agents) || stream.agents.length < 1 || stream.agents.length > 8) {
|
|
93
|
+
throw planError('stream agents are invalid');
|
|
94
|
+
}
|
|
95
|
+
agentCount += stream.agents.length;
|
|
96
|
+
const agents = stream.agents.map((agent) => {
|
|
97
|
+
assertOnlyKeys(agent, ['role', 'provider', 'model', 'displayName'], 'agent');
|
|
98
|
+
if (!ROLES.has(agent.role)) throw planError('agent role is invalid');
|
|
99
|
+
requireText(agent.provider, 'provider', 40);
|
|
100
|
+
requireText(agent.model, 'model', 100);
|
|
101
|
+
if (!grant.providerPolicy?.providers?.[agent.provider]?.includes(agent.model)) {
|
|
102
|
+
throw planError('provider/model is not allowed');
|
|
103
|
+
}
|
|
104
|
+
if (!capabilities.has(`agent.${agent.provider}.invoke`)) {
|
|
105
|
+
throw planError('provider capability is not granted');
|
|
106
|
+
}
|
|
107
|
+
if (agent.role === 'coder' && !capabilities.has('workspace.write')) {
|
|
108
|
+
throw planError('coder lacks workspace.write');
|
|
109
|
+
}
|
|
110
|
+
if (agent.displayName !== undefined) requireText(agent.displayName, 'displayName', 120);
|
|
111
|
+
return { ...agent };
|
|
112
|
+
});
|
|
113
|
+
if (!Array.isArray(stream.checks) || stream.checks.some((check) => !CHECKS.has(check))) {
|
|
114
|
+
throw planError('stream contains an unregistered check');
|
|
115
|
+
}
|
|
116
|
+
return { ...stream, dependencies: [...stream.dependencies], agents, checks: [...stream.checks] };
|
|
117
|
+
});
|
|
118
|
+
for (const stream of streams) {
|
|
119
|
+
for (const dependency of stream.dependencies) {
|
|
120
|
+
if (!names.has(dependency) || dependency === stream.name) {
|
|
121
|
+
throw planError('stream dependency is invalid');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
assertAcyclic(streams);
|
|
126
|
+
if (agentCount > Math.max(1, grant.providerPolicy.maxConcurrency) * streams.length) {
|
|
127
|
+
throw planError('plan agent count exceeds provider concurrency');
|
|
128
|
+
}
|
|
129
|
+
if (plan.risks !== undefined) {
|
|
130
|
+
if (!Array.isArray(plan.risks) || plan.risks.length > 50) throw planError('risks are invalid');
|
|
131
|
+
plan.risks.forEach((risk) => requireText(risk, 'risk', 1_000));
|
|
132
|
+
}
|
|
133
|
+
if (!plan.estimate || typeof plan.estimate !== 'object' || Array.isArray(plan.estimate)) {
|
|
134
|
+
throw planError('estimate is required');
|
|
135
|
+
}
|
|
136
|
+
assertOnlyKeys(plan.estimate, ['tokens', 'costUsd', 'wallClockMinutes'], 'estimate');
|
|
137
|
+
for (const [key, value] of Object.entries(plan.estimate)) {
|
|
138
|
+
if (!Number.isFinite(value) || value < 0) throw planError(`estimate.${key} is invalid`);
|
|
139
|
+
}
|
|
140
|
+
return { streams, risks: [...(plan.risks || [])], estimate: { ...plan.estimate } };
|
|
141
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** How long a background request waits for priority requests before it goes out alongside them. */
|
|
2
|
+
export const BACKGROUND_MAX_WAIT_MS = 1_000;
|
|
3
|
+
const RECENT_CALLS = 50;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Requests that keep the machine and its jobs alive go first: a background request (a mirror batch) waits while a
|
|
7
|
+
* priority request (heartbeat, session, renew, ack, events) is in flight, but for maxWaitMs at most; then it runs
|
|
8
|
+
* alongside, so priority orders requests and never holds the mirror behind a slow or retrying call. A priority
|
|
9
|
+
* request never waits. Every priority call is kept (path, attempts, statuses, backoff, time) for the mirror's pass
|
|
10
|
+
* record.
|
|
11
|
+
*/
|
|
12
|
+
export class PriorityLane {
|
|
13
|
+
constructor({ maxWaitMs = BACKGROUND_MAX_WAIT_MS, now = Date.now } = {}) {
|
|
14
|
+
this.maxWaitMs = maxWaitMs;
|
|
15
|
+
this.now = now;
|
|
16
|
+
this.inFlight = new Set();
|
|
17
|
+
this.recent = [];
|
|
18
|
+
this.idle = Promise.resolve();
|
|
19
|
+
this.release = () => {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** action(trace) runs at once; trace gathers its attempts, statuses and backoff. */
|
|
23
|
+
async priority(action, { path = 'unknown' } = {}) {
|
|
24
|
+
if (this.inFlight.size === 0) this.idle = new Promise((resolve) => { this.release = resolve; });
|
|
25
|
+
const call = { path, at: this.now(), trace: {}, end: null, code: null };
|
|
26
|
+
this.inFlight.add(call);
|
|
27
|
+
try {
|
|
28
|
+
return await action(call.trace);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
call.code = error.code || 'HUB_REQUEST_FAILED';
|
|
31
|
+
throw error;
|
|
32
|
+
} finally {
|
|
33
|
+
call.end = this.now();
|
|
34
|
+
this.inFlight.delete(call);
|
|
35
|
+
this.recent = [...this.recent, call].slice(-RECENT_CALLS);
|
|
36
|
+
if (this.inFlight.size === 0) this.release();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* trace gets laneWaitMs, laneCapped (the wait hit maxWaitMs and the request went out alongside a priority call) and
|
|
42
|
+
* heldBy, the priority calls it waited behind.
|
|
43
|
+
*/
|
|
44
|
+
async background(action, { trace = {} } = {}) {
|
|
45
|
+
const queuedAt = this.now();
|
|
46
|
+
let capped = false;
|
|
47
|
+
if (this.inFlight.size > 0) {
|
|
48
|
+
let timer;
|
|
49
|
+
let expired = false;
|
|
50
|
+
const deadline = new Promise((resolve) => { timer = setTimeout(() => { expired = true; resolve(); }, this.maxWaitMs); });
|
|
51
|
+
try {
|
|
52
|
+
while (this.inFlight.size > 0 && !expired) await Promise.race([this.idle, deadline]);
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
capped = this.inFlight.size > 0;
|
|
57
|
+
}
|
|
58
|
+
const startedAt = this.now();
|
|
59
|
+
trace.laneWaitMs = startedAt - queuedAt;
|
|
60
|
+
trace.laneCapped = capped;
|
|
61
|
+
trace.heldBy = trace.laneWaitMs > 0 || capped ? this.calls({ since: queuedAt, until: startedAt }) : [];
|
|
62
|
+
return action();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The priority calls in flight at any time between since and until (now by default), oldest first. */
|
|
66
|
+
calls({ since = -Infinity, until = Infinity } = {}) {
|
|
67
|
+
return [...this.recent, ...this.inFlight]
|
|
68
|
+
.filter((call) => (call.end ?? Infinity) >= since && call.at <= until)
|
|
69
|
+
.map((call) => ({
|
|
70
|
+
path: call.path,
|
|
71
|
+
at: call.at,
|
|
72
|
+
ms: (call.end ?? this.now()) - call.at,
|
|
73
|
+
attempts: call.trace.attempts ?? 0,
|
|
74
|
+
statuses: [...(call.trace.statuses ?? [])],
|
|
75
|
+
backoffMs: call.trace.backoffMs ?? 0,
|
|
76
|
+
done: call.end !== null,
|
|
77
|
+
code: call.code,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
}
|