@chrischall/gemini-mcp 1.6.1 → 1.7.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/blob-store.js +18 -2
- package/dist/bundle.js +942 -617
- package/dist/client.js +31 -2
- package/dist/errors.js +120 -0
- package/dist/index.js +6 -25
- package/dist/job-store.js +138 -0
- package/dist/jobs.js +257 -12
- package/dist/media-url.js +10 -3
- package/dist/registrars.js +50 -0
- package/dist/session.js +5 -4
- package/dist/signed-url.js +42 -0
- package/dist/storage/media.js +76 -3
- package/dist/tools/files.js +49 -0
- package/dist/tools/jobs.js +5 -1
- package/dist/tools/shared.js +13 -3
- package/dist/tools/uploads.js +42 -19
- package/dist/upload-url.js +24 -4
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createDiskSink, createR2Sink, tenantIdFor } from './storage/media.js';
|
|
|
5
5
|
import { createR2Library } from './library.js';
|
|
6
6
|
import { createUploadUrlMinter } from './upload-url.js';
|
|
7
7
|
import { blobStoreFromEnv } from './blob-store.js';
|
|
8
|
+
import { createBlobJobStore } from './job-store.js';
|
|
8
9
|
import { SessionState } from './session.js';
|
|
9
10
|
import { fetchRemoteImage } from './fetch-image.js';
|
|
10
11
|
// NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
|
|
@@ -167,6 +168,14 @@ export class GeminiClient {
|
|
|
167
168
|
// A replayed idempotent result must not hand back an expired media URL, so
|
|
168
169
|
// the job registry needs a way to re-sign from the stable r2_key.
|
|
169
170
|
this.session.jobs.resigner = this.mediaSink;
|
|
171
|
+
// Durable job records, where the deployment has somewhere to keep them.
|
|
172
|
+
// Without this the hosted connector loses a job the moment its machine
|
|
173
|
+
// stops — which it does as soon as no request is in flight (job-store.ts).
|
|
174
|
+
this.session.jobs.store = opts.jobStore;
|
|
175
|
+
// And on that runtime, `async: true` is served by HOLDING the request:
|
|
176
|
+
// releasing it is what lets the executor be reclaimed mid-generation.
|
|
177
|
+
if (opts.jobStore)
|
|
178
|
+
this.session.jobs.asyncWaitFallbackMs = HOSTED_ASYNC_WAIT_MS;
|
|
170
179
|
this.explicitApiKey = opts.apiKey?.trim() || undefined;
|
|
171
180
|
// Never store the global `fetch` raw on the instance: it would be invoked
|
|
172
181
|
// as `this.fetchImpl(...)`, handing native fetch this GeminiClient as its
|
|
@@ -776,7 +785,11 @@ function hostedStorage() {
|
|
|
776
785
|
const tenant = () => tenantIdFor(readEnvVar('GEMINI_API_KEY') ?? 'local');
|
|
777
786
|
return {
|
|
778
787
|
mediaSink: createR2Sink(blob.bucket, {
|
|
779
|
-
|
|
788
|
+
// The SAME `links` object the minter gets below. Media GETs and upload
|
|
789
|
+
// PUTs are one store reached two ways; handing each its own base URL is
|
|
790
|
+
// how the re-host moved the media links to /b/<registrationId>/ and left
|
|
791
|
+
// the upload links behind. One object, two shapes, no second edit.
|
|
792
|
+
links: blob.links,
|
|
780
793
|
sign: blob.signRead,
|
|
781
794
|
urlTtlMs: BLOB_URL_TTL_MS,
|
|
782
795
|
// Hard ceiling on any PER-PERSIST override. `gemini_image_set` asks for
|
|
@@ -792,9 +805,25 @@ function hostedStorage() {
|
|
|
792
805
|
tenant,
|
|
793
806
|
}),
|
|
794
807
|
library: createR2Library(blob.bucket, { tenant }),
|
|
795
|
-
uploadUrls: createUploadUrlMinter({
|
|
808
|
+
uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant }),
|
|
809
|
+
// Job records go in the same store as the media, for the same reason: this
|
|
810
|
+
// runtime's machine stops whenever no request is in flight, taking the
|
|
811
|
+
// child — and an in-memory registry — with it (src/job-store.ts).
|
|
812
|
+
jobStore: createBlobJobStore(blob.bucket, { tenant }),
|
|
796
813
|
};
|
|
797
814
|
}
|
|
815
|
+
/**
|
|
816
|
+
* How long a hosted `async: true` call holds the request open before handing
|
|
817
|
+
* back a job id.
|
|
818
|
+
*
|
|
819
|
+
* Not a tuning knob so much as the shape of the platform: holding the request
|
|
820
|
+
* is what keeps the executor alive, so this is "how long we are willing to keep
|
|
821
|
+
* the machine up for one generation". Four minutes is the value observed to
|
|
822
|
+
* work in practice against claude.ai, and comfortably covers a Pro/4K image or
|
|
823
|
+
* a modest set; anything slower hands back a durable job id rather than
|
|
824
|
+
* silently dying, which is the behaviour this whole change is for.
|
|
825
|
+
*/
|
|
826
|
+
const HOSTED_ASYNC_WAIT_MS = 240_000;
|
|
798
827
|
/**
|
|
799
828
|
* How long a freshly-minted media link lives.
|
|
800
829
|
*
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Making a failure say what actually failed.
|
|
3
|
+
*
|
|
4
|
+
* A hosted `gemini_get_upload_url` call came back to the client as
|
|
5
|
+
* `{"error": "Error occurred during tool execution", "request_id": "req_…"}` —
|
|
6
|
+
* no message, no failing step, no stack. That outer wrapper is the client's and
|
|
7
|
+
* not ours to change, but it is only ever reached when what we hand back is
|
|
8
|
+
* itself uninformative, and two of this repo's habits make that likely:
|
|
9
|
+
*
|
|
10
|
+
* - a thrown non-`McpToolError` surfaces as bare `err.message`, which for a
|
|
11
|
+
* runtime fault (`Illegal invocation`, `fetch failed`) names neither the
|
|
12
|
+
* tool nor the operation that was in flight; and
|
|
13
|
+
* - `cause` is dropped by MCP serialization, so a wrapped error arrives with
|
|
14
|
+
* its actual root cause deleted — the trap `ChainedRequest404Error` in
|
|
15
|
+
* client.ts already documents for `hint`, which the host drops too.
|
|
16
|
+
*
|
|
17
|
+
* So: {@link describeError} flattens the whole chain into the message,
|
|
18
|
+
* {@link step} labels the operation, and {@link surfaceToolErrors} wraps a
|
|
19
|
+
* server so EVERY registered handler gets both without each registrar
|
|
20
|
+
* remembering to. `index.ts` applies it to every registrar in the list.
|
|
21
|
+
*/
|
|
22
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
23
|
+
/** How far down a `cause` chain to walk before assuming it is pathological. */
|
|
24
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
25
|
+
/**
|
|
26
|
+
* One flat, self-describing line for anything throwable — including the
|
|
27
|
+
* `cause` chain, which MCP serialization deletes.
|
|
28
|
+
*
|
|
29
|
+
* The error's class name is prefixed only when it carries information: a bare
|
|
30
|
+
* `Error` says nothing a reader does not already know, while `TypeError:
|
|
31
|
+
* Illegal invocation` is the entire diagnosis of the workerd receiver bug (see
|
|
32
|
+
* the fetchImpl note in CLAUDE.md).
|
|
33
|
+
*/
|
|
34
|
+
export function describeError(err) {
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const describe = (value, depth) => {
|
|
37
|
+
if (!(value instanceof Error)) {
|
|
38
|
+
if (typeof value === 'string')
|
|
39
|
+
return value;
|
|
40
|
+
try {
|
|
41
|
+
return typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// A generic `Error` name is noise; anything else is the diagnosis.
|
|
48
|
+
const named = value.name && value.name !== 'Error' && value.name !== 'McpToolError'
|
|
49
|
+
? `${value.name}: ${value.message}`
|
|
50
|
+
: value.message;
|
|
51
|
+
const hint = value instanceof McpToolError && value.hint && !value.message.includes(value.hint)
|
|
52
|
+
? ` ${value.hint}`
|
|
53
|
+
: '';
|
|
54
|
+
const cause = value.cause;
|
|
55
|
+
// Self-referential and over-deep chains are real (a retry wrapper that
|
|
56
|
+
// re-wraps its own error); walking one forever is not an option.
|
|
57
|
+
if (cause === undefined || cause === null || depth >= MAX_CAUSE_DEPTH || seen.has(cause)) {
|
|
58
|
+
return `${named}${hint}`;
|
|
59
|
+
}
|
|
60
|
+
seen.add(cause);
|
|
61
|
+
return `${named}${hint} [caused by: ${describe(cause, depth + 1)}]`;
|
|
62
|
+
};
|
|
63
|
+
seen.add(err);
|
|
64
|
+
return describe(err, 0);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Run one named operation, and on failure say which one it was.
|
|
68
|
+
*
|
|
69
|
+
* Only the INNERMOST step labels: an already-labelled failure passes straight
|
|
70
|
+
* through, so a nested `step` reports `signing the upload URL: …` rather than
|
|
71
|
+
* `minting: signing the upload URL: …`. The label is what the caller needs;
|
|
72
|
+
* the call stack above it is not.
|
|
73
|
+
*/
|
|
74
|
+
export async function step(label, run) {
|
|
75
|
+
try {
|
|
76
|
+
return await run();
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err instanceof StepError)
|
|
80
|
+
throw err;
|
|
81
|
+
throw new StepError(`${label}: ${describeError(err)}`, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** An `McpToolError` that has already been attributed to a step. */
|
|
85
|
+
class StepError extends McpToolError {
|
|
86
|
+
constructor(message, cause) {
|
|
87
|
+
super(message, { cause });
|
|
88
|
+
this.name = 'McpToolError';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Wrap a server so every tool registered through it reports failures fully.
|
|
93
|
+
*
|
|
94
|
+
* A `Proxy` rather than a spread: `McpServer` methods are bound to real private
|
|
95
|
+
* state, and registrars reach for more than `registerTool` (`server.server`,
|
|
96
|
+
* `registerResource`). Everything but `registerTool` passes through untouched,
|
|
97
|
+
* and a successful result is returned byte-identically — this only ever changes
|
|
98
|
+
* what an exception looks like.
|
|
99
|
+
*/
|
|
100
|
+
export function surfaceToolErrors(server) {
|
|
101
|
+
return new Proxy(server, {
|
|
102
|
+
get(target, prop, receiver) {
|
|
103
|
+
const value = Reflect.get(target, prop, receiver);
|
|
104
|
+
if (prop !== 'registerTool' || typeof value !== 'function') {
|
|
105
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
106
|
+
}
|
|
107
|
+
return (name, config, handler) => value.call(target, name, config, async (...args) => {
|
|
108
|
+
try {
|
|
109
|
+
return await handler(...args);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Named for the tool AND the underlying error. When the client
|
|
113
|
+
// still shows its own generic wrapper, this text is what a
|
|
114
|
+
// maintainer has to work from — so it carries everything.
|
|
115
|
+
throw new McpToolError(`${name} failed: ${describeError(err)}`, { cause: err });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,16 +2,7 @@
|
|
|
2
2
|
import { runMcp } from '@chrischall/mcp-utils';
|
|
3
3
|
import { loadStdioDotenv } from './dotenv.js';
|
|
4
4
|
import { VERSION } from './version.js';
|
|
5
|
-
import {
|
|
6
|
-
import { registerGenerateTools } from './tools/generate.js';
|
|
7
|
-
import { registerSetTools } from './tools/set.js';
|
|
8
|
-
import { registerInteractTools } from './tools/interact.js';
|
|
9
|
-
import { registerVideoTools } from './tools/video.js';
|
|
10
|
-
import { registerMusicTools } from './tools/music.js';
|
|
11
|
-
import { registerJobTools } from './tools/jobs.js';
|
|
12
|
-
import { registerFileTools } from './tools/files.js';
|
|
13
|
-
import { registerUploadUrlTools } from './tools/uploads.js';
|
|
14
|
-
import { registerLibraryTools } from './tools/library.js';
|
|
5
|
+
import { TOOL_REGISTRARS } from './registrars.js';
|
|
15
6
|
import { client } from './client.js';
|
|
16
7
|
// Load `.env` here rather than in client.ts: `import.meta.url` + async I/O at
|
|
17
8
|
// module scope is not safe in every runtime this module graph reaches (see
|
|
@@ -29,24 +20,14 @@ await loadStdioDotenv();
|
|
|
29
20
|
// boots and answers the host's install-time tools/list probe even without
|
|
30
21
|
// GEMINI_API_KEY. It also owns this process's session state (job registry,
|
|
31
22
|
// last-interaction memory); stdio is single-user, so one client is one session.
|
|
23
|
+
//
|
|
24
|
+
// The roster itself lives in registrars.ts: this file cannot be imported
|
|
25
|
+
// without booting a server, and a list nothing can import is a list nothing
|
|
26
|
+
// can test (see the note there).
|
|
32
27
|
await runMcp({
|
|
33
28
|
name: 'gemini-mcp',
|
|
34
29
|
version: VERSION,
|
|
35
30
|
banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
|
|
36
|
-
tools:
|
|
37
|
-
registerModelTools,
|
|
38
|
-
registerGenerateTools,
|
|
39
|
-
registerSetTools,
|
|
40
|
-
registerInteractTools,
|
|
41
|
-
registerVideoTools,
|
|
42
|
-
registerMusicTools,
|
|
43
|
-
registerJobTools,
|
|
44
|
-
registerFileTools,
|
|
45
|
-
// Both gate on the client having somewhere to put bytes: hosted with a
|
|
46
|
-
// blob store they register, and on a local install they no-op rather than
|
|
47
|
-
// advertising a tool that cannot work.
|
|
48
|
-
registerUploadUrlTools,
|
|
49
|
-
registerLibraryTools,
|
|
50
|
-
],
|
|
31
|
+
tools: TOOL_REGISTRARS,
|
|
51
32
|
deps: client,
|
|
52
33
|
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable half of the job registry (`src/jobs.ts`).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `JobRegistry` keeps jobs in a `Map`, which is exactly right for stdio: the
|
|
7
|
+
* process that starts a generation is the process that finishes it, and if it
|
|
8
|
+
* dies the user is sitting at a terminal watching it die.
|
|
9
|
+
*
|
|
10
|
+
* Hosted, that premise is false in a specific and non-obvious way. The
|
|
11
|
+
* connector is this same stdio server run as a Node child on an mcp-host Fly
|
|
12
|
+
* machine configured `auto_stop_machines = "stop"` with
|
|
13
|
+
* `min_machines_running = 0`. Fly stops that machine when nothing is in
|
|
14
|
+
* flight — mcp-host's own docs put it plainly: *"an open connection is exactly
|
|
15
|
+
* what stops the machine stopping"*. An `async: true` call is a request that
|
|
16
|
+
* answers in milliseconds, so it holds nothing open. The machine stops, the
|
|
17
|
+
* child is killed, and the generation dies **with the only record that it ever
|
|
18
|
+
* existed**. Polling `gemini_get_result` a couple of minutes later reports the
|
|
19
|
+
* id as unknown, which reads like an expiry bug and is actually a machine that
|
|
20
|
+
* went to sleep.
|
|
21
|
+
*
|
|
22
|
+
* This is also why a long `max_wait_ms` works where `async: true` does not: the
|
|
23
|
+
* wait keeps the HTTP request open, and the open request keeps the machine up.
|
|
24
|
+
*
|
|
25
|
+
* ## What durability can and cannot buy here
|
|
26
|
+
*
|
|
27
|
+
* It buys the RECORD, not the EXECUTION. There is no Workflows, no Queue, no
|
|
28
|
+
* Durable Object and no KV in reach — the child's environment is allowlist-only
|
|
29
|
+
* (mcp-host `spawn-env.ts`), and the only durable thing it can address is the
|
|
30
|
+
* blob store its media already goes to. Nothing here resumes a killed
|
|
31
|
+
* generation; re-running it would re-bill it, which is the cost this whole
|
|
32
|
+
* mechanism exists to avoid.
|
|
33
|
+
*
|
|
34
|
+
* So the contract is: a job never silently vanishes, and a job whose executor
|
|
35
|
+
* died ends in a **terminal** state that says so. The bytes a dead job managed
|
|
36
|
+
* to write are still in `gen/`, which is what `gemini_list_recent_media` is
|
|
37
|
+
* for.
|
|
38
|
+
*
|
|
39
|
+
* ## Liveness is a heartbeat, not a guess
|
|
40
|
+
*
|
|
41
|
+
* "Still running" and "the machine ate it" are indistinguishable from a single
|
|
42
|
+
* stored status, and a timeout long enough to be safe for a 12-scene set is far
|
|
43
|
+
* too long to be useful. So a running job re-stamps `updatedAt` on an interval
|
|
44
|
+
* ({@link HEARTBEAT_MS}), and a record whose stamp is older than
|
|
45
|
+
* {@link EXECUTOR_LOST_AFTER_MS} is read as `failed` — decided at READ time, so
|
|
46
|
+
* no boot scan is needed and a machine that never comes back still resolves its
|
|
47
|
+
* jobs the moment anybody asks.
|
|
48
|
+
*
|
|
49
|
+
* Every operation is best-effort and swallows its errors. A durable store that
|
|
50
|
+
* is down must degrade to the in-memory behaviour that came before it, never
|
|
51
|
+
* fail a generation the user is paying for.
|
|
52
|
+
*/
|
|
53
|
+
import { createHash } from 'node:crypto';
|
|
54
|
+
/** How often a running job re-stamps its record so a reader can see it is alive. */
|
|
55
|
+
export const HEARTBEAT_MS = 60_000;
|
|
56
|
+
/**
|
|
57
|
+
* How stale a `running` record may get before its executor is presumed dead.
|
|
58
|
+
*
|
|
59
|
+
* Three missed heartbeats: long enough that a paused event loop mid-generation
|
|
60
|
+
* is not mistaken for a dead machine, short enough that a caller learns the
|
|
61
|
+
* truth in minutes rather than being told to keep polling forever.
|
|
62
|
+
*/
|
|
63
|
+
export const EXECUTOR_LOST_AFTER_MS = 3 * HEARTBEAT_MS;
|
|
64
|
+
/** Where a job record lives, relative to the store root. */
|
|
65
|
+
const PREFIX = 'jobs';
|
|
66
|
+
/**
|
|
67
|
+
* An idempotency key is an arbitrary caller-supplied string: it can contain
|
|
68
|
+
* slashes, spaces, or `..`. Hashing it makes the key space closed by
|
|
69
|
+
* construction rather than by escaping, so no crafted key can address an object
|
|
70
|
+
* outside this tenant's prefix.
|
|
71
|
+
*/
|
|
72
|
+
function keyHash(idempotencyKey) {
|
|
73
|
+
return createHash('sha256').update(idempotencyKey).digest('hex').slice(0, 32);
|
|
74
|
+
}
|
|
75
|
+
/** Job records over the mcp-host blob store. */
|
|
76
|
+
export function createBlobJobStore(bucket, opts) {
|
|
77
|
+
const now = opts.now ?? (() => Date.now());
|
|
78
|
+
const tenantOf = async () => (typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant);
|
|
79
|
+
const recordKey = async (jobId) => `${PREFIX}/${await tenantOf()}/${encodeURIComponent(jobId)}.json`;
|
|
80
|
+
const pointerKey = async (k) => `${PREFIX}/${await tenantOf()}/keys/${keyHash(k)}.json`;
|
|
81
|
+
async function writeJson(key, value) {
|
|
82
|
+
try {
|
|
83
|
+
await bucket.put(key, JSON.stringify(value), { httpMetadata: { contentType: 'application/json' } });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// A durable write that fails leaves the in-memory registry exactly as
|
|
87
|
+
// capable as it was before this module existed. Failing the generation
|
|
88
|
+
// instead would trade a recoverable gap for a lost paid result.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function readJson(key) {
|
|
92
|
+
try {
|
|
93
|
+
const obj = await bucket.get(key);
|
|
94
|
+
if (!obj)
|
|
95
|
+
return undefined;
|
|
96
|
+
return JSON.parse(new TextDecoder().decode(await obj.arrayBuffer()));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
async put(record) {
|
|
104
|
+
await writeJson(await recordKey(record.jobId), record);
|
|
105
|
+
},
|
|
106
|
+
async get(jobId) {
|
|
107
|
+
const record = await readJson(await recordKey(jobId));
|
|
108
|
+
if (!record || typeof record.status !== 'string')
|
|
109
|
+
return undefined;
|
|
110
|
+
if (record.status !== 'running')
|
|
111
|
+
return record;
|
|
112
|
+
if (now() - record.updatedAt <= EXECUTOR_LOST_AFTER_MS)
|
|
113
|
+
return record;
|
|
114
|
+
// Terminal, and honest about which kind of gone it is. Derived on read
|
|
115
|
+
// rather than persisted: the process that could have written the
|
|
116
|
+
// transition is by definition the one that is no longer there.
|
|
117
|
+
return {
|
|
118
|
+
...record,
|
|
119
|
+
status: 'failed',
|
|
120
|
+
error: {
|
|
121
|
+
message: `Job "${jobId}" was lost: its executor stopped reporting ${Math.round((now() - record.updatedAt) / 1000)}s ` +
|
|
122
|
+
`ago, before the generation finished. This connector runs on a machine that stops when no request is ` +
|
|
123
|
+
`in flight, so background work started with \`async: true\` does not reliably survive.`,
|
|
124
|
+
hint: 'Re-run with `max_wait_ms` (e.g. 240000) instead of `async: true` — holding the request open keeps the ' +
|
|
125
|
+
'executor alive. Any images the lost run did finish are still stored: list them with ' +
|
|
126
|
+
'gemini_list_recent_media before paying for a regeneration.',
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
async putKey(idempotencyKey, jobId) {
|
|
131
|
+
await writeJson(await pointerKey(idempotencyKey), { jobId });
|
|
132
|
+
},
|
|
133
|
+
async jobIdForKey(idempotencyKey) {
|
|
134
|
+
const pointer = await readJson(await pointerKey(idempotencyKey));
|
|
135
|
+
return typeof pointer?.jobId === 'string' ? pointer.jobId : undefined;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|