@crouter/api 0.3.387 → 0.3.389
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/dist/api/__tests__/integration/client.test.js +97 -0
- package/dist/api/client.d.ts +7 -0
- package/dist/api/client.js +40 -21
- package/dist/api/dto/config.d.ts +11 -1
- package/dist/core/asset-root.d.ts +7 -0
- package/dist/core/asset-root.js +18 -0
- package/dist/core/canvas/boot-id.d.ts +6 -0
- package/dist/core/canvas/boot-id.js +26 -0
- package/dist/core/canvas/paths.d.ts +72 -0
- package/dist/core/canvas/paths.js +163 -0
- package/dist/core/canvas/pid.d.ts +391 -0
- package/dist/core/canvas/pid.js +948 -0
- package/dist/core/command-plugins/bundle.d.ts +149 -0
- package/dist/core/command-plugins/bundle.js +588 -0
- package/dist/core/command-plugins/endpoint.d.ts +24 -0
- package/dist/core/command-plugins/endpoint.js +51 -0
- package/dist/core/config.d.ts +233 -0
- package/dist/core/config.js +1120 -0
- package/dist/core/env-name.d.ts +6 -0
- package/dist/core/env-name.js +9 -0
- package/dist/core/errors.d.ts +38 -0
- package/dist/core/errors.js +90 -0
- package/dist/core/events/emit.d.ts +6 -0
- package/dist/core/events/emit.js +42 -0
- package/dist/core/events/envelope.d.ts +2 -0
- package/dist/core/events/envelope.js +84 -0
- package/dist/core/events/errors.d.ts +4 -0
- package/dist/core/events/errors.js +69 -0
- package/dist/core/events/operation-id.d.ts +4 -0
- package/dist/core/events/operation-id.js +24 -0
- package/dist/core/events/serialize.d.ts +4 -0
- package/dist/core/events/serialize.js +199 -0
- package/dist/core/events/source.d.ts +16 -0
- package/dist/core/events/source.js +31 -0
- package/dist/core/events/types.d.ts +68 -0
- package/dist/core/events/types.js +11 -0
- package/dist/core/exclusive-lock.d.ts +34 -0
- package/dist/core/exclusive-lock.js +197 -0
- package/dist/core/fs-utils.d.ts +44 -0
- package/dist/core/fs-utils.js +208 -0
- package/dist/core/help.d.ts +309 -0
- package/dist/core/help.js +406 -0
- package/dist/core/human/page-catalog.d.ts +57 -0
- package/dist/core/human/page-catalog.js +172 -0
- package/dist/core/installed-plugins.d.ts +2 -0
- package/dist/core/installed-plugins.js +79 -0
- package/dist/core/io.d.ts +122 -0
- package/dist/core/io.js +373 -0
- package/dist/core/keybindings/attach-control.d.ts +49 -0
- package/dist/core/keybindings/attach-control.js +42 -0
- package/dist/core/keybindings/catalog.d.ts +18 -0
- package/dist/core/keybindings/catalog.js +257 -0
- package/dist/core/keybindings/types.d.ts +42 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/layout.d.ts +26 -0
- package/dist/core/layout.js +94 -0
- package/dist/core/locked-file.d.ts +27 -0
- package/dist/core/locked-file.js +118 -0
- package/dist/core/log.d.ts +9 -0
- package/dist/core/log.js +89 -0
- package/dist/core/manifest.d.ts +5 -0
- package/dist/core/manifest.js +15 -0
- package/dist/core/plugin-env.d.ts +8 -0
- package/dist/core/plugin-env.js +31 -0
- package/dist/core/plugin-extensions.d.ts +29 -0
- package/dist/core/plugin-extensions.js +191 -0
- package/dist/core/plugin-swap-lock.d.ts +9 -0
- package/dist/core/plugin-swap-lock.js +31 -0
- package/dist/core/preview-result-path.d.ts +4 -0
- package/dist/core/preview-result-path.js +26 -0
- package/dist/core/profiles/env-store.d.ts +22 -0
- package/dist/core/profiles/env-store.js +163 -0
- package/dist/core/profiles/fuzzy-match.d.ts +19 -0
- package/dist/core/profiles/fuzzy-match.js +92 -0
- package/dist/core/profiles/manifest.d.ts +120 -0
- package/dist/core/profiles/manifest.js +529 -0
- package/dist/core/rate-limit-scope.d.ts +25 -0
- package/dist/core/rate-limit-scope.js +64 -0
- package/dist/core/render.d.ts +12 -0
- package/dist/core/render.js +138 -0
- package/dist/core/resolver.d.ts +14 -0
- package/dist/core/resolver.js +111 -0
- package/dist/core/runtime/branded-host.d.ts +25 -0
- package/dist/core/runtime/branded-host.js +264 -0
- package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
- package/dist/core/runtime/broker/daemon-ops.js +177 -0
- package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
- package/dist/core/runtime/broker/signal-stream.js +149 -0
- package/dist/core/scope.d.ts +32 -0
- package/dist/core/scope.js +184 -0
- package/dist/core/scoped-state/db.d.ts +17 -0
- package/dist/core/scoped-state/db.js +247 -0
- package/dist/core/scoped-state/migrate.d.ts +8 -0
- package/dist/core/scoped-state/migrate.js +187 -0
- package/dist/core/scoped-state/paths.d.ts +9 -0
- package/dist/core/scoped-state/paths.js +27 -0
- package/dist/core/scoped-state/profiles.d.ts +27 -0
- package/dist/core/scoped-state/profiles.js +93 -0
- package/dist/core/scoped-state/providers.d.ts +24 -0
- package/dist/core/scoped-state/providers.js +19 -0
- package/dist/core/scoped-state/schema.d.ts +6 -0
- package/dist/core/scoped-state/schema.js +43 -0
- package/dist/core/scoped-state/settings.d.ts +28 -0
- package/dist/core/scoped-state/settings.js +83 -0
- package/dist/core/spaces/open-beneath.d.ts +71 -0
- package/dist/core/spaces/open-beneath.js +581 -0
- package/dist/core/sqlite-statements.d.ts +4 -0
- package/dist/core/sqlite-statements.js +17 -0
- package/dist/core/subscription-state.d.ts +121 -0
- package/dist/core/subscription-state.js +287 -0
- package/dist/core/user-settings.d.ts +377 -0
- package/dist/core/user-settings.js +458 -0
- package/dist/daemon/broker-signals/bus.d.ts +30 -0
- package/dist/daemon/broker-signals/bus.js +87 -0
- package/dist/daemon/manage.d.ts +176 -0
- package/dist/daemon/manage.js +664 -0
- package/dist/daemon/pidfile.d.ts +8 -0
- package/dist/daemon/pidfile.js +37 -0
- package/dist/daemon/startup-policy.d.ts +1 -0
- package/dist/daemon/startup-policy.js +1 -0
- package/dist/native/linux.d.ts +29 -0
- package/dist/native/linux.js +20 -0
- package/dist/shared/env.d.ts +116 -0
- package/dist/shared/env.js +271 -0
- package/dist/shared/inbox-entry-body.d.ts +22 -0
- package/dist/shared/inbox-entry-body.js +116 -0
- package/dist/shared/working-activity.d.ts +9 -0
- package/dist/shared/working-activity.js +27 -0
- package/dist/types.d.ts +562 -0
- package/dist/types.js +186 -0
- package/package.json +1 -1
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
// Profile definitions live in scoped state; memory and environment have separate roots.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
4
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
5
|
+
import { SCHEMA_VERSION } from '../../types.js';
|
|
6
|
+
import { profileSpace } from '../layout.js';
|
|
7
|
+
import { PROFILE_PROJECT_MEMORY_VALUES } from '../../api/dto/profiles.js';
|
|
8
|
+
import { userScopeRoot } from '../scope.js';
|
|
9
|
+
import { ensureDir, nowIso } from '../fs-utils.js';
|
|
10
|
+
import { deleteStoredProfile, getStoredProfile, insertStoredProfile, listStoredProfiles, pausedProfileIds, profilePaused, updateStoredProfile } from '../scoped-state/profiles.js';
|
|
11
|
+
import { usage, notFound, ambiguous, general } from '../errors.js';
|
|
12
|
+
import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
|
|
13
|
+
import { fuzzyMatchProfile } from './fuzzy-match.js';
|
|
14
|
+
// A generated id and its stored app determine the memory and environment paths.
|
|
15
|
+
/** `<slug>-<shortid>`: slug is lower-case, non-alphanumeric collapsed to `-`,
|
|
16
|
+
* trimmed to 40 chars (defaulting to `profile`); shortid is 8 lowercase hex
|
|
17
|
+
* chars. The char class alone rules out `/`, `\`, `.`, and `..` segments —
|
|
18
|
+
* this is the "generated id shape" check every exact-id lookup requires. */
|
|
19
|
+
const ID_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}$/;
|
|
20
|
+
export const ROOT_PROFILE_ID = 'root-00000000';
|
|
21
|
+
const ROOT_PROFILE_NAME = 'root';
|
|
22
|
+
function profileApp(manifest) { return manifest.grantee ?? 'app:terminal'; }
|
|
23
|
+
function scaffoldProfile(profileId, manifest) {
|
|
24
|
+
ensureDir(profileSpace(profileApp(manifest), profileId).memory);
|
|
25
|
+
}
|
|
26
|
+
function assertSafeProfileId(profileId) {
|
|
27
|
+
if (!ID_SHAPE.test(profileId)) {
|
|
28
|
+
throw usage(`invalid profile id: ${profileId}`, {
|
|
29
|
+
received: profileId,
|
|
30
|
+
next: 'Profile ids look like <slug>-<8-hex-chars> (as printed by `crtr profile new`). Pass an exact id or a profile name, not a raw path.',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function profileMemoryDir(profileId) {
|
|
35
|
+
assertSafeProfileId(profileId);
|
|
36
|
+
const stored = getStoredProfile(userScopeRoot(), profileId);
|
|
37
|
+
if (stored === null)
|
|
38
|
+
throw notFound(`profile not found: ${profileId}`, { received: profileId });
|
|
39
|
+
return profileSpace(profileApp(stored.manifest), profileId).memory;
|
|
40
|
+
}
|
|
41
|
+
// Profile operands resolve against the scoped-state rows.
|
|
42
|
+
export function assertProfileProjects(entries) {
|
|
43
|
+
if (!Array.isArray(entries))
|
|
44
|
+
throw usage('projects must be an array of {path} objects', { received: typeof entries, field: 'projects', next: 'Pass entries like {"path": "/abs/dir"}.' });
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
|
|
47
|
+
throw usage(`invalid project entry: ${JSON.stringify(entry)}`, { field: 'projects', next: 'Every project is an object like {"path": "/abs/dir"}.' });
|
|
48
|
+
const { path } = entry;
|
|
49
|
+
if (typeof path !== 'string' || path.trim() === '')
|
|
50
|
+
throw usage(`project entry has no path: ${JSON.stringify(entry)}`, { field: 'projects', next: 'Give every project entry a non-empty directory path.' });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function assertDeliveryLimits(value) {
|
|
54
|
+
const levels = PROFILE_PROJECT_MEMORY_VALUES.join(', ');
|
|
55
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
56
|
+
throw usage('delivery_limits must be an object mapping owner handles to a level', { received: typeof value, field: 'delivery_limits', next: `Pass e.g. {"crouter": "preview"} — each level is one of: ${levels}.` });
|
|
57
|
+
for (const [handle, level] of Object.entries(value)) {
|
|
58
|
+
if (handle.trim() === '')
|
|
59
|
+
throw usage('delivery_limits has an empty owner handle', { field: 'delivery_limits', next: 'Key each limit by an owner handle such as a repo handle or `user`, or exactly by `<type>:<handle>` such as `repo:crouter`.' });
|
|
60
|
+
if (typeof level !== 'string' || !PROFILE_PROJECT_MEMORY_VALUES.includes(level))
|
|
61
|
+
throw usage(`invalid delivery limit for ${handle}: ${JSON.stringify(level)}`, { received: String(level), field: 'delivery_limits', next: `Set ${handle} to one of: ${levels}.` });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function enumerateProfiles() { return { entries: listStoredProfiles(userScopeRoot()), unreadableIds: [] }; }
|
|
65
|
+
export function listProfiles() { return enumerateProfiles().entries; }
|
|
66
|
+
export function lookupProfile(profileIdOrName) { const entries = listProfiles(); const exact = findExactProfile(entries, profileIdOrName, entryCandidate); return exact ? { kind: 'resolved', entry: exact } : { kind: 'absent' }; }
|
|
67
|
+
// Three resolvers, in descending tolerance. `resolveProfileOperand` is what a
|
|
68
|
+
// command leaf calls for a value the USER TYPED: exact id, exact name, then a
|
|
69
|
+
// fuzzy fallback. `loadProfileManifest` is the strict middle — exact id or
|
|
70
|
+
// exact name, no guessing — and is what every runtime consumer holding a
|
|
71
|
+
// stored `profile_id` or `CRTR_PROFILE_ID` calls. `loadExactProfileManifest`
|
|
72
|
+
// skips name matching entirely, for persistence gates already holding a
|
|
73
|
+
// resolved durable identity. Exact-id lookup validates the generated id.
|
|
74
|
+
/** Read one exact profile id without falling back to manifest-name matching.
|
|
75
|
+
* Used at persistence gates that already hold a resolved durable identity. */
|
|
76
|
+
export function loadExactProfileManifest(profileId) {
|
|
77
|
+
assertSafeProfileId(profileId);
|
|
78
|
+
const stored = getStoredProfile(userScopeRoot(), profileId);
|
|
79
|
+
if (stored !== null)
|
|
80
|
+
return stored;
|
|
81
|
+
throw notFound(`profile not found: ${profileId}`, { received: profileId, next: 'Run `crtr profile list` to see available profiles.' });
|
|
82
|
+
}
|
|
83
|
+
const entryCandidate = (p) => ({ profileId: p.profileId, name: p.manifest.name });
|
|
84
|
+
function findExactProfile(all, profileIdOrName, key) {
|
|
85
|
+
if (ID_SHAPE.test(profileIdOrName)) {
|
|
86
|
+
const byId = all.find((p) => key(p).profileId === profileIdOrName);
|
|
87
|
+
if (byId !== undefined)
|
|
88
|
+
return byId;
|
|
89
|
+
}
|
|
90
|
+
const byName = all.filter((p) => key(p).name === profileIdOrName);
|
|
91
|
+
if (byName.length === 1)
|
|
92
|
+
return byName[0];
|
|
93
|
+
if (byName.length > 1) {
|
|
94
|
+
const ids = byName.map((p) => key(p).profileId);
|
|
95
|
+
throw ambiguous(`profile name "${profileIdOrName}" matches ${ids.length} profiles: ${ids.join(', ')}`, {
|
|
96
|
+
received: profileIdOrName,
|
|
97
|
+
candidates: ids,
|
|
98
|
+
next: `Re-run with one exact profile id: ${ids.join(', ')}.`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/** Name the actual value set in the failure: a caller that guessed an id or a
|
|
104
|
+
* stale name can correct itself from this error alone, without a lookup hop. */
|
|
105
|
+
function profileNotFound(all, profileIdOrName, key) {
|
|
106
|
+
const available = all.map((p) => `${key(p).profileId} (${key(p).name})`);
|
|
107
|
+
return notFound(`profile not found: ${profileIdOrName}`, {
|
|
108
|
+
received: profileIdOrName,
|
|
109
|
+
available,
|
|
110
|
+
next: available.length > 0
|
|
111
|
+
? `Re-run with one of the profiles on this host: ${available.join(', ')} — or create one with \`crtr profile new --name <name>\`.`
|
|
112
|
+
: 'No profiles exist on this host yet — create one with `crtr profile new --name <name>`.',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
export function loadProfileManifest(profileIdOrName) {
|
|
116
|
+
const lookup = lookupProfile(profileIdOrName);
|
|
117
|
+
if (lookup.kind === 'resolved')
|
|
118
|
+
return lookup.entry;
|
|
119
|
+
// Unreadable and absent both answer not-found here: this is the tolerant
|
|
120
|
+
// resolver every runtime consumer already handles that way. A caller that
|
|
121
|
+
// must NOT substitute another identity calls `lookupProfile` directly.
|
|
122
|
+
throw profileNotFound(listProfiles(), profileIdOrName, entryCandidate);
|
|
123
|
+
}
|
|
124
|
+
/** Resolve a profile the USER TYPED — a `--profile` flag or a `<profile>`
|
|
125
|
+
* operand. Exact id or name wins outright; only when nothing matches exactly
|
|
126
|
+
* does this fall back to fuzzy matching on name and id slug, so a typed
|
|
127
|
+
* `crout`, `Crouter`, or `cruoter` reaches the `crouter` profile instead of
|
|
128
|
+
* failing with a list the user has to read and retype. A fuzzy hit is
|
|
129
|
+
* announced on STDERR (never stdout, which callers pipe) so the user always
|
|
130
|
+
* knows which profile actually ran.
|
|
131
|
+
*
|
|
132
|
+
* NEVER call this with a STORED `profile_id` (a node's `meta.profile_id`,
|
|
133
|
+
* `CRTR_PROFILE_ID`, a pinned default). A durable id that no longer resolves
|
|
134
|
+
* means the profile was deleted — the correct answer there is the not-found
|
|
135
|
+
* those callers already handle, not the nearest surviving profile. Use
|
|
136
|
+
* `loadProfileManifest` (or `loadExactProfileManifest`) for those. */
|
|
137
|
+
export function resolveProfileOperand(operand) {
|
|
138
|
+
return matchProfileOperand(listProfiles(), operand, entryCandidate);
|
|
139
|
+
}
|
|
140
|
+
/** `resolveProfileOperand` over any candidate list — the CLI passes the
|
|
141
|
+
* profiles the daemon lists for its caller. */
|
|
142
|
+
export function matchProfileOperand(all, operand, key) {
|
|
143
|
+
const exact = findExactProfile(all, operand, key);
|
|
144
|
+
if (exact !== null)
|
|
145
|
+
return exact;
|
|
146
|
+
const { match, tied } = fuzzyMatchProfile(operand, all.map(key));
|
|
147
|
+
if (match !== null) {
|
|
148
|
+
const picked = all.find((p) => key(p).profileId === match.profileId);
|
|
149
|
+
console.error(`crtr: no profile named "${operand}" — using the closest match: ${key(picked).name} (${key(picked).profileId})`);
|
|
150
|
+
return picked;
|
|
151
|
+
}
|
|
152
|
+
if (tied.length > 1) {
|
|
153
|
+
const ids = tied.map((c) => `${c.profileId} (${c.name})`);
|
|
154
|
+
throw ambiguous(`"${operand}" is equally close to ${tied.length} profiles: ${ids.join(', ')}`, {
|
|
155
|
+
received: operand,
|
|
156
|
+
candidates: tied.map((c) => c.profileId),
|
|
157
|
+
next: `Re-run with one exact profile id or name: ${ids.join(', ')}.`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
throw profileNotFound(all, operand, key);
|
|
161
|
+
}
|
|
162
|
+
function locksDir() { return join(userScopeRoot(), 'profile-locks'); }
|
|
163
|
+
/** Hold the per-profile manifest lock for the duration of `fn`. ALL manifest
|
|
164
|
+
* mutations (create/rename/add-project/remove-project/delete/last-used)
|
|
165
|
+
* below run inside this. */
|
|
166
|
+
export function withProfileManifestLock(profileId, fn) {
|
|
167
|
+
assertSafeProfileId(profileId);
|
|
168
|
+
ensureDir(locksDir());
|
|
169
|
+
return withExclusiveDirectoryLock(join(locksDir(), `${profileId}.lock`), fn, {
|
|
170
|
+
timeoutError: () => general(`timed out waiting for the profile lock: ${profileId}`, {
|
|
171
|
+
next: 'Retry — another `crtr profile` command may be running concurrently on this profile.',
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Project dirs — resolved to absolute real paths, must exist + be a directory
|
|
176
|
+
// on add/create, deduped by real path, stored in manifest order.
|
|
177
|
+
function resolveExistingProjectDir(dir) {
|
|
178
|
+
const abs = resolvePath(dir);
|
|
179
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
180
|
+
throw usage(`project directory does not exist or is not a directory: ${dir}`, {
|
|
181
|
+
received: dir,
|
|
182
|
+
next: 'Pass an existing directory path.',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
return realpathSync(abs);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return abs;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** First-wins by resolved path, manifest order preserved. */
|
|
193
|
+
function dedupeProjects(entries) {
|
|
194
|
+
const seen = new Set();
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
if (seen.has(entry.path))
|
|
198
|
+
continue;
|
|
199
|
+
seen.add(entry.path);
|
|
200
|
+
out.push(entry);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
function slugify(name) {
|
|
205
|
+
let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
206
|
+
if (slug.length > 40)
|
|
207
|
+
slug = slug.slice(0, 40).replace(/-+$/g, '');
|
|
208
|
+
return slug.length > 0 ? slug : 'profile';
|
|
209
|
+
}
|
|
210
|
+
function generateProfileId(name) {
|
|
211
|
+
const slug = slugify(name);
|
|
212
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
213
|
+
const shortId = randomUUID().replace(/-/g, '').slice(0, 8);
|
|
214
|
+
const candidate = `${slug}-${shortId}`;
|
|
215
|
+
if (getStoredProfile(userScopeRoot(), candidate) === null)
|
|
216
|
+
return candidate;
|
|
217
|
+
}
|
|
218
|
+
throw general(`failed to generate a unique profile id for "${name}" after 20 attempts`);
|
|
219
|
+
}
|
|
220
|
+
// Mutations — all under withProfileManifestLock, all atomic writes.
|
|
221
|
+
export function ensureRootProfile() {
|
|
222
|
+
return withProfileManifestLock(ROOT_PROFILE_ID, () => {
|
|
223
|
+
const existing = getStoredProfile(userScopeRoot(), ROOT_PROFILE_ID);
|
|
224
|
+
if (existing !== null) {
|
|
225
|
+
scaffoldProfile(ROOT_PROFILE_ID, existing.manifest);
|
|
226
|
+
return existing;
|
|
227
|
+
}
|
|
228
|
+
const next = {
|
|
229
|
+
schema_version: SCHEMA_VERSION,
|
|
230
|
+
name: ROOT_PROFILE_NAME,
|
|
231
|
+
projects: [],
|
|
232
|
+
grantee: 'app:terminal',
|
|
233
|
+
paused_at: null,
|
|
234
|
+
created_at: nowIso(),
|
|
235
|
+
last_used_at: nowIso(),
|
|
236
|
+
};
|
|
237
|
+
scaffoldProfile(ROOT_PROFILE_ID, next);
|
|
238
|
+
insertStoredProfile(userScopeRoot(), { profileId: ROOT_PROFILE_ID, manifest: next });
|
|
239
|
+
return { profileId: ROOT_PROFILE_ID, manifest: next };
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/** Key shape every stored metadata entry must satisfy — kept env-mappable
|
|
243
|
+
* (`CRTR_PROFILE_META_<KEY>`, see `core/runtime/spawn-env.ts`) by
|
|
244
|
+
* construction. */
|
|
245
|
+
const METADATA_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
246
|
+
/** The env name a metadata entry surfaces as (spawn-env source F). Also the
|
|
247
|
+
* uniqueness domain for keys: the fold is lossy (`a-b`, `a_b`, `A_B` →
|
|
248
|
+
* `CRTR_PROFILE_META_A_B`), so two stored keys may not share a fold. */
|
|
249
|
+
export function metadataEnvName(key) {
|
|
250
|
+
return `CRTR_PROFILE_META_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
|
|
251
|
+
}
|
|
252
|
+
/** Reject anything a valid metadata map may not carry. Takes `unknown`
|
|
253
|
+
* because the API route hands over a caller-supplied body slice — the
|
|
254
|
+
* TypeScript type on the DTO proves nothing at runtime. */
|
|
255
|
+
export function assertProfileMetadata(entries) {
|
|
256
|
+
if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) {
|
|
257
|
+
throw usage('metadata must be a map of string keys to string values', {
|
|
258
|
+
field: 'metadata',
|
|
259
|
+
next: 'Pass an object, e.g. {"role": "support"}.',
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const claimed = new Map();
|
|
263
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
264
|
+
if (!METADATA_KEY_SHAPE.test(key)) {
|
|
265
|
+
throw usage(`invalid metadata key: ${JSON.stringify(key)}`, {
|
|
266
|
+
received: key,
|
|
267
|
+
field: 'metadata',
|
|
268
|
+
next: 'Keys start with a letter or digit, followed by letters, digits, `_`, or `-`.',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (typeof value !== 'string') {
|
|
272
|
+
throw usage(`metadata value for ${key} must be a string`, {
|
|
273
|
+
received: typeof value,
|
|
274
|
+
field: 'metadata',
|
|
275
|
+
next: 'Every metadata value is an env-var value — pass strings only.',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (value.includes('\0')) {
|
|
279
|
+
throw usage(`metadata value for ${key} contains a NUL byte, which no OS accepts in an environment variable`, {
|
|
280
|
+
field: 'metadata',
|
|
281
|
+
next: 'Remove NUL bytes from the value and retry.',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const envName = metadataEnvName(key);
|
|
285
|
+
const prior = claimed.get(envName);
|
|
286
|
+
if (prior !== undefined) {
|
|
287
|
+
throw usage(`metadata keys ${JSON.stringify(prior)} and ${JSON.stringify(key)} both surface as ${envName}`, {
|
|
288
|
+
field: 'metadata',
|
|
289
|
+
next: 'Rename or drop one — keys must stay distinct after uppercasing and `_`-folding.',
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
claimed.set(envName, key);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Drop entries no valid mutation could have written — a manifest is
|
|
296
|
+
* hand-editable, so a projection or merge base must never trust it raw.
|
|
297
|
+
* Never throws; on an env-name collision the first entry wins (matching
|
|
298
|
+
* injection, where a deterministic winner beats insertion-order luck). */
|
|
299
|
+
export function sanitizeProfileMetadata(stored) {
|
|
300
|
+
if (typeof stored !== 'object' || stored === null || Array.isArray(stored))
|
|
301
|
+
return {};
|
|
302
|
+
const out = {};
|
|
303
|
+
const claimed = new Set();
|
|
304
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
305
|
+
if (typeof value !== 'string')
|
|
306
|
+
continue;
|
|
307
|
+
if (!METADATA_KEY_SHAPE.test(key))
|
|
308
|
+
continue;
|
|
309
|
+
if (value.includes('\0'))
|
|
310
|
+
continue;
|
|
311
|
+
const envName = metadataEnvName(key);
|
|
312
|
+
if (claimed.has(envName))
|
|
313
|
+
continue;
|
|
314
|
+
claimed.add(envName);
|
|
315
|
+
out[key] = value;
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
export function createProfile(name, projects = [], opts = {}) {
|
|
320
|
+
const trimmed = name.trim();
|
|
321
|
+
if (trimmed === '')
|
|
322
|
+
throw usage('profile name must not be empty');
|
|
323
|
+
if (opts.metadata !== undefined)
|
|
324
|
+
assertProfileMetadata(opts.metadata);
|
|
325
|
+
assertProfileProjects(projects);
|
|
326
|
+
const resolvedProjects = dedupeProjects(projects.map((p) => ({ path: resolveExistingProjectDir(p.path) })));
|
|
327
|
+
const profileId = generateProfileId(trimmed);
|
|
328
|
+
return withProfileManifestLock(profileId, () => {
|
|
329
|
+
const manifest = {
|
|
330
|
+
schema_version: SCHEMA_VERSION,
|
|
331
|
+
name: trimmed,
|
|
332
|
+
projects: resolvedProjects,
|
|
333
|
+
paused_at: null,
|
|
334
|
+
...(opts.grantee === undefined ? {} : { grantee: opts.grantee }),
|
|
335
|
+
...(opts.defaultKind !== undefined ? { default_kind: opts.defaultKind } : {}),
|
|
336
|
+
...(opts.metadata !== undefined && Object.keys(opts.metadata).length > 0 ? { metadata: { ...opts.metadata } } : {}),
|
|
337
|
+
created_at: nowIso(),
|
|
338
|
+
last_used_at: null,
|
|
339
|
+
};
|
|
340
|
+
scaffoldProfile(profileId, manifest);
|
|
341
|
+
insertStoredProfile(userScopeRoot(), { profileId, manifest });
|
|
342
|
+
return { profileId, manifest };
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
export function ensureAppProfile(grantee) {
|
|
346
|
+
if (!grantee.startsWith('app:') || grantee.length <= 4)
|
|
347
|
+
throw usage('an app grantee is required');
|
|
348
|
+
const name = `app-${grantee.slice(4)}`;
|
|
349
|
+
const existing = listProfiles().filter((entry) => entry.manifest.name === name && entry.manifest.grantee === grantee);
|
|
350
|
+
if (existing.length > 1)
|
|
351
|
+
throw new Error(`duplicate default profile for ${grantee}`);
|
|
352
|
+
if (existing[0])
|
|
353
|
+
return existing[0];
|
|
354
|
+
return createProfile(name, [], { grantee });
|
|
355
|
+
}
|
|
356
|
+
function requireExistingProfile(profileId) {
|
|
357
|
+
assertSafeProfileId(profileId);
|
|
358
|
+
if (getStoredProfile(userScopeRoot(), profileId) === null)
|
|
359
|
+
throw notFound(`profile not found: ${profileId}`, { received: profileId, next: 'Run `crtr profile list` to see available profiles.' });
|
|
360
|
+
}
|
|
361
|
+
function mutateManifest(profileId, mutate) {
|
|
362
|
+
requireExistingProfile(profileId);
|
|
363
|
+
return withProfileManifestLock(profileId, () => {
|
|
364
|
+
const result = updateStoredProfile(userScopeRoot(), profileId, mutate);
|
|
365
|
+
if (result === null)
|
|
366
|
+
throw notFound(`profile not found: ${profileId}`, { received: profileId });
|
|
367
|
+
return result;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
export function updateProfileLastUsed(profileId) {
|
|
371
|
+
return mutateManifest(profileId, (m) => ({ ...m, last_used_at: nowIso() }));
|
|
372
|
+
}
|
|
373
|
+
/** Resolve an existing profile for a launch gate without making stale durable
|
|
374
|
+
* profile ids a new failure mode on a hot path. */
|
|
375
|
+
function loadProfileManifestOrNull(profileId) {
|
|
376
|
+
if (profileId === null || profileId === undefined || profileId === '')
|
|
377
|
+
return null;
|
|
378
|
+
try {
|
|
379
|
+
return loadProfileManifest(profileId);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** Paused check for an ALREADY-RESOLVED durable id (a canvas row's `profile_id`,
|
|
386
|
+
* `CRTR_PROFILE_ID`) — exact-id only, no name matching, so it costs one small
|
|
387
|
+
* read on the hot delivery paths that call it every poll. Fails OPEN: an id
|
|
388
|
+
* whose manifest is missing or corrupt is not paused, because treating it as
|
|
389
|
+
* paused would strand that node's queued inbox forever. */
|
|
390
|
+
export function isProfilePaused(profileId) {
|
|
391
|
+
if (profileId === null || profileId === undefined || profileId === '')
|
|
392
|
+
return false;
|
|
393
|
+
try {
|
|
394
|
+
assertSafeProfileId(profileId);
|
|
395
|
+
return profilePaused(userScopeRoot(), profileId);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** One set-based pause read for canvas-wide projections. */
|
|
402
|
+
export function pausedCanvasProfiles() {
|
|
403
|
+
return pausedProfileIds(userScopeRoot());
|
|
404
|
+
}
|
|
405
|
+
/** Refuse a launch under a paused profile, naming the resume command. Shared by
|
|
406
|
+
* every gate that already holds a loaded manifest. */
|
|
407
|
+
export function assertProfileEntryActive(entry) {
|
|
408
|
+
if (entry.manifest.paused_at === null)
|
|
409
|
+
return;
|
|
410
|
+
throw usage(`profile "${entry.manifest.name}" is paused and cannot start nodes`, {
|
|
411
|
+
received: entry.profileId,
|
|
412
|
+
next: `Resume it with \`crtr profile resume ${entry.profileId}\`.`,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
export function assertProfileActive(profileId) {
|
|
416
|
+
const entry = loadProfileManifestOrNull(profileId);
|
|
417
|
+
if (entry === null)
|
|
418
|
+
return null;
|
|
419
|
+
assertProfileEntryActive(entry);
|
|
420
|
+
return entry;
|
|
421
|
+
}
|
|
422
|
+
export function pauseProfile(profileId) {
|
|
423
|
+
return mutateManifest(profileId, (m) => ({ ...m, paused_at: m.paused_at ?? nowIso() }));
|
|
424
|
+
}
|
|
425
|
+
export function resumeProfile(profileId) {
|
|
426
|
+
let pausedAt = null;
|
|
427
|
+
const entry = mutateManifest(profileId, (m) => {
|
|
428
|
+
pausedAt = m.paused_at;
|
|
429
|
+
return { ...m, paused_at: null };
|
|
430
|
+
});
|
|
431
|
+
return { ...entry, pausedAt };
|
|
432
|
+
}
|
|
433
|
+
export function setProfileDefaultKind(profileId, defaultKind) {
|
|
434
|
+
return mutateManifest(profileId, (m) => ({ ...m, default_kind: defaultKind }));
|
|
435
|
+
}
|
|
436
|
+
/** Merge `set` entries over the stored map and drop `unset` keys; the
|
|
437
|
+
* `metadata` field is omitted entirely when the result is empty. */
|
|
438
|
+
export function updateProfileMetadata(profileId, set, unset = []) {
|
|
439
|
+
assertProfileMetadata(set);
|
|
440
|
+
return mutateManifest(profileId, (m) => {
|
|
441
|
+
const next = { ...sanitizeProfileMetadata(m.metadata), ...set };
|
|
442
|
+
for (const key of unset)
|
|
443
|
+
delete next[key];
|
|
444
|
+
// Re-assert the merged result: a set key may collide with a STORED key in
|
|
445
|
+
// the env-name domain (`a-b` over stored `a_b`), which per-argument
|
|
446
|
+
// validation cannot see.
|
|
447
|
+
assertProfileMetadata(next);
|
|
448
|
+
const { metadata: _dropped, ...rest } = m;
|
|
449
|
+
return { ...rest, ...(Object.keys(next).length > 0 ? { metadata: next } : {}) };
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/** A profile's stored metadata for broker-env injection — sanitized entry by
|
|
453
|
+
* entry (a manifest is hand-editable, so a bad key/value is dropped rather
|
|
454
|
+
* than trusted) and never throwing: a stale/invalid/absent profile id is a
|
|
455
|
+
* hot-path no-op, because broker-env resolution must never fail a launch
|
|
456
|
+
* over a bad profile id (mirrors `readProfileEnvVars`). */
|
|
457
|
+
export function readProfileMetadata(profileId) {
|
|
458
|
+
if (profileId === null || profileId === '')
|
|
459
|
+
return {};
|
|
460
|
+
try {
|
|
461
|
+
return sanitizeProfileMetadata(loadExactProfileManifest(profileId).manifest.metadata);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return {};
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/** Change the manifest `name` only. Callers that own the canvas (the daemon's
|
|
468
|
+
* `PATCH /v1/profiles/:name`) must also rename the profile's canvas handle —
|
|
469
|
+
* use `renameProfileAndHandle` there; the CLI and panels go through that route. */
|
|
470
|
+
export function renameProfile(profileId, name) {
|
|
471
|
+
const trimmed = name.trim();
|
|
472
|
+
if (trimmed === '')
|
|
473
|
+
throw usage('profile name must not be empty');
|
|
474
|
+
const result = mutateManifest(profileId, (m) => ({ ...m, name: trimmed }));
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
/** A new path appends; a path already listed keeps its position. */
|
|
478
|
+
export function addProfileProject(profileId, dir) {
|
|
479
|
+
assertProfileProjects([{ path: dir }]);
|
|
480
|
+
const resolved = resolveExistingProjectDir(dir);
|
|
481
|
+
const result = mutateManifest(profileId, (m) => ({ ...m, projects: dedupeProjects([...m.projects, { path: resolved }]) }));
|
|
482
|
+
return result;
|
|
483
|
+
}
|
|
484
|
+
/** Set one owner's automatic delivery limit. `content` is the uncapped level,
|
|
485
|
+
* so it removes the owner's entry instead of storing it. */
|
|
486
|
+
export function setProfileDeliveryLimit(profileId, owner, level) {
|
|
487
|
+
assertDeliveryLimits({ [owner]: level });
|
|
488
|
+
return mutateManifest(profileId, (m) => {
|
|
489
|
+
const { [owner]: _previous, ...rest } = m.delivery_limits ?? {};
|
|
490
|
+
const limits = level === 'content' ? rest : { ...rest, [owner]: level };
|
|
491
|
+
const { delivery_limits: _old, ...base } = m;
|
|
492
|
+
return Object.keys(limits).length === 0 ? base : { ...base, delivery_limits: limits };
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
export function removeProfileProject(profileId, dir) {
|
|
496
|
+
// Lenient resolution: a project dir may have been deleted since it was
|
|
497
|
+
// added, so removal must not require it to still exist on disk. Compare
|
|
498
|
+
// against both its real path (if still resolvable) and its plain resolved
|
|
499
|
+
// path (how it would have been stored had it never been a symlink).
|
|
500
|
+
const abs = resolvePath(dir);
|
|
501
|
+
let real = abs;
|
|
502
|
+
try {
|
|
503
|
+
real = realpathSync(abs);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
/* dir no longer exists — fall back to comparing by its resolved path */
|
|
507
|
+
}
|
|
508
|
+
const result = mutateManifest(profileId, (m) => {
|
|
509
|
+
const filtered = m.projects.filter((p) => p.path !== real && p.path !== abs);
|
|
510
|
+
if (filtered.length === m.projects.length) {
|
|
511
|
+
throw notFound(`project not found on profile: ${dir}`, {
|
|
512
|
+
received: dir,
|
|
513
|
+
next: `Current projects: ${m.projects.length > 0 ? m.projects.map((p) => p.path).join(', ') : '(none)'}.`,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
return { ...m, projects: filtered };
|
|
517
|
+
});
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
export function deleteProfile(profileId) {
|
|
521
|
+
requireExistingProfile(profileId);
|
|
522
|
+
withProfileManifestLock(profileId, () => {
|
|
523
|
+
const space = profileSpace(profileApp(loadExactProfileManifest(profileId).manifest), profileId);
|
|
524
|
+
if (!deleteStoredProfile(userScopeRoot(), profileId))
|
|
525
|
+
throw notFound(`profile not found: ${profileId}`, { received: profileId });
|
|
526
|
+
rmSync(space.memory, { recursive: true, force: true });
|
|
527
|
+
rmSync(space.env, { force: true });
|
|
528
|
+
});
|
|
529
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type ModelCooldownFamily = 'opus' | 'sonnet';
|
|
2
|
+
export type RateLimitScope = {
|
|
3
|
+
kind: 'account';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'model';
|
|
6
|
+
family: ModelCooldownFamily;
|
|
7
|
+
};
|
|
8
|
+
export interface RateLimitObservation {
|
|
9
|
+
scope: RateLimitScope;
|
|
10
|
+
/** `anthropic-ratelimit-unified-representative-claim`, when present. */
|
|
11
|
+
claim?: string;
|
|
12
|
+
/** Epoch ms the representative claim resets, from `-unified-reset` on a rejected response. */
|
|
13
|
+
resetAt?: number;
|
|
14
|
+
/** The rate-limit headers of the response (no auth material), lower-cased names. */
|
|
15
|
+
headers: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
type HeaderSource = Headers | Record<string, string> | undefined;
|
|
18
|
+
/** The cooldown family a model id belongs to, or undefined when only account-wide cooldowns apply to it. */
|
|
19
|
+
export declare function modelCooldownFamily(modelId: string | undefined): ModelCooldownFamily | undefined;
|
|
20
|
+
/** Only the rate-limit headers of a response: safe to log and store on a fault. */
|
|
21
|
+
export declare function rateLimitHeaders(headers: HeaderSource): Record<string, string>;
|
|
22
|
+
/** Classify a 429's headers for the model that was requested. */
|
|
23
|
+
export declare function classifyRateLimit(headers: HeaderSource, modelId: string | undefined, now?: number): RateLimitObservation;
|
|
24
|
+
export declare function describeRateLimitScope(scope: RateLimitScope): string;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// rate-limit-scope.ts — what a subscription 429 actually cooled: the whole
|
|
2
|
+
// account, or one model family on it.
|
|
3
|
+
//
|
|
4
|
+
// Anthropic subscription responses carry `anthropic-ratelimit-unified-*`
|
|
5
|
+
// headers. `-status` is `allowed` | `allowed_warning` | `rejected`;
|
|
6
|
+
// `-representative-claim` names the binding window (`five_hour`, `seven_day`,
|
|
7
|
+
// `seven_day_opus`, `seven_day_sonnet`, `seven_day_overage_included`,
|
|
8
|
+
// `overage`, …); `-reset` is the epoch second it frees up. The grammar is read
|
|
9
|
+
// from Claude Code's client, not from published docs, so the classifier fails
|
|
10
|
+
// safe: only a rejected response whose claim is a per-model weekly window that
|
|
11
|
+
// matches the requested model's family is model-scoped. Anything else —
|
|
12
|
+
// unknown claims, missing status, a claim for a different family, other
|
|
13
|
+
// providers — cools the whole account exactly as before.
|
|
14
|
+
const MODEL_CLAIMS = { seven_day_opus: 'opus', seven_day_sonnet: 'sonnet' };
|
|
15
|
+
const MAX_HEADERS = 48;
|
|
16
|
+
const MAX_VALUE = 256;
|
|
17
|
+
/** The cooldown family a model id belongs to, or undefined when only account-wide cooldowns apply to it. */
|
|
18
|
+
export function modelCooldownFamily(modelId) {
|
|
19
|
+
if (!modelId)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (/opus/i.test(modelId))
|
|
22
|
+
return 'opus';
|
|
23
|
+
if (/sonnet/i.test(modelId))
|
|
24
|
+
return 'sonnet';
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
function entries(headers) {
|
|
28
|
+
if (!headers)
|
|
29
|
+
return [];
|
|
30
|
+
if (typeof Headers !== 'undefined' && headers instanceof Headers)
|
|
31
|
+
return [...headers.entries()];
|
|
32
|
+
return Object.entries(headers).filter((pair) => typeof pair[1] === 'string');
|
|
33
|
+
}
|
|
34
|
+
function isRateLimitHeader(name) {
|
|
35
|
+
return name.startsWith('anthropic-ratelimit-') || name.startsWith('x-ratelimit-') || name === 'retry-after' || name === 'retry-after-ms';
|
|
36
|
+
}
|
|
37
|
+
/** Only the rate-limit headers of a response: safe to log and store on a fault. */
|
|
38
|
+
export function rateLimitHeaders(headers) {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [rawName, value] of entries(headers)) {
|
|
41
|
+
const name = rawName.toLowerCase();
|
|
42
|
+
if (!isRateLimitHeader(name))
|
|
43
|
+
continue;
|
|
44
|
+
if (Object.keys(out).length >= MAX_HEADERS)
|
|
45
|
+
break;
|
|
46
|
+
out[name] = value.slice(0, MAX_VALUE);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** Classify a 429's headers for the model that was requested. */
|
|
51
|
+
export function classifyRateLimit(headers, modelId, now = Date.now()) {
|
|
52
|
+
const recorded = rateLimitHeaders(headers);
|
|
53
|
+
const claim = recorded['anthropic-ratelimit-unified-representative-claim']?.trim() || undefined;
|
|
54
|
+
const rejected = recorded['anthropic-ratelimit-unified-status']?.trim() === 'rejected';
|
|
55
|
+
const resetSeconds = Number(recorded['anthropic-ratelimit-unified-reset']);
|
|
56
|
+
const resetAt = rejected && Number.isFinite(resetSeconds) && resetSeconds > 0 && resetSeconds * 1000 > now ? resetSeconds * 1000 : undefined;
|
|
57
|
+
const claimed = claim === undefined ? undefined : MODEL_CLAIMS[claim];
|
|
58
|
+
const family = modelCooldownFamily(modelId);
|
|
59
|
+
const scope = rejected && claimed !== undefined && claimed === family ? { kind: 'model', family: claimed } : { kind: 'account' };
|
|
60
|
+
return { scope, ...(claim !== undefined ? { claim } : {}), ...(resetAt !== undefined ? { resetAt } : {}), headers: recorded };
|
|
61
|
+
}
|
|
62
|
+
export function describeRateLimitScope(scope) {
|
|
63
|
+
return scope.kind === 'account' ? 'account' : `model:${scope.family}`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LeafHelp } from './help.js';
|
|
2
|
+
import type { ErrorPayload } from './io.js';
|
|
3
|
+
/** Schema-driven fallback: turn a result object into agent-ready plain markdown.
|
|
4
|
+
* Field order follows the leaf's declared output schema; any extra keys append
|
|
5
|
+
* after. Scalars (and nested objects) list as `- name: value` bullets, prose
|
|
6
|
+
* fields render as paragraphs, and arrays become a count lead-in plus a table
|
|
7
|
+
* or bullets. No root tag — the result is read as a continuation of the prompt. */
|
|
8
|
+
export declare function renderResult(result: Record<string, unknown>, help: LeafHelp): string;
|
|
9
|
+
/** Render a structured failure as an instruction-shaped block: what broke, what
|
|
10
|
+
* was received, and the recovery road sign — the same recovery info the JSON
|
|
11
|
+
* payload carries, shaped for the model to act on. */
|
|
12
|
+
export declare function renderError(p: ErrorPayload): string;
|