@pulsarinteractive/geniustasker 0.1.0-beta.2 → 0.1.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +67 -12
- package/LICENSE +21 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +55 -59
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/docs/ARCHITECTURE.md +126 -0
- package/docs/CLI.md +30 -11
- package/docs/INTEGRATIONS.md +3 -3
- package/docs/LIMITS.md +49 -0
- package/docs/MEMORY.md +38 -0
- package/docs/OPENAI.md +10 -11
- package/docs/RELEASING.md +11 -3
- package/docs/TESTING.md +52 -0
- package/examples/codex.toml +1 -1
- package/examples/mcp.json +1 -7
- package/mcp.json +1 -7
- package/package.json +20 -9
- package/plugin.json +6 -15
- package/skills/geniustasker/SKILL.md +3 -2
- package/skills/geniustasker/agents/openai.yaml +3 -3
- package/skills/geniustasker/references/collaboration.md +0 -2
- package/skills/geniustasker/references/project-reads.md +43 -0
- package/skills/geniustasker-memory/SKILL.md +67 -6
- package/skills/geniustasker-memory/agents/openai.yaml +3 -3
- package/skills/geniustasker-memory/references/knowledge-workflows.md +57 -0
- package/src/changes.mjs +104 -28
- package/src/cli.mjs +372 -77
- package/src/client.mjs +201 -34
- package/src/command-catalog.mjs +109 -5
- package/src/commands.mjs +215 -52
- package/src/errors.mjs +55 -12
- package/src/knowledge.mjs +109 -0
- package/src/login.mjs +190 -47
- package/src/mcp.mjs +256 -43
- package/src/media.mjs +242 -0
- package/src/profiles.mjs +123 -32
- package/src/read-cache.mjs +111 -0
- package/src/store.mjs +101 -26
- package/src/transport.mjs +165 -32
package/src/media.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
+
import * as z from 'zod/v4';
|
|
6
|
+
import { CredentialStore } from './store.mjs';
|
|
7
|
+
import { operationIdSchema } from './commands.mjs';
|
|
8
|
+
import { AgentError, fail } from './errors.mjs';
|
|
9
|
+
|
|
10
|
+
const mediaScope = z.string().regex(/^memory:[a-zA-Z0-9_-]{1,128}$/);
|
|
11
|
+
const types = [
|
|
12
|
+
'image/png',
|
|
13
|
+
'image/jpeg',
|
|
14
|
+
'image/webp',
|
|
15
|
+
'image/gif',
|
|
16
|
+
'image/avif',
|
|
17
|
+
'application/pdf',
|
|
18
|
+
'text/plain',
|
|
19
|
+
];
|
|
20
|
+
export const mediaPrepareSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
scope: mediaScope,
|
|
23
|
+
file: z.string().min(1).max(4096),
|
|
24
|
+
contentType: z.enum(types),
|
|
25
|
+
epoch: z.string().min(1).max(128),
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
28
|
+
export const mediaActionSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
scope: mediaScope,
|
|
31
|
+
ownerId: z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/),
|
|
32
|
+
id: operationIdSchema,
|
|
33
|
+
action: z.enum(['inspect', 'publish', 'cancel', 'remove']),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
export const mediaDownloadSchema = mediaActionSchema
|
|
37
|
+
.omit({ action: true })
|
|
38
|
+
.extend({ file: z.string().min(1).max(4096) })
|
|
39
|
+
.strict();
|
|
40
|
+
const parse = (schema, value) => {
|
|
41
|
+
const result = schema.safeParse(value);
|
|
42
|
+
if (!result.success) {
|
|
43
|
+
fail(
|
|
44
|
+
'invalid_arguments',
|
|
45
|
+
'Invalid media arguments. Supply the Memory scope, epoch and a supported private file.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return result.data;
|
|
49
|
+
};
|
|
50
|
+
async function fileBytes(path) {
|
|
51
|
+
let handle;
|
|
52
|
+
try {
|
|
53
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
54
|
+
const stat = await handle.stat();
|
|
55
|
+
if (!stat.isFile() || stat.size < 1 || stat.size > 15000000) {
|
|
56
|
+
fail('invalid_arguments', 'Use a regular file between 1 and 15,000,000 bytes.');
|
|
57
|
+
}
|
|
58
|
+
const bytes = Buffer.alloc(15000001);
|
|
59
|
+
let size = 0;
|
|
60
|
+
while (size < bytes.length) {
|
|
61
|
+
const result = await handle.read(bytes, size, bytes.length - size, size);
|
|
62
|
+
if (!result.bytesRead) {
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
size += result.bytesRead;
|
|
66
|
+
}
|
|
67
|
+
if (size > 15000000 || size !== stat.size) {
|
|
68
|
+
fail('file_changed', 'The file changed while reading. Prepare it again.');
|
|
69
|
+
}
|
|
70
|
+
return bytes.subarray(0, size);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof AgentError) {
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
fail('invalid_arguments', 'Could not read a regular local media file.');
|
|
76
|
+
} finally {
|
|
77
|
+
await handle?.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
81
|
+
const binding = (session) => ({
|
|
82
|
+
origin: session.origin,
|
|
83
|
+
connectionId: session.connection_id,
|
|
84
|
+
profileId: session.profile_id,
|
|
85
|
+
sessionId: session.session_id,
|
|
86
|
+
});
|
|
87
|
+
function sessionView(value, id, size) {
|
|
88
|
+
if (
|
|
89
|
+
!value ||
|
|
90
|
+
value.id !== id ||
|
|
91
|
+
typeof value.ownerId !== 'string' ||
|
|
92
|
+
!/^[a-zA-Z0-9_-]{1,128}$/.test(value.ownerId) ||
|
|
93
|
+
value.size !== size ||
|
|
94
|
+
typeof value.published !== 'boolean'
|
|
95
|
+
) {
|
|
96
|
+
fail('invalid_response', 'The attachment session did not match the prepared file.');
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
/** Private immutable local journal: retries reuse the same blob ID and digest.
|
|
101
|
+
* No binary data, credentials or remote response text are written to this file.
|
|
102
|
+
*/
|
|
103
|
+
export class MediaJournal {
|
|
104
|
+
constructor(client) {
|
|
105
|
+
this.client = client;
|
|
106
|
+
}
|
|
107
|
+
store(id) {
|
|
108
|
+
parse(operationIdSchema, id);
|
|
109
|
+
return new CredentialStore({
|
|
110
|
+
directory: join(this.client.store.directory, 'media', this.client.store.context),
|
|
111
|
+
context: id,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
async prepare(value) {
|
|
115
|
+
const input = parse(mediaPrepareSchema, value),
|
|
116
|
+
session = await this.client.authorizedSession();
|
|
117
|
+
const path = resolve(input.file),
|
|
118
|
+
bytes = await fileBytes(path),
|
|
119
|
+
id = randomUUID(),
|
|
120
|
+
store = this.store(id);
|
|
121
|
+
const fileName = basename(path);
|
|
122
|
+
if (fileName.length > 200) {
|
|
123
|
+
fail('invalid_arguments', 'Use a filename of at most 200 characters.');
|
|
124
|
+
}
|
|
125
|
+
await store.locked(() =>
|
|
126
|
+
store.write({
|
|
127
|
+
formatVersion: 1,
|
|
128
|
+
binding: binding(session),
|
|
129
|
+
path,
|
|
130
|
+
input: {
|
|
131
|
+
id,
|
|
132
|
+
issuedAt: Date.now(),
|
|
133
|
+
scope: input.scope,
|
|
134
|
+
expectedEpoch: input.epoch,
|
|
135
|
+
size: bytes.length,
|
|
136
|
+
sha256: digest(bytes),
|
|
137
|
+
contentType: input.contentType,
|
|
138
|
+
fileName,
|
|
139
|
+
kind: 'memory',
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
return {
|
|
144
|
+
operationId: id,
|
|
145
|
+
scope: input.scope,
|
|
146
|
+
fileName,
|
|
147
|
+
size: bytes.length,
|
|
148
|
+
sha256: digest(bytes),
|
|
149
|
+
prepared: true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async upload(id) {
|
|
153
|
+
const store = this.store(id);
|
|
154
|
+
return store.locked(async () => {
|
|
155
|
+
const value = await store.read(),
|
|
156
|
+
session = await this.client.authorizedSession();
|
|
157
|
+
if (
|
|
158
|
+
!value ||
|
|
159
|
+
value.formatVersion !== 1 ||
|
|
160
|
+
value.input?.id !== id ||
|
|
161
|
+
JSON.stringify(value.binding) !== JSON.stringify(binding(session))
|
|
162
|
+
) {
|
|
163
|
+
fail('media_context_changed', 'Prepare a new upload in this authenticated context.');
|
|
164
|
+
}
|
|
165
|
+
const bytes = await fileBytes(value.path);
|
|
166
|
+
if (bytes.length !== value.input.size || digest(bytes) !== value.input.sha256) {
|
|
167
|
+
fail('file_changed', 'The prepared file changed; prepare a new operation.');
|
|
168
|
+
}
|
|
169
|
+
const transport = this.client.transport,
|
|
170
|
+
token = session.access_token;
|
|
171
|
+
const opened = sessionView(
|
|
172
|
+
await transport.request('/api/agents/v1/media/sessions', {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
json: value.input,
|
|
175
|
+
token,
|
|
176
|
+
}),
|
|
177
|
+
id,
|
|
178
|
+
bytes.length,
|
|
179
|
+
);
|
|
180
|
+
const query = { scope: value.input.scope, ownerId: opened.ownerId, id };
|
|
181
|
+
if (!opened.published) {
|
|
182
|
+
await transport.request('/api/agents/v1/media/content', {
|
|
183
|
+
method: 'PUT',
|
|
184
|
+
query,
|
|
185
|
+
bytes,
|
|
186
|
+
token,
|
|
187
|
+
});
|
|
188
|
+
const published = sessionView(
|
|
189
|
+
await transport.request('/api/agents/v1/media/actions', {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
json: { ...query, action: 'publish' },
|
|
192
|
+
token,
|
|
193
|
+
}),
|
|
194
|
+
id,
|
|
195
|
+
bytes.length,
|
|
196
|
+
);
|
|
197
|
+
if (!published.published) {
|
|
198
|
+
fail(
|
|
199
|
+
'media_not_published',
|
|
200
|
+
'Upload is not published; inspect its state before retrying.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
operationId: id,
|
|
206
|
+
assetId: id,
|
|
207
|
+
ownerId: opened.ownerId,
|
|
208
|
+
scope: value.input.scope,
|
|
209
|
+
published: true,
|
|
210
|
+
fileName: value.input.fileName,
|
|
211
|
+
size: bytes.length,
|
|
212
|
+
sha256: value.input.sha256,
|
|
213
|
+
};
|
|
214
|
+
}, this.client.transport.signal);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export async function mediaAction(client, value) {
|
|
218
|
+
const input = parse(mediaActionSchema, value);
|
|
219
|
+
return client.transport.request('/api/agents/v1/media/actions', {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
json: input,
|
|
222
|
+
token: await client.accessToken(),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** Explicit destination only. Exclusive creation never overwrites existing files. */
|
|
226
|
+
export async function downloadMedia(client, value) {
|
|
227
|
+
const { file, ...query } = parse(mediaDownloadSchema, value);
|
|
228
|
+
const bytes = await client.transport.request('/api/agents/v1/media/content', {
|
|
229
|
+
query,
|
|
230
|
+
binary: true,
|
|
231
|
+
token: await client.accessToken(),
|
|
232
|
+
});
|
|
233
|
+
const path = resolve(file);
|
|
234
|
+
const handle = await open(path, 'wx', 0o600);
|
|
235
|
+
try {
|
|
236
|
+
await handle.writeFile(bytes);
|
|
237
|
+
await handle.sync();
|
|
238
|
+
} finally {
|
|
239
|
+
await handle.close();
|
|
240
|
+
}
|
|
241
|
+
return { file: path, size: bytes.length, sha256: digest(bytes) };
|
|
242
|
+
}
|
package/src/profiles.mjs
CHANGED
|
@@ -2,62 +2,153 @@ import * as z from 'zod/v4';
|
|
|
2
2
|
import { AgentError, fail } from './errors.mjs';
|
|
3
3
|
import catalog from './command-catalog.mjs';
|
|
4
4
|
|
|
5
|
-
const name = z
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
const name = z
|
|
6
|
+
.string()
|
|
7
|
+
.trim()
|
|
8
|
+
.min(1)
|
|
9
|
+
.max(80)
|
|
10
|
+
// eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
|
|
11
|
+
.regex(/^[^\u0000-\u001f\u007f]+$/u);
|
|
12
|
+
const unique = (values) => new Set(values).size === values.length;
|
|
13
|
+
export const grantSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
allResources: z.boolean(),
|
|
16
|
+
resourceIds: z
|
|
17
|
+
.array(z.string().min(1).max(256))
|
|
18
|
+
.max(catalog.grants.maxResources)
|
|
19
|
+
.refine(unique),
|
|
20
|
+
permissions: z
|
|
21
|
+
.array(z.enum(catalog.grants.permissions))
|
|
22
|
+
.max(catalog.grants.permissions.length)
|
|
23
|
+
.refine(unique),
|
|
24
|
+
})
|
|
25
|
+
.strict()
|
|
26
|
+
.refine((grant) => !grant.allResources || grant.resourceIds.length === 0);
|
|
12
27
|
export const profileCreateSchema = z.object({ id: z.uuid(), name, grant: grantSchema }).strict();
|
|
13
|
-
export const profileRestrictSchema = z
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
export const profileRestrictSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
profileId: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/),
|
|
31
|
+
name,
|
|
32
|
+
expectedRevision: z.number().int().min(1),
|
|
33
|
+
grant: grantSchema,
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
export const profileSelectSchema = z
|
|
37
|
+
.object({ profileId: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/) })
|
|
38
|
+
.strict();
|
|
39
|
+
const choiceSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
id: z.string(),
|
|
42
|
+
name,
|
|
43
|
+
revision: z.number().int().min(1),
|
|
44
|
+
current: z.boolean(),
|
|
45
|
+
permissions: z.array(z.string()),
|
|
46
|
+
allResources: z.boolean(),
|
|
47
|
+
resourceCount: z.number().int().min(0),
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
const choicesSchema = z
|
|
51
|
+
.object({
|
|
52
|
+
connectionId: z.string(),
|
|
53
|
+
currentProfileId: z.string(),
|
|
54
|
+
items: z.array(choiceSchema).max(catalog.grants.maxProfilesPerConnection),
|
|
55
|
+
})
|
|
56
|
+
.strict();
|
|
57
|
+
const profileSchema = z
|
|
58
|
+
.object({
|
|
59
|
+
id: z.string(),
|
|
60
|
+
connectionId: z.string(),
|
|
61
|
+
name,
|
|
62
|
+
revision: z.number().int().min(1),
|
|
63
|
+
grant: grantSchema,
|
|
64
|
+
disabledAt: z.number().nullable(),
|
|
65
|
+
})
|
|
66
|
+
.strict();
|
|
67
|
+
const input = (schema, value) => {
|
|
68
|
+
const result = schema.safeParse(value);
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
fail('invalid_arguments', 'Use the declared profile fields, a valid name and a bounded grant.');
|
|
71
|
+
}
|
|
72
|
+
return result.data;
|
|
73
|
+
};
|
|
20
74
|
|
|
21
75
|
/** Profile management is separate from domain-operation journals: creation has
|
|
22
76
|
* an explicit UUID retained in the caller's file, restriction a live revision,
|
|
23
77
|
* and selection rotates the credential family under the credential-store lock. */
|
|
24
78
|
export class AgentProfiles {
|
|
25
|
-
constructor(client) {
|
|
79
|
+
constructor(client) {
|
|
80
|
+
this.client = client;
|
|
81
|
+
}
|
|
26
82
|
async list() {
|
|
27
83
|
const result = choicesSchema.safeParse(await this.client.get('profiles'));
|
|
28
|
-
if (!result.success)
|
|
84
|
+
if (!result.success) {
|
|
85
|
+
fail('invalid_response', 'The profile list was invalid.');
|
|
86
|
+
}
|
|
29
87
|
return result.data;
|
|
30
88
|
}
|
|
31
|
-
async create(value) {
|
|
32
|
-
|
|
89
|
+
async create(value) {
|
|
90
|
+
return this.write('profiles', 'POST', input(profileCreateSchema, value));
|
|
91
|
+
}
|
|
92
|
+
async restrict(value) {
|
|
93
|
+
return this.write('profile', 'PUT', input(profileRestrictSchema, value));
|
|
94
|
+
}
|
|
33
95
|
async write(resource, method, value) {
|
|
34
96
|
const session = await this.client.authorizedSession();
|
|
35
|
-
if (method === 'PUT' && value.profileId !== session.profile_id)
|
|
36
|
-
fail('profile_selection_conflict', 'This draft belongs to another profile.', {
|
|
97
|
+
if (method === 'PUT' && value.profileId !== session.profile_id) {
|
|
98
|
+
fail('profile_selection_conflict', 'This draft belongs to another profile.', {
|
|
99
|
+
status: 409,
|
|
100
|
+
remediation: 'Read whoami and prepare a fresh restriction for the intended profile.',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
37
103
|
try {
|
|
38
|
-
const result = await this.client.transport.request(`/api/agents/v1/${resource}`, {
|
|
104
|
+
const result = await this.client.transport.request(`/api/agents/v1/${resource}`, {
|
|
105
|
+
method,
|
|
106
|
+
token: session.access_token,
|
|
107
|
+
json: value,
|
|
108
|
+
});
|
|
39
109
|
const parsed = profileSchema.safeParse(result);
|
|
40
|
-
if (
|
|
41
|
-
|
|
110
|
+
if (
|
|
111
|
+
!parsed.success ||
|
|
112
|
+
parsed.data.connectionId !== session.connection_id ||
|
|
113
|
+
parsed.data.id !== (method === 'POST' ? value.id : session.profile_id)
|
|
114
|
+
) {
|
|
42
115
|
fail('invalid_response', 'The profile change could not be confirmed.');
|
|
116
|
+
}
|
|
43
117
|
return parsed.data;
|
|
44
118
|
} catch (error) {
|
|
45
|
-
if (!(error instanceof AgentError))
|
|
119
|
+
if (!(error instanceof AgentError)) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
46
122
|
error.details.profileId = method === 'POST' ? value.id : session.profile_id;
|
|
47
123
|
error.details.execution ??= 'unknown';
|
|
48
|
-
error.remediation =
|
|
49
|
-
|
|
50
|
-
|
|
124
|
+
error.remediation =
|
|
125
|
+
method === 'POST'
|
|
126
|
+
? 'List profiles and reconcile this profileId. Keep the same UUID on retry; do not create a replacement after an uncertain reply.'
|
|
127
|
+
: 'Read whoami to see the current profile and revision before preparing another restriction.';
|
|
51
128
|
throw error;
|
|
52
129
|
}
|
|
53
130
|
}
|
|
54
131
|
async select(value) {
|
|
55
132
|
const { profileId } = input(profileSelectSchema, value);
|
|
56
133
|
const choices = await this.list();
|
|
57
|
-
const source = choices.items.find(p => p.id === choices.currentProfileId && p.current);
|
|
58
|
-
const target = choices.items.find(p => p.id === profileId);
|
|
59
|
-
if (!source || !target)
|
|
60
|
-
|
|
61
|
-
|
|
134
|
+
const source = choices.items.find((p) => p.id === choices.currentProfileId && p.current);
|
|
135
|
+
const target = choices.items.find((p) => p.id === profileId);
|
|
136
|
+
if (!source || !target) {
|
|
137
|
+
fail(
|
|
138
|
+
'profile_selection_denied',
|
|
139
|
+
'This profile is not available within the current connection and permissions.',
|
|
140
|
+
{
|
|
141
|
+
status: 403,
|
|
142
|
+
remediation: 'Run profiles list. An agent cannot switch back to broader permissions.',
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return this.client.selectProfile({
|
|
147
|
+
profileId,
|
|
148
|
+
expectedRevision: target.revision,
|
|
149
|
+
expectedSourceRevision: source.revision,
|
|
150
|
+
sourceProfileId: source.id,
|
|
151
|
+
connectionId: choices.connectionId,
|
|
152
|
+
});
|
|
62
153
|
}
|
|
63
154
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { fail } from './errors.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounded process-local LRU for conditional resource reads. Every access calls
|
|
5
|
+
* the server; TTL controls retention only, never permission freshness. Document
|
|
6
|
+
* data is neither persisted to disk nor served as an offline fallback.
|
|
7
|
+
*/
|
|
8
|
+
export class ConditionalReadCache {
|
|
9
|
+
constructor({
|
|
10
|
+
maxBytes = 8 * 1024 * 1024,
|
|
11
|
+
maxEntries = 64,
|
|
12
|
+
maxAgeMs = 900000,
|
|
13
|
+
now = Date.now,
|
|
14
|
+
} = {}) {
|
|
15
|
+
for (const value of [maxBytes, maxEntries, maxAgeMs]) {
|
|
16
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
17
|
+
throw new TypeError('Read cache limits must be positive integers.');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
this.maxBytes = maxBytes;
|
|
21
|
+
this.maxEntries = maxEntries;
|
|
22
|
+
this.maxAgeMs = maxAgeMs;
|
|
23
|
+
this.now = now;
|
|
24
|
+
this.entries = new Map();
|
|
25
|
+
this.pending = new Map();
|
|
26
|
+
this.bytes = 0;
|
|
27
|
+
this.generation = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clear() {
|
|
31
|
+
this.generation++;
|
|
32
|
+
this.entries.clear();
|
|
33
|
+
this.pending.clear();
|
|
34
|
+
this.bytes = 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
remove(key) {
|
|
38
|
+
const old = this.entries.get(key);
|
|
39
|
+
if (old) {
|
|
40
|
+
this.bytes -= old.bytes;
|
|
41
|
+
this.entries.delete(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async read(key, load) {
|
|
46
|
+
const existing = this.pending.get(key);
|
|
47
|
+
if (existing) {
|
|
48
|
+
return structuredClone(await existing);
|
|
49
|
+
}
|
|
50
|
+
const generation = this.generation;
|
|
51
|
+
for (const [entryKey, entry] of this.entries) {
|
|
52
|
+
if (this.now() - entry.at >= this.maxAgeMs) {
|
|
53
|
+
this.remove(entryKey);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const cached = this.entries.get(key);
|
|
57
|
+
const pending = (async () => {
|
|
58
|
+
const received = await load(cached?.value.etag);
|
|
59
|
+
if (generation !== this.generation) {
|
|
60
|
+
fail('read_context_changed', 'The read context changed while this request was running.', {
|
|
61
|
+
retryable: true,
|
|
62
|
+
remediation: 'Read again in the currently selected account and profile.',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (!received || typeof received !== 'object' || Array.isArray(received)) {
|
|
66
|
+
fail('invalid_response', 'The resource response was invalid.');
|
|
67
|
+
}
|
|
68
|
+
let value = received;
|
|
69
|
+
if (received?.notModified === true) {
|
|
70
|
+
if (
|
|
71
|
+
!cached ||
|
|
72
|
+
received.etag !== cached.value.etag ||
|
|
73
|
+
received.scope !== cached.value.scope ||
|
|
74
|
+
received.collection !== cached.value.collection ||
|
|
75
|
+
received.epoch !== cached.value.epoch ||
|
|
76
|
+
received.checkpoint !== cached.value.checkpoint ||
|
|
77
|
+
!Array.isArray(received.items) ||
|
|
78
|
+
received.items.length !== 0
|
|
79
|
+
) {
|
|
80
|
+
fail('invalid_response', 'A conditional response did not match the cached resource.');
|
|
81
|
+
}
|
|
82
|
+
value = cached.value;
|
|
83
|
+
}
|
|
84
|
+
this.remove(key);
|
|
85
|
+
const size = Buffer.byteLength(JSON.stringify(value));
|
|
86
|
+
if (typeof value?.etag === 'string' && value.etag.length <= 8192 && size <= this.maxBytes) {
|
|
87
|
+
this.entries.set(key, { value: structuredClone(value), bytes: size, at: this.now() });
|
|
88
|
+
this.bytes += size;
|
|
89
|
+
while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) {
|
|
90
|
+
this.remove(this.entries.keys().next().value);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
})();
|
|
95
|
+
this.pending.set(key, pending);
|
|
96
|
+
try {
|
|
97
|
+
return structuredClone(await pending);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A permission, authentication, network or schema failure can never turn
|
|
100
|
+
// into stale private success, or let another in-flight read refill it.
|
|
101
|
+
if (generation === this.generation) {
|
|
102
|
+
this.clear();
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
} finally {
|
|
106
|
+
if (this.pending.get(key) === pending) {
|
|
107
|
+
this.pending.delete(key);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|