Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/recording-retention-30d.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Request recordings are kept for 30 days by default and the recording variables are renamed to THINKER_RECORDING_* (#28). Ships inside 0.1.0; release notes in docs/releases/0.1.0.md.
7 changes: 4 additions & 3 deletions docs/dev/manifest-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ Thinker reads only `THINKER_*` names (#21); the upstream-era names are not alias
- `AUTH_DB_POOL_MAX` — Connection pool size for Better Auth's own `pg.Pool`, separate from `DB_POOL_MAX`. Default: `5`.
- `THINKER_LMSTUDIO_BASE_URL` / `THINKER_LLAMACPP_BASE_URL` — OpenAI-compatible server for the built-in `lmstudio` / `llamacpp` providers (connected with `authType: 'local'`). With or without the trailing `/v1`. Default `http://<host alias>:1234` / `http://<host alias>:8080`, where the alias is `host.docker.internal` in Docker, `host.containers.internal` in Podman and `localhost` otherwise (`common/constants/local-servers.ts`). Operator-set, so not SSRF-revalidated, like `OLLAMA_HOST`. A value that is not an absolute `http(s)` URL (e.g. `192.168.1.20:1234`, or one with credentials or a query string) is ignored with a warning at boot and the default is used. Tile-connected servers are custom providers and use their own base URL instead; `lmstudio`, `llamacpp` and every other built-in provider id are reserved, so a custom provider cannot publish under them.
- `OLLAMA_HOST` — Ollama endpoint for the built-in tile. Defaults to `http://localhost:11434` outside Docker and `http://host.docker.internal:11434` inside the bundled `docker/docker-compose.yml`.
- `REQUEST_RECORDING_STORAGE` / `REQUEST_RECORDING_FILESYSTEM_PATH` / `REQUEST_RECORDING_S3_*` / `REQUEST_RECORDING_RETENTION_DAYS` — Provider Attempt body recording storage + retention. See [Request Recording](#request-recording-provider-attempt-bodies).
- `THINKER_RECORDING_STORAGE` / `THINKER_RECORDING_FILESYSTEM_PATH` / `THINKER_RECORDING_S3_*` / `THINKER_RECORDING_RETENTION_DAYS` — Provider Attempt body recording storage + retention. See [Request Recording](#request-recording-provider-attempt-bodies).
- Thinker has no billing, plans or request quotas (#14): the `STRIPE_*`, `PLAN_LIMIT_*` and `PLAN_REQUEST_QUOTA_RESET_AT` variables are gone and ignored if set. The proxy no longer checks a plan limit per request and never answers 402 on its own; `DropBillingSchema1803200000000` drops the leftover billing tables and columns.
- Thinker has no waitlist, CRM metrics feed, GitHub stars, free-models catalog, public error pages or managed Gemini Free (#15): `MANIFEST_PUBLIC_STATS`, `ERROR_PAGE_PUSH_SECRET`, `CRM_METRICS_SECRET` and `CREDITS_*` are gone and ignored if set, and so are their outbound calls (`credits.manifest.build`, `api.github.com`, the `mnfst/awesome-free-llm-apis` list on `raw.githubusercontent.com`). `DropSaasFeatureSchema1803300000000` drops `waitlist_claims`, `public_error_pages` and the cloud-only `IDX_requests_autofix_healed` index (with `lock_timeout` set), and deletes every `gemini-free` connection in `tenant_providers` together with its agent grants: the key only worked against `credits.manifest.build`. Request history keeps its `gemini-free` label, and routes that still name the provider resolve as unavailable and fall through to their fallbacks, as after a disconnect.
- Thinker has no Autofix (#16): `AUTOFIX_GLOBAL_ENABLED`, `AUTOFIX_URL`, `AUTOFIX_TIMEOUT_MS`, `AUTOFIX_HEALING_API_KEY`, `AUTOFIX_HEALING_URL`, `AUTOFIX_REPORT_ALL_4XX`, `AUTOFIX_REPAIRABLE_STATUSES`, `AUTOFIX_ROLLOUT` and `HEALING_CLIENT` are gone and ignored if set, and so is every call to `autofix.manifest.build` (the boot-time health probe included). See [Autofix removed](#autofix-removed).
Expand Down Expand Up @@ -574,9 +574,10 @@ When the primary and every fallback fail, the response **says what the provider
Opt-in, **per-harness** recording of Provider Attempt request/response bodies, browsable from the Requests log.

- **Toggle**: `GET/PATCH /api/v1/routing/:agentName/recording` (in `tier.controller.ts`); UI in `pages/SettingsRecordingSection.tsx`. **Default ON for newly created harnesses only** — enabling the feature never changed existing harnesses' settings. Setting changes apply immediately across replicas.
- **Storage selection** (`REQUEST_RECORDING_STORAGE=auto|s3|filesystem|disabled`, default `auto`): complete S3 credentials win; otherwise recordings go to the filesystem path (`REQUEST_RECORDING_FILESYSTEM_PATH`, default `/data/request-recordings` in production, `.data/request-recordings` under the working directory otherwise). S3 config: `REQUEST_RECORDING_S3_BUCKET/ENDPOINT/REGION/ACCESS_KEY_ID/SECRET_ACCESS_KEY/FORCE_PATH_STYLE`.
- **Storage selection** (`THINKER_RECORDING_STORAGE=auto|s3|filesystem|disabled`, default `auto`): complete S3 credentials win; otherwise recordings go to the filesystem path (`THINKER_RECORDING_FILESYSTEM_PATH`, default `/data/request-recordings` in production, `.data/request-recordings` under the working directory otherwise). S3 config: `THINKER_RECORDING_S3_BUCKET/ENDPOINT/REGION/ACCESS_KEY_ID/SECRET_ACCESS_KEY/FORCE_PATH_STYLE`.
- **Encryption at rest**: recordings are encrypted under the **current** `THINKER_ENCRYPTION_KEY` only. The `THINKER_ENCRYPTION_KEY_PREVIOUS` re-encryption pass does **not** rewrite recordings — after a key change, older recordings are unreadable until retention removes them. Pre-encryption gzip-only recordings stay readable.
- **Retention** (`REQUEST_RECORDING_RETENTION_DAYS`): one instance-wide value, default 365d (no per-plan retention). Cleanup cron in `database/request-recording-retention.service.ts`.
- **Retention** (`THINKER_RECORDING_RETENTION_DAYS`): one instance-wide value, default **30 days** (`DEFAULT_RECORDING_RETENTION_DAYS` in `config/recording-retention.ts`, no per-plan retention). A value that is not a positive integer fails boot. The effective value and backend are logged at boot (`Request recordings: retention N days, storage <filesystem|s3|unavailable>`). Cleanup cron (02:00, advisory lock `4011986`) in `database/request-recording-retention.service.ts`.
- **Variable names**: the family is `THINKER_RECORDING_*`. The former `REQUEST_RECORDING_*` names are not read (no aliases); if any is still set, boot logs a warning naming its replacement.
- **Privacy**: inline base64 images are stripped from recordings.
- **Code**: `common/services/request-recording-storage.service.ts` (storage backends), `common/utils/request-recording-codec.ts` (gzip + encryption codec), `routing/proxy/attempt-recording.service.ts` + `attempt-recording-capture.ts` (capture in the proxy path).
- **Deploy templates** provision durable recording storage (see README deploy table). Volume-backed templates are single-instance; use S3-compatible storage before scaling horizontally.
Expand Down
21 changes: 11 additions & 10 deletions packages/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,20 @@ BETTER_AUTH_URL=http://localhost:3001
# AGENT_USAGE_DAILY_READ_TENANTS= # Optional comma-separated tenant rollout before global cutover
# AGENT_USAGE_DAILY_BATCH_SIZE=250 # Requests and provider attempts selected per transaction
# AGENT_USAGE_DAILY_RUN_BUDGET_MS=5000 # Maximum work budget for each one-minute run
# Optional recording retention override, in days (default 365).
# REQUEST_RECORDING_RETENTION_DAYS=365
# Recording retention, in days (default 30). Must be a positive integer;
# an invalid value fails boot.
# THINKER_RECORDING_RETENTION_DAYS=30
# Storage selection defaults to auto: complete S3 credentials win; otherwise
# self-hosted installs use the mounted filesystem path. Managed Cloud never
# falls back to ephemeral local storage.
# REQUEST_RECORDING_STORAGE=auto # auto | s3 | filesystem | disabled
# REQUEST_RECORDING_FILESYSTEM_PATH=/data/request-recordings
# REQUEST_RECORDING_S3_BUCKET=
# REQUEST_RECORDING_S3_ENDPOINT=
# REQUEST_RECORDING_S3_REGION=auto
# REQUEST_RECORDING_S3_ACCESS_KEY_ID=
# REQUEST_RECORDING_S3_SECRET_ACCESS_KEY=
# REQUEST_RECORDING_S3_FORCE_PATH_STYLE=false
# THINKER_RECORDING_STORAGE=auto # auto | s3 | filesystem | disabled
# THINKER_RECORDING_FILESYSTEM_PATH=/data/request-recordings
# THINKER_RECORDING_S3_BUCKET=
# THINKER_RECORDING_S3_ENDPOINT=
# THINKER_RECORDING_S3_REGION=auto
# THINKER_RECORDING_S3_ACCESS_KEY_ID=
# THINKER_RECORDING_S3_SECRET_ACCESS_KEY=
# THINKER_RECORDING_S3_FORCE_PATH_STYLE=false

# ── Public model catalogs (ADR 0003) ─────────────────
# THINKER_CATALOG_SYNC=true # Set false to stop the only outbound calls Thinker makes on its own: openrouter.ai and models.dev (boot + daily) and the OpenCode Go doc on raw.githubusercontent.com (only with an OpenCode Go provider). Costs then come from the bundled data only; most models show $0.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('request recording storage selection', () => {
resolveRecordingStorage(config({ 'app.requestRecordingStorage': 'somewhere-else' })),
).toEqual({
backend: null,
reason: 'Unknown REQUEST_RECORDING_STORAGE value "somewhere-else"',
reason: 'Unknown THINKER_RECORDING_STORAGE value "somewhere-else"',
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function resolveRecordingStorage(
if (requested !== 'auto') {
return {
backend: null,
reason: `Unknown REQUEST_RECORDING_STORAGE value "${requested}"`,
reason: `Unknown THINKER_RECORDING_STORAGE value "${requested}"`,
};
}
if (bucket || region || hasPartialCredentials || endpoint) return s3();
Expand Down
36 changes: 22 additions & 14 deletions packages/backend/src/config/app.config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { LEGACY_RECORDING_PREFIX } from './recording-retention';

describe('appConfig', () => {
const originalEnv = process.env;

Expand Down Expand Up @@ -108,24 +110,30 @@ describe('appConfig', () => {
expect(config.runMigrationsOnBoot).toBe(false);
});

it('leaves request recording retention unset by default', async () => {
delete process.env['REQUEST_RECORDING_RETENTION_DAYS'];
it('defaults request recording retention to 30 days', async () => {
delete process.env['THINKER_RECORDING_RETENTION_DAYS'];
const config = await loadConfig();
expect(config.requestRecordingRetentionDays).toBeNull();
expect(config.requestRecordingRetentionDays).toBe(30);
});

it('reads a positive request recording retention override', async () => {
process.env['REQUEST_RECORDING_RETENTION_DAYS'] = '90';
process.env['THINKER_RECORDING_RETENTION_DAYS'] = '7';
const config = await loadConfig();
expect(config.requestRecordingRetentionDays).toBe(90);
expect(config.requestRecordingRetentionDays).toBe(7);
});

it('ignores the legacy retention variable name', async () => {
delete process.env['THINKER_RECORDING_RETENTION_DAYS'];
process.env[`${LEGACY_RECORDING_PREFIX}RETENTION_DAYS`] = '90';
const config = await loadConfig();
expect(config.requestRecordingRetentionDays).toBe(30);
});

it.each(['0', '-1', '1.5', 'invalid', '9007199254740992'])(
'ignores invalid request recording retention %s',
'fails on an invalid request recording retention %s',
async (value) => {
process.env['REQUEST_RECORDING_RETENTION_DAYS'] = value;
const config = await loadConfig();
expect(config.requestRecordingRetentionDays).toBeNull();
process.env['THINKER_RECORDING_RETENTION_DAYS'] = value;
await expect(loadConfig()).rejects.toThrow(/THINKER_RECORDING_RETENTION_DAYS/);
},
);

Expand Down Expand Up @@ -174,16 +182,16 @@ describe('appConfig', () => {
);

it('defaults request recording storage selection to auto', async () => {
delete process.env['REQUEST_RECORDING_STORAGE'];
delete process.env['THINKER_RECORDING_STORAGE'];
const config = await loadConfig();
expect(config.requestRecordingStorage).toBe('auto');
});

it('reads request recording storage settings', async () => {
process.env['REQUEST_RECORDING_STORAGE'] = 's3';
process.env['REQUEST_RECORDING_S3_BUCKET'] = 'recordings';
process.env['REQUEST_RECORDING_S3_REGION'] = 'auto';
process.env['REQUEST_RECORDING_S3_FORCE_PATH_STYLE'] = 'true';
process.env['THINKER_RECORDING_STORAGE'] = 's3';
process.env['THINKER_RECORDING_S3_BUCKET'] = 'recordings';
process.env['THINKER_RECORDING_S3_REGION'] = 'auto';
process.env['THINKER_RECORDING_S3_FORCE_PATH_STYLE'] = 'true';
const config = await loadConfig();

expect(config.requestRecordingStorage).toBe('s3');
Expand Down
25 changes: 13 additions & 12 deletions packages/backend/src/config/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { registerAs } from '@nestjs/config';
import { optionalPositiveInteger } from './env.util';
import { parseRecordingRetentionDays } from './recording-retention';

function resolveDatabaseUrl(): string {
const url = process.env['DATABASE_URL'];
Expand Down Expand Up @@ -52,22 +53,22 @@ export const appConfig = registerAs('app', () => ({
// routing during the post-SIGTERM deregistration lag (the rolling-deploy 5xx
// spike). Must be shorter than Railway's `drainingSeconds`. 0 disables it.
shutdownDrainMs: Number(process.env['SHUTDOWN_DRAIN_MS'] ?? 10000),
// Optional instance-wide override. When unset, request recordings are kept
// for the 365-day default.
requestRecordingRetentionDays: optionalPositiveInteger(
process.env['REQUEST_RECORDING_RETENTION_DAYS'],
// Instance-wide retention in days: 30 by default, overridden by
// THINKER_RECORDING_RETENTION_DAYS. An invalid value fails boot.
requestRecordingRetentionDays: parseRecordingRetentionDays(
process.env['THINKER_RECORDING_RETENTION_DAYS'],
),
// Recording storage uses capability detection by default: complete S3
// credentials win, otherwise self-hosted installs use their mounted
// filesystem. The explicit selector is an escape hatch for custom setups.
requestRecordingStorage: process.env['REQUEST_RECORDING_STORAGE'] ?? 'auto',
requestRecordingFilesystemPath: process.env['REQUEST_RECORDING_FILESYSTEM_PATH'] ?? '',
requestRecordingS3Bucket: process.env['REQUEST_RECORDING_S3_BUCKET'] ?? '',
requestRecordingS3Endpoint: process.env['REQUEST_RECORDING_S3_ENDPOINT'] ?? '',
requestRecordingS3Region: process.env['REQUEST_RECORDING_S3_REGION'] ?? '',
requestRecordingS3AccessKeyId: process.env['REQUEST_RECORDING_S3_ACCESS_KEY_ID'] ?? '',
requestRecordingS3SecretAccessKey: process.env['REQUEST_RECORDING_S3_SECRET_ACCESS_KEY'] ?? '',
requestRecordingS3ForcePathStyle: process.env['REQUEST_RECORDING_S3_FORCE_PATH_STYLE'] === 'true',
requestRecordingStorage: process.env['THINKER_RECORDING_STORAGE'] ?? 'auto',
requestRecordingFilesystemPath: process.env['THINKER_RECORDING_FILESYSTEM_PATH'] ?? '',
requestRecordingS3Bucket: process.env['THINKER_RECORDING_S3_BUCKET'] ?? '',
requestRecordingS3Endpoint: process.env['THINKER_RECORDING_S3_ENDPOINT'] ?? '',
requestRecordingS3Region: process.env['THINKER_RECORDING_S3_REGION'] ?? '',
requestRecordingS3AccessKeyId: process.env['THINKER_RECORDING_S3_ACCESS_KEY_ID'] ?? '',
requestRecordingS3SecretAccessKey: process.env['THINKER_RECORDING_S3_SECRET_ACCESS_KEY'] ?? '',
requestRecordingS3ForcePathStyle: process.env['THINKER_RECORDING_S3_FORCE_PATH_STYLE'] === 'true',
// Sliding lifetime (days) of a CLI-minted management PAT. Every successful
// authentication pushes the key's `expires_at` this far into the future, so
// an active CLI never has to re-login and an abandoned one lapses.
Expand Down
48 changes: 48 additions & 0 deletions packages/backend/src/config/recording-retention.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
DEFAULT_RECORDING_RETENTION_DAYS,
LEGACY_RECORDING_PREFIX,
legacyRecordingEnvWarning,
parseRecordingRetentionDays,
} from './recording-retention';

describe('parseRecordingRetentionDays', () => {
it('defaults to 30 days when unset or blank', () => {
expect(DEFAULT_RECORDING_RETENTION_DAYS).toBe(30);
expect(parseRecordingRetentionDays(undefined)).toBe(30);
expect(parseRecordingRetentionDays('')).toBe(30);
expect(parseRecordingRetentionDays(' ')).toBe(30);
});

it('accepts a positive integer override', () => {
expect(parseRecordingRetentionDays('7')).toBe(7);
expect(parseRecordingRetentionDays(' 365 ')).toBe(365);
});

it.each(['0', '-1', '1.5', '7d', 'invalid', '9007199254740992'])(
'rejects %s with a message naming the variable',
(value) => {
expect(() => parseRecordingRetentionDays(value)).toThrow(
/THINKER_RECORDING_RETENTION_DAYS must be a positive integer/,
);
},
);
});

describe('legacyRecordingEnvWarning', () => {
it('is null when no legacy variable is set', () => {
expect(legacyRecordingEnvWarning({ THINKER_RECORDING_STORAGE: 's3' })).toBeNull();
expect(legacyRecordingEnvWarning({ [`${LEGACY_RECORDING_PREFIX}STORAGE`]: '' })).toBeNull();
});

it('names each legacy variable and its replacement, never the value', () => {
const warning = legacyRecordingEnvWarning({
[`${LEGACY_RECORDING_PREFIX}S3_SECRET_ACCESS_KEY`]: 'super-secret-value',
[`${LEGACY_RECORDING_PREFIX}STORAGE`]: 's3',
});
expect(warning).toContain(
`${LEGACY_RECORDING_PREFIX}S3_SECRET_ACCESS_KEY -> THINKER_RECORDING_S3_SECRET_ACCESS_KEY`,
);
expect(warning).toContain(`${LEGACY_RECORDING_PREFIX}STORAGE -> THINKER_RECORDING_STORAGE`);
expect(warning).not.toContain('super-secret-value');
});
});
50 changes: 50 additions & 0 deletions packages/backend/src/config/recording-retention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Request recording retention (#28). Recordings hold full prompts and
* responses, so the default keeps them for 30 days (docs/decisoes-iniciais.md
* §4). `THINKER_RECORDING_RETENTION_DAYS` overrides it instance-wide.
*/
export const DEFAULT_RECORDING_RETENTION_DAYS = 30;

export const RECORDING_RETENTION_ENV = 'THINKER_RECORDING_RETENTION_DAYS';

/**
* Parses the retention override. Unset or blank means the 30-day default; any
* other value must be a positive integer, otherwise boot fails with a message
* naming the variable, so a typo never silently keeps recordings forever.
*/
export function parseRecordingRetentionDays(value: string | undefined): number {
const raw = value?.trim() ?? '';
if (raw === '') return DEFAULT_RECORDING_RETENTION_DAYS;
if (/^\d+$/.test(raw)) {
const parsed = Number(raw);
if (Number.isSafeInteger(parsed) && parsed > 0) return parsed;
}
throw new Error(
`${RECORDING_RETENTION_ENV} must be a positive integer number of days (got "${raw}"). ` +
`Unset it to use the ${DEFAULT_RECORDING_RETENTION_DAYS}-day default.`,
);
}

// Built from parts so the old family name only exists here as a value to
// detect, and a grep for the old family over packages/ stays empty (#28).
export const LEGACY_RECORDING_PREFIX = ['REQUEST', 'RECORDING', ''].join('_');
const RECORDING_PREFIX = 'THINKER_RECORDING_';

/**
* The recording variables were renamed from `REQUEST` + `_RECORDING_*` to
* `THINKER_RECORDING_*` without aliases. Returns one warning line naming the
* old variables that are still set (never their values), or null.
*/
export function legacyRecordingEnvWarning(env: NodeJS.ProcessEnv = process.env): string | null {
const names = Object.keys(env)
.filter((name) => name.startsWith(LEGACY_RECORDING_PREFIX) && (env[name] ?? '') !== '')
.sort();
if (names.length === 0) return null;
return (
'Ignoring legacy request recording variables (Thinker reads only THINKER_RECORDING_*): ' +
names
.map((name) => `${name} -> ${RECORDING_PREFIX}${name.slice(LEGACY_RECORDING_PREFIX.length)}`)
.join(', ') +
'.'
);
}
Loading
Loading