diff --git a/README.md b/README.md index 439e882..1a72edc 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,12 @@ macOS Keychain and keeps all model/search provider credentials in Cortex Worker. Live execution requires the authenticated catalog's durable protocol v2: the client submits one idempotent request, records the server-owned run ID, and polls the tenant-bound result. A dropped response or rolling deployment is -retried with the same request ID instead of starting a second paid run. Preserve -the printed request ID; `--request-id ` explicitly reattaches after a local -timeout or interruption. +retried with the same request ID instead of starting a second paid run. Before +the first submission, the client atomically writes a chmod-0600 checkpoint to +`--metadata` and prints its generated request ID to stderr. After Cortex accepts +the run, that checkpoint also records the durable run ID; terminal success +replaces it with final metadata. Preserve the checkpoint and use +`--request-id ` to reattach after a local timeout or interruption. `run-research.mjs` remains available as an explicit local-provider fallback; it supports allowlisted interactive-shell environment loading and build-freshness checks. Exact lane preflight requires an as-of date: @@ -204,7 +207,7 @@ npm run pack:release # → releases/braintied-research-.tgz ```jsonc // consumer package.json -"@braintied/research": "file:vendor/braintied-research-0.8.1.tgz" +"@braintied/research": "file:vendor/braintied-research-0.8.2.tgz" ``` ## Development diff --git a/docs/internal-agent-research-release-2026-07-23.md b/docs/internal-agent-research-release-2026-07-23.md index 2b501b6..e74cf38 100644 --- a/docs/internal-agent-research-release-2026-07-23.md +++ b/docs/internal-agent-research-release-2026-07-23.md @@ -2,17 +2,18 @@ ## Outcome -`@braintied/research` 0.8.1 replaces the long-lived HTTP execution contract -with resumable, idempotent durable polling. It is the client-side half of Cortex -internal-tools protocol v2 and is required for paid agent research. +`@braintied/research` 0.8.2 makes Cortex protocol-v2 research recoverable even +when the local CLI is killed before a terminal response. The request ID now +exists in a private durable checkpoint and a flushed diagnostic before the +first paid submission instead of living only in process memory. ## Root cause -Version 0.8.0 kept a deep research request attached to one Fly Machine. JSON -whitespace heartbeats prevented idle proxy timeouts, but a rolling Machine -replacement still severed the live request after provider work had begun. The -client could not determine whether work or billing continued and a retry risked -starting a second attempt. +Version 0.8.1 replaced the long-lived 0.8.0 request with resumable durable +polling, but a generated request ID was written to stdout and metadata only +after terminal success. A SIGINT, process death, or local machine failure after +submission could therefore lose the only reattachment key even though Cortex +still owned a queued or running paid attempt. ## Durable contract @@ -20,13 +21,22 @@ starting a second attempt. submission/status paths. - Submission uses one caller request ID as its idempotency key. The client may retry a dropped submission without creating another durable record. +- Before the first submission, the client fsyncs a mode-0600 temporary + checkpoint, atomically renames it onto `--metadata`, fsyncs the parent + directory, and flushes a compact request-ID recovery diagnostic to stderr. + The checkpoint contains no brief, Agent Auth token, provider credential, or + provider result. - The server returns a run UUID; the client polls its authenticated status and - validates the final result before writing any artifact. + validates the final result. Immediately after acceptance, the checkpoint is + atomically replaced with one that also records the durable run ID and status. +- Terminal success atomically replaces the checkpoint with final run metadata. - `--request-id` reattaches a later CLI process to the same attempt. - Temporary transport, `404`, `408`, `425`, `429`, `502`, `503`, and `504` responses use bounded retry delays within the caller's deadline. -- Metadata and stdout expose the request ID and durable run ID, never the Agent - Auth token, provider credentials, raw trusted recall, or provider error body. +- Private checkpoint metadata and pre-submission stderr expose the request ID; + accepted/final metadata and final stdout also expose the durable run ID. + None expose the Agent Auth token, provider credentials, raw trusted recall, + brief, or provider error body. - A protocol-v1/stream-only deployment is rejected before a paid call. ## Deployment exclusion @@ -40,15 +50,19 @@ bounded so a dead deploy process cannot create an indefinite outage. ## Operator recovery When a client deadline or local process interruption occurs, do not infer zero -cost and do not issue a new request ID. Repeat the identical command with the -reported `--request-id`; the client will retrieve or continue polling the -existing durable record. A fresh ID is a deliberately separate paid run. +cost and do not issue a new request ID. Read `request_id` from `--metadata` or +copy it from the pre-submission stderr line, then repeat the identical command +with `--request-id `. A `submission_pending` checkpoint does not prove +whether Cortex accepted the request; resubmitting its same ID is safe because +server admission is idempotent. A fresh ID is a deliberately separate paid +run. ## Acceptance gates Package acceptance passed: -- unit and contract tests: 56/56; +- unit and contract tests: 57/57, including forced SIGINT before terminal + status plus a second-process `--request-id` reattachment proof; - Instagram/Bright Data boundary tests: 8/8; - TypeScript and production build; - CommonJS, ESM, packaged CLI, and exact version import from a clean tarball @@ -56,11 +70,11 @@ Package acceptance passed: - clean production dependency audit: zero vulnerabilities; - exact 14-file tarball audit plus Gitleaks and high-confidence provider, Agent Auth, Swishh, GitHub, Slack, and private-key signatures: no leaks; -- archive: `releases/braintied-research-0.8.1.tgz`; +- archive: `releases/braintied-research-0.8.2.tgz`; - SHA-256: - `901a1734195d060e47fe5f34bb9b5fb540f7afa3a81e374fed9aebff7e0c05ea`; + `cbb5fa5d255914728a213085dc3b0e379e7da678901538d9576e46b99f47e155`; - npm integrity: - `sha512-w/GrdkCn56vmlQ+gExVPE4F/NHDbttS2xAkgE33N7fKBJcbwZ8eBEacYKXJbfwF0FQaTxBVNUmFuoTw2gd9zPw==`. + `sha512-67nLLm7tme3GaUDYrZFi7Eap+B0qIVnA4NKaVYdFMi63SNJGODUDvU6jXibTDXiw807y95Lwvv5OcdloCqSqvA==`. Production acceptance additionally requires a protocol-v2 catalog probe plus a transport-loss canary that destroys the first submission socket after diff --git a/package-lock.json b/package-lock.json index 437cfb8..80586df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@braintied/research", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@braintied/research", - "version": "0.8.1", + "version": "0.8.2", "license": "UNLICENSED", "dependencies": { "@anthropic-ai/sdk": "^0.104.1", diff --git a/package.json b/package.json index 31e2868..1a0c439 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@braintied/research", - "version": "0.8.1", + "version": "0.8.2", "description": "Deep-research engine — multi-provider search, quote extraction, synthesis, critique, grounded report assembly, and research-driven document generation (PRDs, briefs, specs)", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/skills/run-braintied-research/SKILL.md b/skills/run-braintied-research/SKILL.md index 541c424..8130f7a 100644 --- a/skills/run-braintied-research/SKILL.md +++ b/skills/run-braintied-research/SKILL.md @@ -74,9 +74,13 @@ model providers; treat every brief as outbound data. ``` The server persists the authenticated input before dispatch and returns a - durable run ID. Submission and status requests retry bounded transport and - temporary-service failures with the same request ID. If the local deadline - or process interrupts the client, rerun the identical command with the + durable run ID. Before the first submission, the client atomically writes a + chmod-0600 checkpoint to `--metadata` and prints the generated request ID to + stderr. Once Cortex accepts the run, the same checkpoint also records the + durable run ID; terminal success replaces it with final metadata. Submission + and status requests retry bounded transport and temporary-service failures + with the same request ID. If the local deadline or process interrupts the + client, preserve that checkpoint and rerun the identical command with its printed `--request-id ` to reattach; never choose a fresh ID for the same paid attempt. Cortex deploys atomically exclude queued/running research, so a normal rolling release cannot cut a paid run in half. @@ -197,9 +201,12 @@ grounding were **not produced**; do not describe either as `null`. If standalone provider probes were made, report their known quota/credit cost separately. For an interrupted internal run, distinguish client transport from server -execution. Preserve its request ID and use `--request-id` to retrieve the same -durable record. Do not report `$0.00`, infer provider cost, or start a replacement -run until the durable status proves the original attempt terminal. +execution. Read the request ID from the private `--metadata` checkpoint or the +pre-submission stderr diagnostic and use `--request-id` to retrieve the same +durable record. A `submission_pending` checkpoint does not prove the server did +or did not accept the request; resubmitting that same ID is the safe recovery +operation. Do not report `$0.00`, infer provider cost, or start a replacement run +until the durable status proves the original attempt terminal. Run `node skills/run-braintied-research/scripts/run-internal-research.mjs --help` for the default internal surface and `run-research.mjs --help` for the local diff --git a/skills/run-braintied-research/references/runtime.md b/skills/run-braintied-research/references/runtime.md index 2ad2631..bd75e21 100644 --- a/skills/run-braintied-research/references/runtime.md +++ b/skills/run-braintied-research/references/runtime.md @@ -41,9 +41,13 @@ node skills/run-braintied-research/scripts/run-internal-research.mjs \ --allow-external ``` -The Markdown file is always the public-provider report. The metadata file adds -`program_status`, `source_coverage`, `profile_coverage`, a pointer to the -trusted artifact, and a separately labeled reference-only `private_manifest`. +The Markdown file is always the public-provider report. Before submission, the +metadata path contains a minimal chmod-0600 recovery checkpoint with the +request ID and no brief, token, or provider content. Cortex acceptance adds the +durable run ID; terminal success atomically replaces that checkpoint with final +metadata. Final metadata adds `program_status`, `source_coverage`, +`profile_coverage`, a pointer to the trusted artifact, and a separately labeled +reference-only `private_manifest`. The third file is a chmod-0600 trusted-local appendix for the authenticated agent: up to 20 findings balanced across Cortex and Telegram, each with credential-scrubbed excerpts capped at 1,200 Unicode characters, bounded @@ -69,11 +73,15 @@ reattaches instead of paying for a second run. Deep requests default to a 3,600-second local client deadline; other kinds default to 1,200 seconds. The deadline stops polling, not the server-owned run. -Preserve the request ID from the diagnostic and repeat the same invocation with -`--request-id ` to resume. Metadata records both `request_id` and -`durable_run_id`. A successful HTTP response is not itself success: the client -requires the strict completed result envelope and a non-empty report before it -writes artifacts. +The client fsyncs and atomically renames a private checkpoint, then flushes a +compact request-ID diagnostic to stderr, before it sends the first paid +submission. Preserve the request ID from that checkpoint or diagnostic and +repeat the same invocation with `--request-id ` to resume. A +`submission_pending` checkpoint is intentionally ambiguous about remote +acceptance; resubmission with its same ID is idempotent. Accepted and final +metadata record both `request_id` and `durable_run_id`. A successful HTTP +response is not itself success: the client requires the strict completed +result envelope and a non-empty report before it writes final artifacts. Cortex deployments acquire an atomic database exclusion lease. Lease acquisition fails while any unexpired research run is queued/running, and new diff --git a/skills/run-braintied-research/scripts/run-internal-research.mjs b/skills/run-braintied-research/scripts/run-internal-research.mjs index 664d402..b69d099 100644 --- a/skills/run-braintied-research/scripts/run-internal-research.mjs +++ b/skills/run-braintied-research/scripts/run-internal-research.mjs @@ -2,7 +2,7 @@ import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -15,6 +15,7 @@ const PIPELINE_KINDS = new Set(['quick', 'standard', 'deep', 'social']); const DEFAULT_ENDPOINT = 'https://ora-cortex-worker.fly.dev/internal/tools/execute'; const DEFAULT_KEYCHAIN_SERVICE = 'braintied-agent-auth'; const DEFAULT_KEYCHAIN_ACCOUNT = 'codex'; +const CHECKPOINT_SCHEMA_VERSION = 1; const PACKAGE_ROOT = fileURLToPath(new URL('../../../', import.meta.url)); const PACKAGE_JSON = path.join(PACKAGE_ROOT, 'package.json'); const execFileAsync = promisify(execFile); @@ -41,7 +42,7 @@ Options: --keychain-service macOS Keychain service (default: braintied-agent-auth) --keychain-account macOS Keychain account (default: codex) --output Required Markdown report output - --metadata Required JSON run metadata output + --metadata Required private JSON checkpoint/final metadata output --trusted-output Required for live profile runs; chmod-0600 JSON trusted-local appendix (never public Markdown) --allow-external Acknowledge that the brief goes to external services @@ -289,17 +290,77 @@ async function readBrief(values) { async function atomicWrite(targetPath, contents) { const absolute = path.resolve(targetPath); - await mkdir(path.dirname(absolute), { recursive: true }); - const temporary = `${absolute}.tmp-${process.pid}`; + const directory = path.dirname(absolute); + await mkdir(directory, { recursive: true }); + const temporary = `${absolute}.tmp-${process.pid}-${randomUUID()}`; + let handle; try { - await writeFile(temporary, contents, { encoding: 'utf8', mode: 0o600 }); + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(contents, { encoding: 'utf8' }); + await handle.sync(); + await handle.close(); + handle = undefined; await rename(temporary, absolute); + const directoryHandle = await open(directory, 'r'); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } } finally { + await handle?.close(); await rm(temporary, { force: true }); } return absolute; } +async function writeStderrLine(value) { + await new Promise((resolve, reject) => { + process.stderr.write(`${value}\n`, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function createResearchCheckpoint({ + packageVersion: currentPackageVersion, + requestId, + run, + protocolVersion, + startedAt, + kind, + maxCostUsd, + recencyDays, + profileRef, + profileMode, + asOf, + timeoutSeconds, +}) { + return { + artifact_type: 'braintied_internal_research_checkpoint', + schema_version: CHECKPOINT_SCHEMA_VERSION, + mode: 'internal', + package_version: currentPackageVersion, + request_id: requestId, + durable_run_id: run?.id ?? null, + execution_protocol: protocolVersion, + checkpoint_status: run === null + ? 'submission_pending' + : 'awaiting_terminal_result', + durable_run_status: run?.status ?? null, + started_at: startedAt.toISOString(), + updated_at: new Date().toISOString(), + kind, + requested_max_cost_usd: maxCostUsd ?? null, + requested_recency_days: recencyDays ?? null, + profile_ref: profileRef ?? null, + profile_mode: profileMode ?? null, + as_of: asOf ?? null, + timeout_seconds: timeoutSeconds, + }; +} + function validateResult(payload) { if (payload === null || typeof payload !== 'object' || payload.ok !== true) { throw new Error('Internal tool returned an invalid success envelope.'); @@ -794,7 +855,15 @@ async function pollDurableResearch({ ); } -async function executeResearch({ endpoint, token, timeoutSeconds, requestId, input, probe }) { +async function executeResearch({ + endpoint, + token, + timeoutSeconds, + requestId, + input, + probe, + onSubmitted, +}) { const durable = resolveDurableEndpoints(endpoint, probe); const deadlineMs = Date.now() + timeoutSeconds * 1000; const run = await submitDurableResearch({ @@ -804,6 +873,7 @@ async function executeResearch({ endpoint, token, timeoutSeconds, requestId, inp input, deadlineMs, }); + await onSubmitted(run); const result = await pollDurableResearch({ endpoint: durable.status(run.id), token, @@ -935,12 +1005,40 @@ async function main() { const requestId = requestedRequestId ?? randomUUID(); const startedAt = new Date(); const startedMonotonic = process.hrtime.bigint(); + const checkpointContext = { + packageVersion: check.package_version, + requestId, + protocolVersion: liveProbe.protocol_version, + startedAt, + kind, + maxCostUsd, + recencyDays, + profileRef, + profileMode, + asOf, + timeoutSeconds, + }; + const persistCheckpoint = async (run) => atomicWrite( + values.metadata, + `${JSON.stringify(createResearchCheckpoint({ + ...checkpointContext, + run, + }), null, 2)}\n`, + ); + const checkpointPath = await persistCheckpoint(null); + await writeStderrLine(JSON.stringify({ + event: 'braintied_internal_research_checkpoint', + request_id: requestId, + metadata: checkpointPath, + resume_with: `--request-id ${requestId}`, + })); const execution = await executeResearch({ endpoint, token: auth.token, timeoutSeconds, requestId, probe: liveProbe, + onSubmitted: persistCheckpoint, input: { brief, kind, diff --git a/test/internal-runner-preflight.test.ts b/test/internal-runner-preflight.test.ts index 45e66d5..9e3cf7c 100644 --- a/test/internal-runner-preflight.test.ts +++ b/test/internal-runner-preflight.test.ts @@ -614,6 +614,187 @@ test('live internal runner reattaches to a durable run after submission transpor assert.equal(statusReads, 2); }); +test('an interrupted live runner preserves its generated request ID and reattaches explicitly', { + timeout: 15_000, +}, async (t) => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'braintied-interrupted-')); + t.after(async () => { await rm(temporaryDirectory, { recursive: true, force: true }); }); + const reportPath = path.join(temporaryDirectory, 'report.md'); + const metadataPath = path.join(temporaryDirectory, 'metadata.json'); + const token = 'sat_interrupted_test'; + const brief = 'Prove a locally interrupted paid run can reattach without duplicate execution.'; + + let acceptedRequestId: string | undefined; + let completeStatus = false; + let firstStatusSeenResolve: (() => void) | undefined; + const firstStatusSeen = new Promise((resolve) => { + firstStatusSeenResolve = resolve; + }); + const submissionRequestIds: string[] = []; + const submissionCheckpointStatuses: string[] = []; + const server = createServer(async (request, response) => { + assert.equal(request.headers.authorization, `Bearer ${token}`); + if (request.method === 'GET' && request.url === '/internal/tools') { + sendDurableCatalog(response); + return; + } + if (request.method === 'POST' && request.url === '/internal/tools/runs') { + const requestId = request.headers['x-request-id']; + assert.equal(typeof requestId, 'string'); + acceptedRequestId ??= requestId; + assert.equal(requestId, acceptedRequestId); + submissionRequestIds.push(requestId); + + const checkpoint = JSON.parse(await readFile(metadataPath, 'utf8')) as { + artifact_type: string; + schema_version: number; + request_id: string; + durable_run_id: string | null; + checkpoint_status: string; + }; + assert.equal(checkpoint.artifact_type, 'braintied_internal_research_checkpoint'); + assert.equal(checkpoint.schema_version, 1); + assert.equal(checkpoint.request_id, requestId); + assert.equal(checkpoint.durable_run_id, null); + submissionCheckpointStatuses.push(checkpoint.checkpoint_status); + + response.writeHead(202, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ + ok: true, + run: { + id: DURABLE_RUN_ID, + requestId, + status: 'running', + pollAfterMs: 250, + }, + })); + return; + } + + assert.equal(request.method, 'GET'); + assert.equal(request.url, `/internal/tools/runs/${DURABLE_RUN_ID}`); + if (!completeStatus) { + response.writeHead(202, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ + ok: true, + run: { + id: DURABLE_RUN_ID, + requestId: acceptedRequestId, + status: 'running', + pollAfterMs: 250, + }, + })); + firstStatusSeenResolve?.(); + return; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ + ok: true, + result: { + kind: 'quick', + engine: 'pipeline', + report: { + full_markdown: '# Resumed result\n\nThe original durable run completed.', + bibliography: [], + gaps: [], + word_count: 7, + }, + grounding: { + ratio: 0, + total_citations: 0, + valid_citations: 0, + hallucinated: [], + status: 'ungrounded', + }, + costUsd: 0.1, + appliedMaxCostUsd: 0.25, + quoteCount: 0, + }, + })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { server.close(); }); + const address = server.address(); + assert.ok(address !== null && typeof address === 'object'); + const endpoint = `http://127.0.0.1:${address.port}/internal/tools/execute`; + const baseArgs = [ + '--brief', brief, + '--kind', 'quick', + '--max-cost-usd', '0.25', + '--endpoint', endpoint, + '--timeout-seconds', '10', + '--output', reportPath, + '--metadata', metadataPath, + '--allow-external', + ]; + + const interruptedChild = spawn(process.execPath, [runner, ...baseArgs], { + cwd: packageRoot, + env: { ...process.env, BRAINTIED_AGENT_TOKEN: token }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + t.after(() => { interruptedChild.kill('SIGKILL'); }); + let interruptedStderr = ''; + interruptedChild.stderr.setEncoding('utf8'); + interruptedChild.stderr.on('data', (chunk: string) => { + interruptedStderr += chunk; + }); + const interruptedClose = new Promise<{ status: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + interruptedChild.once('error', reject); + interruptedChild.once('close', (status, signal) => resolve({ status, signal })); + }, + ); + + await firstStatusSeen; + assert.equal(interruptedChild.kill('SIGINT'), true); + const interrupted = await interruptedClose; + assert.equal(interrupted.status, null); + assert.equal(interrupted.signal, 'SIGINT'); + assert.ok(acceptedRequestId); + assert.match(acceptedRequestId, /^[0-9a-f-]{36}$/i); + assert.match(interruptedStderr, /"event":"braintied_internal_research_checkpoint"/); + assert.match(interruptedStderr, new RegExp(`--request-id ${acceptedRequestId}`)); + + const interruptedMetadataText = await readFile(metadataPath, 'utf8'); + const interruptedMetadata = JSON.parse(interruptedMetadataText) as { + artifact_type: string; + request_id: string; + durable_run_id: string; + execution_protocol: string; + checkpoint_status: string; + durable_run_status: string; + }; + assert.equal(interruptedMetadata.artifact_type, 'braintied_internal_research_checkpoint'); + assert.equal(interruptedMetadata.request_id, acceptedRequestId); + assert.equal(interruptedMetadata.durable_run_id, DURABLE_RUN_ID); + assert.equal(interruptedMetadata.execution_protocol, '2'); + assert.equal(interruptedMetadata.checkpoint_status, 'awaiting_terminal_result'); + assert.equal(interruptedMetadata.durable_run_status, 'running'); + assert.equal((await stat(metadataPath)).mode & 0o777, 0o600); + assert.equal(interruptedMetadataText.includes(token), false); + assert.equal(interruptedMetadataText.includes(brief), false); + + completeStatus = true; + const resumed = await runRunner([ + ...baseArgs, + '--request-id', acceptedRequestId, + ], { ...process.env, BRAINTIED_AGENT_TOKEN: token }); + assert.equal(resumed.status, 0, resumed.stderr); + assert.match(resumed.stderr, new RegExp(`--request-id ${acceptedRequestId}`)); + assert.match(await readFile(reportPath, 'utf8'), /original durable run completed/); + const completedMetadata = JSON.parse(await readFile(metadataPath, 'utf8')) as { + request_id: string; + durable_run_id: string; + execution_protocol: string; + }; + assert.equal(completedMetadata.request_id, acceptedRequestId); + assert.equal(completedMetadata.durable_run_id, DURABLE_RUN_ID); + assert.equal(completedMetadata.execution_protocol, '2'); + assert.deepEqual(submissionRequestIds, [acceptedRequestId, acceptedRequestId]); + assert.deepEqual(submissionCheckpointStatuses, ['submission_pending', 'submission_pending']); +}); + test('live internal runner preserves a sanitized transport diagnostic and request ID', async (t) => { const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'braintied-transport-')); t.after(async () => { await rm(temporaryDirectory, { recursive: true, force: true }); });