Merge pull request #1356 from paperclipai/feature/dev-restart-log-censor-followups

Improve dev restart handling and instance settings behavior
This commit is contained in:
Dotta
2026-03-20 13:19:41 -05:00
committed by GitHub
44 changed files with 11673 additions and 207 deletions

View File

@@ -39,6 +39,8 @@ This starts:
`pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching. `pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching.
`pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server.
Tailscale/private-auth dev mode: Tailscale/private-auth dev mode:
```sh ```sh

View File

@@ -13,9 +13,19 @@ npx paperclipai onboard --yes
This walks you through setup, configures your environment, and gets Paperclip running. This walks you through setup, configures your environment, and gets Paperclip running.
To start Paperclip again later:
```sh
npx paperclipai run
```
> **Note:** If you used `npx` for setup, always use `npx paperclipai` to run commands. The `pnpm paperclipai` form only works inside a cloned copy of the Paperclip repository (see Local Development below).
## Local Development ## Local Development
Prerequisites: Node.js 20+ and pnpm 9+. For contributors working on Paperclip itself. Prerequisites: Node.js 20+ and pnpm 9+.
Clone the repository, then:
```sh ```sh
pnpm install pnpm install
@@ -26,7 +36,7 @@ This starts the API server and UI at [http://localhost:3100](http://localhost:31
No external database required — Paperclip uses an embedded PostgreSQL instance by default. No external database required — Paperclip uses an embedded PostgreSQL instance by default.
## One-Command Bootstrap When working from the cloned repo, you can also use:
```sh ```sh
pnpm paperclipai run pnpm paperclipai run

View File

@@ -1,19 +1,29 @@
import type { TranscriptEntry } from "./types.js"; import type { TranscriptEntry } from "./types.js";
export const REDACTED_HOME_PATH_USER = "[]"; export const REDACTED_HOME_PATH_USER = "*";
export interface HomePathRedactionOptions {
enabled?: boolean;
}
function maskHomePathUserSegment(value: string) {
const trimmed = value.trim();
if (!trimmed) return REDACTED_HOME_PATH_USER;
return `${trimmed[0]}${"*".repeat(Math.max(1, Array.from(trimmed).length - 1))}`;
}
const HOME_PATH_PATTERNS = [ const HOME_PATH_PATTERNS = [
{ {
regex: /\/Users\/[^/\\\s]+/g, regex: /\/Users\/([^/\\\s]+)/g,
replace: `/Users/${REDACTED_HOME_PATH_USER}`, replace: (_match: string, user: string) => `/Users/${maskHomePathUserSegment(user)}`,
}, },
{ {
regex: /\/home\/[^/\\\s]+/g, regex: /\/home\/([^/\\\s]+)/g,
replace: `/home/${REDACTED_HOME_PATH_USER}`, replace: (_match: string, user: string) => `/home/${maskHomePathUserSegment(user)}`,
}, },
{ {
regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g, regex: /([A-Za-z]:\\Users\\)([^\\/\s]+)/g,
replace: `$1${REDACTED_HOME_PATH_USER}`, replace: (_match: string, prefix: string, user: string) => `${prefix}${maskHomePathUserSegment(user)}`,
}, },
] as const; ] as const;
@@ -23,7 +33,8 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return proto === Object.prototype || proto === null; return proto === Object.prototype || proto === null;
} }
export function redactHomePathUserSegments(text: string): string { export function redactHomePathUserSegments(text: string, opts?: HomePathRedactionOptions): string {
if (opts?.enabled === false) return text;
let result = text; let result = text;
for (const pattern of HOME_PATH_PATTERNS) { for (const pattern of HOME_PATH_PATTERNS) {
result = result.replace(pattern.regex, pattern.replace); result = result.replace(pattern.regex, pattern.replace);
@@ -31,12 +42,12 @@ export function redactHomePathUserSegments(text: string): string {
return result; return result;
} }
export function redactHomePathUserSegmentsInValue<T>(value: T): T { export function redactHomePathUserSegmentsInValue<T>(value: T, opts?: HomePathRedactionOptions): T {
if (typeof value === "string") { if (typeof value === "string") {
return redactHomePathUserSegments(value) as T; return redactHomePathUserSegments(value, opts) as T;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T; return value.map((entry) => redactHomePathUserSegmentsInValue(entry, opts)) as T;
} }
if (!isPlainObject(value)) { if (!isPlainObject(value)) {
return value; return value;
@@ -44,12 +55,12 @@ export function redactHomePathUserSegmentsInValue<T>(value: T): T {
const redacted: Record<string, unknown> = {}; const redacted: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) { for (const [key, entry] of Object.entries(value)) {
redacted[key] = redactHomePathUserSegmentsInValue(entry); redacted[key] = redactHomePathUserSegmentsInValue(entry, opts);
} }
return redacted as T; return redacted as T;
} }
export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry { export function redactTranscriptEntryPaths(entry: TranscriptEntry, opts?: HomePathRedactionOptions): TranscriptEntry {
switch (entry.kind) { switch (entry.kind) {
case "assistant": case "assistant":
case "thinking": case "thinking":
@@ -57,23 +68,27 @@ export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEn
case "stderr": case "stderr":
case "system": case "system":
case "stdout": case "stdout":
return { ...entry, text: redactHomePathUserSegments(entry.text) }; return { ...entry, text: redactHomePathUserSegments(entry.text, opts) };
case "tool_call": case "tool_call":
return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) }; return {
...entry,
name: redactHomePathUserSegments(entry.name, opts),
input: redactHomePathUserSegmentsInValue(entry.input, opts),
};
case "tool_result": case "tool_result":
return { ...entry, content: redactHomePathUserSegments(entry.content) }; return { ...entry, content: redactHomePathUserSegments(entry.content, opts) };
case "init": case "init":
return { return {
...entry, ...entry,
model: redactHomePathUserSegments(entry.model), model: redactHomePathUserSegments(entry.model, opts),
sessionId: redactHomePathUserSegments(entry.sessionId), sessionId: redactHomePathUserSegments(entry.sessionId, opts),
}; };
case "result": case "result":
return { return {
...entry, ...entry,
text: redactHomePathUserSegments(entry.text), text: redactHomePathUserSegments(entry.text, opts),
subtype: redactHomePathUserSegments(entry.subtype), subtype: redactHomePathUserSegments(entry.subtype, opts),
errors: entry.errors.map((error) => redactHomePathUserSegments(error)), errors: entry.errors.map((error) => redactHomePathUserSegments(error, opts)),
}; };
default: default:
return entry; return entry;

View File

@@ -1,8 +1,4 @@
import { import { type TranscriptEntry } from "@paperclipai/adapter-utils";
redactHomePathUserSegments,
redactHomePathUserSegmentsInValue,
type TranscriptEntry,
} from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown { function safeJsonParse(text: string): unknown {
try { try {
@@ -43,12 +39,12 @@ function errorText(value: unknown): string {
} }
function stringifyUnknown(value: unknown): string { function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return redactHomePathUserSegments(value); if (typeof value === "string") return value;
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
try { try {
return JSON.stringify(redactHomePathUserSegmentsInValue(value), null, 2); return JSON.stringify(value, null, 2);
} catch { } catch {
return redactHomePathUserSegments(String(value)); return String(value);
} }
} }
@@ -61,8 +57,8 @@ function parseCommandExecutionItem(
const command = asString(item.command); const command = asString(item.command);
const status = asString(item.status); const status = asString(item.status);
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null; const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
const safeCommand = redactHomePathUserSegments(command); const safeCommand = command;
const output = redactHomePathUserSegments(asString(item.aggregated_output)).replace(/\s+$/, ""); const output = asString(item.aggregated_output).replace(/\s+$/, "");
if (phase === "started") { if (phase === "started") {
return [{ return [{
@@ -109,7 +105,7 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
.filter((change): change is Record<string, unknown> => Boolean(change)) .filter((change): change is Record<string, unknown> => Boolean(change))
.map((change) => { .map((change) => {
const kind = asString(change.kind, "update"); const kind = asString(change.kind, "update");
const path = redactHomePathUserSegments(asString(change.path, "unknown")); const path = asString(change.path, "unknown");
return `${kind} ${path}`; return `${kind} ${path}`;
}); });
@@ -131,13 +127,13 @@ function parseCodexItem(
if (itemType === "agent_message") { if (itemType === "agent_message") {
const text = asString(item.text); const text = asString(item.text);
if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }]; if (text) return [{ kind: "assistant", ts, text }];
return []; return [];
} }
if (itemType === "reasoning") { if (itemType === "reasoning") {
const text = asString(item.text); const text = asString(item.text);
if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }]; if (text) return [{ kind: "thinking", ts, text }];
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }]; return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
} }
@@ -153,9 +149,9 @@ function parseCodexItem(
return [{ return [{
kind: "tool_call", kind: "tool_call",
ts, ts,
name: redactHomePathUserSegments(asString(item.name, "unknown")), name: asString(item.name, "unknown"),
toolUseId: asString(item.id), toolUseId: asString(item.id),
input: redactHomePathUserSegmentsInValue(item.input ?? {}), input: item.input ?? {},
}]; }];
} }
@@ -167,12 +163,12 @@ function parseCodexItem(
asString(item.result) || asString(item.result) ||
stringifyUnknown(item.content ?? item.output ?? item.result); stringifyUnknown(item.content ?? item.output ?? item.result);
const isError = item.is_error === true || asString(item.status) === "error"; const isError = item.is_error === true || asString(item.status) === "error";
return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }]; return [{ kind: "tool_result", ts, toolUseId, content, isError }];
} }
if (itemType === "error" && phase === "completed") { if (itemType === "error" && phase === "completed") {
const text = errorText(item.message ?? item.error ?? item); const text = errorText(item.message ?? item.error ?? item);
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }]; return [{ kind: "stderr", ts, text: text || "error" }];
} }
const id = asString(item.id); const id = asString(item.id);
@@ -181,14 +177,14 @@ function parseCodexItem(
return [{ return [{
kind: "system", kind: "system",
ts, ts,
text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`), text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`,
}]; }];
} }
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] { export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line)); const parsed = asRecord(safeJsonParse(line));
if (!parsed) { if (!parsed) {
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }]; return [{ kind: "stdout", ts, text: line }];
} }
const type = asString(parsed.type); const type = asString(parsed.type);
@@ -198,8 +194,8 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{ return [{
kind: "init", kind: "init",
ts, ts,
model: redactHomePathUserSegments(asString(parsed.model, "codex")), model: asString(parsed.model, "codex"),
sessionId: redactHomePathUserSegments(threadId), sessionId: threadId,
}]; }];
} }
@@ -221,15 +217,15 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{ return [{
kind: "result", kind: "result",
ts, ts,
text: redactHomePathUserSegments(asString(parsed.result)), text: asString(parsed.result),
inputTokens, inputTokens,
outputTokens, outputTokens,
cachedTokens, cachedTokens,
costUsd: asNumber(parsed.total_cost_usd), costUsd: asNumber(parsed.total_cost_usd),
subtype: redactHomePathUserSegments(asString(parsed.subtype)), subtype: asString(parsed.subtype),
isError: parsed.is_error === true, isError: parsed.is_error === true,
errors: Array.isArray(parsed.errors) errors: Array.isArray(parsed.errors)
? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean) ? parsed.errors.map(errorText).filter(Boolean)
: [], : [],
}]; }];
} }
@@ -243,21 +239,21 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{ return [{
kind: "result", kind: "result",
ts, ts,
text: redactHomePathUserSegments(asString(parsed.result)), text: asString(parsed.result),
inputTokens, inputTokens,
outputTokens, outputTokens,
cachedTokens, cachedTokens,
costUsd: asNumber(parsed.total_cost_usd), costUsd: asNumber(parsed.total_cost_usd),
subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")), subtype: asString(parsed.subtype, "turn.failed"),
isError: true, isError: true,
errors: message ? [redactHomePathUserSegments(message)] : [], errors: message ? [message] : [],
}]; }];
} }
if (type === "error") { if (type === "error") {
const message = errorText(parsed.message ?? parsed.error ?? parsed); const message = errorText(parsed.message ?? parsed.error ?? parsed);
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }]; return [{ kind: "stderr", ts, text: message || line }];
} }
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }]; return [{ kind: "stdout", ts, text: line }];
} }

View File

@@ -0,0 +1 @@
ALTER TABLE "instance_settings" ADD COLUMN "general" jsonb DEFAULT '{}'::jsonb NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -274,6 +274,13 @@
"when": 1773931592563, "when": 1773931592563,
"tag": "0038_careless_iron_monger", "tag": "0038_careless_iron_monger",
"breakpoints": true "breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1774011294562,
"tag": "0039_curly_maria_hill",
"breakpoints": true
} }
] ]
} }

View File

@@ -5,6 +5,7 @@ export const instanceSettings = pgTable(
{ {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
singletonKey: text("singleton_key").notNull().default("default"), singletonKey: text("singleton_key").notNull().default("default"),
general: jsonb("general").$type<Record<string, unknown>>().notNull().default({}),
experimental: jsonb("experimental").$type<Record<string, unknown>>().notNull().default({}), experimental: jsonb("experimental").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),

View File

@@ -121,6 +121,7 @@ export {
export type { export type {
Company, Company,
InstanceExperimentalSettings, InstanceExperimentalSettings,
InstanceGeneralSettings,
InstanceSettings, InstanceSettings,
Agent, Agent,
AgentAccessState, AgentAccessState,
@@ -248,6 +249,9 @@ export type {
} from "./types/index.js"; } from "./types/index.js";
export { export {
instanceGeneralSettingsSchema,
patchInstanceGeneralSettingsSchema,
type PatchInstanceGeneralSettings,
instanceExperimentalSettingsSchema, instanceExperimentalSettingsSchema,
patchInstanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSchema,
type PatchInstanceExperimentalSettings, type PatchInstanceExperimentalSettings,

View File

@@ -1,5 +1,5 @@
export type { Company } from "./company.js"; export type { Company } from "./company.js";
export type { InstanceExperimentalSettings, InstanceSettings } from "./instance.js"; export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings } from "./instance.js";
export type { export type {
Agent, Agent,
AgentAccessState, AgentAccessState,

View File

@@ -1,9 +1,15 @@
export interface InstanceGeneralSettings {
censorUsernameInLogs: boolean;
}
export interface InstanceExperimentalSettings { export interface InstanceExperimentalSettings {
enableIsolatedWorkspaces: boolean; enableIsolatedWorkspaces: boolean;
autoRestartDevServerWhenIdle: boolean;
} }
export interface InstanceSettings { export interface InstanceSettings {
id: string; id: string;
general: InstanceGeneralSettings;
experimental: InstanceExperimentalSettings; experimental: InstanceExperimentalSettings;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;

View File

@@ -1,4 +1,8 @@
export { export {
instanceGeneralSettingsSchema,
patchInstanceGeneralSettingsSchema,
type InstanceGeneralSettings,
type PatchInstanceGeneralSettings,
instanceExperimentalSettingsSchema, instanceExperimentalSettingsSchema,
patchInstanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSchema,
type InstanceExperimentalSettings, type InstanceExperimentalSettings,

View File

@@ -1,10 +1,19 @@
import { z } from "zod"; import { z } from "zod";
export const instanceGeneralSettingsSchema = z.object({
censorUsernameInLogs: z.boolean().default(false),
}).strict();
export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial();
export const instanceExperimentalSettingsSchema = z.object({ export const instanceExperimentalSettingsSchema = z.object({
enableIsolatedWorkspaces: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false),
}).strict(); }).strict();
export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial(); export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial();
export type InstanceGeneralSettings = z.infer<typeof instanceGeneralSettingsSchema>;
export type PatchInstanceGeneralSettings = z.infer<typeof patchInstanceGeneralSettingsSchema>;
export type InstanceExperimentalSettings = z.infer<typeof instanceExperimentalSettingsSchema>; export type InstanceExperimentalSettings = z.infer<typeof instanceExperimentalSettingsSchema>;
export type PatchInstanceExperimentalSettings = z.infer<typeof patchInstanceExperimentalSettingsSchema>; export type PatchInstanceExperimentalSettings = z.infer<typeof patchInstanceExperimentalSettingsSchema>;

View File

@@ -1,10 +1,54 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process"; import { stdin, stdout } from "node:process";
import { fileURLToPath } from "node:url";
const mode = process.argv[2] === "watch" ? "watch" : "dev"; const mode = process.argv[2] === "watch" ? "watch" : "dev";
const cliArgs = process.argv.slice(3); const cliArgs = process.argv.slice(3);
const scanIntervalMs = 1500;
const autoRestartPollIntervalMs = 2500;
const gracefulShutdownTimeoutMs = 10_000;
const changedPathSampleLimit = 5;
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
const watchedDirectories = [
".paperclip",
"cli",
"scripts",
"server",
"packages/adapter-utils",
"packages/adapters",
"packages/db",
"packages/plugins/sdk",
"packages/shared",
].map((relativePath) => path.join(repoRoot, relativePath));
const watchedFiles = [
".env",
"package.json",
"pnpm-workspace.yaml",
"tsconfig.base.json",
"tsconfig.json",
"vitest.config.ts",
].map((relativePath) => path.join(repoRoot, relativePath));
const ignoredDirectoryNames = new Set([
".git",
".turbo",
".vite",
"coverage",
"dist",
"node_modules",
"ui-dist",
]);
const ignoredRelativePaths = new Set([
".paperclip/dev-server-status.json",
]);
const tailscaleAuthFlagNames = new Set([ const tailscaleAuthFlagNames = new Set([
"--tailscale-auth", "--tailscale-auth",
@@ -34,6 +78,10 @@ const env = {
PAPERCLIP_UI_DEV_MIDDLEWARE: "true", PAPERCLIP_UI_DEV_MIDDLEWARE: "true",
}; };
if (mode === "dev") {
env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath;
}
if (mode === "watch") { if (mode === "watch") {
env.PAPERCLIP_MIGRATION_PROMPT ??= "never"; env.PAPERCLIP_MIGRATION_PROMPT ??= "never";
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
@@ -50,6 +98,19 @@ if (tailscaleAuth) {
} }
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
let previousSnapshot = collectWatchedSnapshot();
let dirtyPaths = new Set();
let pendingMigrations = [];
let lastChangedAt = null;
let lastRestartAt = null;
let scanInFlight = false;
let restartInFlight = false;
let shuttingDown = false;
let childExitWasExpected = false;
let child = null;
let childExitPromise = null;
let scanTimer = null;
let autoRestartTimer = null;
function toError(error, context = "Dev runner command failed") { function toError(error, context = "Dev runner command failed") {
if (error instanceof Error) return error; if (error instanceof Error) return error;
@@ -82,9 +143,110 @@ function formatPendingMigrationSummary(migrations) {
: migrations.join(", "); : migrations.join(", ");
} }
function exitForSignal(signal) {
if (signal === "SIGINT") {
process.exit(130);
}
if (signal === "SIGTERM") {
process.exit(143);
}
process.exit(1);
}
function toRelativePath(absolutePath) {
return path.relative(repoRoot, absolutePath).split(path.sep).join("/");
}
function readSignature(absolutePath) {
const stats = statSync(absolutePath);
return `${Math.trunc(stats.mtimeMs)}:${stats.size}`;
}
function addFileToSnapshot(snapshot, absolutePath) {
const relativePath = toRelativePath(absolutePath);
if (ignoredRelativePaths.has(relativePath)) return;
snapshot.set(relativePath, readSignature(absolutePath));
}
function walkDirectory(snapshot, absoluteDirectory) {
if (!existsSync(absoluteDirectory)) return;
for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) {
if (ignoredDirectoryNames.has(entry.name)) continue;
const absolutePath = path.join(absoluteDirectory, entry.name);
if (entry.isDirectory()) {
walkDirectory(snapshot, absolutePath);
continue;
}
if (entry.isFile() || entry.isSymbolicLink()) {
addFileToSnapshot(snapshot, absolutePath);
}
}
}
function collectWatchedSnapshot() {
const snapshot = new Map();
for (const absoluteDirectory of watchedDirectories) {
walkDirectory(snapshot, absoluteDirectory);
}
for (const absoluteFile of watchedFiles) {
if (!existsSync(absoluteFile)) continue;
addFileToSnapshot(snapshot, absoluteFile);
}
return snapshot;
}
function diffSnapshots(previous, next) {
const changed = new Set();
for (const [relativePath, signature] of next) {
if (previous.get(relativePath) !== signature) {
changed.add(relativePath);
}
}
for (const relativePath of previous.keys()) {
if (!next.has(relativePath)) {
changed.add(relativePath);
}
}
return [...changed].sort();
}
function ensureDevStatusDirectory() {
mkdirSync(path.dirname(devServerStatusFilePath), { recursive: true });
}
function writeDevServerStatus() {
if (mode !== "dev") return;
ensureDevStatusDirectory();
const changedPaths = [...dirtyPaths].sort();
writeFileSync(
devServerStatusFilePath,
`${JSON.stringify({
dirty: changedPaths.length > 0 || pendingMigrations.length > 0,
lastChangedAt,
changedPathCount: changedPaths.length,
changedPathsSample: changedPaths.slice(0, changedPathSampleLimit),
pendingMigrations,
lastRestartAt,
}, null, 2)}\n`,
"utf8",
);
}
function clearDevServerStatus() {
if (mode !== "dev") return;
rmSync(devServerStatusFilePath, { force: true });
}
async function runPnpm(args, options = {}) { async function runPnpm(args, options = {}) {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const child = spawn(pnpmBin, args, { const spawned = spawn(pnpmBin, args, {
stdio: options.stdio ?? ["ignore", "pipe", "pipe"], stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
env: options.env ?? process.env, env: options.env ?? process.env,
shell: process.platform === "win32", shell: process.platform === "win32",
@@ -93,19 +255,19 @@ async function runPnpm(args, options = {}) {
let stdoutBuffer = ""; let stdoutBuffer = "";
let stderrBuffer = ""; let stderrBuffer = "";
if (child.stdout) { if (spawned.stdout) {
child.stdout.on("data", (chunk) => { spawned.stdout.on("data", (chunk) => {
stdoutBuffer += String(chunk); stdoutBuffer += String(chunk);
}); });
} }
if (child.stderr) { if (spawned.stderr) {
child.stderr.on("data", (chunk) => { spawned.stderr.on("data", (chunk) => {
stderrBuffer += String(chunk); stderrBuffer += String(chunk);
}); });
} }
child.on("error", reject); spawned.on("error", reject);
child.on("exit", (code, signal) => { spawned.on("exit", (code, signal) => {
resolve({ resolve({
code: code ?? 0, code: code ?? 0,
signal, signal,
@@ -116,9 +278,7 @@ async function runPnpm(args, options = {}) {
}); });
} }
async function maybePreflightMigrations() { async function getMigrationStatusPayload() {
if (mode !== "watch") return;
const status = await runPnpm( const status = await runPnpm(
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"], ["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
{ env }, { env },
@@ -132,9 +292,8 @@ async function maybePreflightMigrations() {
process.exit(status.code); process.exit(status.code);
} }
let payload;
try { try {
payload = JSON.parse(status.stdout.trim()); return JSON.parse(status.stdout.trim());
} catch (error) { } catch (error) {
process.stderr.write( process.stderr.write(
status.stderr || status.stderr ||
@@ -143,15 +302,31 @@ async function maybePreflightMigrations() {
); );
throw toError(error, "Unable to parse migration-status JSON output"); throw toError(error, "Unable to parse migration-status JSON output");
} }
}
if (payload.status !== "needsMigrations" || payload.pendingMigrations.length === 0) { async function refreshPendingMigrations() {
const payload = await getMigrationStatusPayload();
pendingMigrations =
payload.status === "needsMigrations" && Array.isArray(payload.pendingMigrations)
? payload.pendingMigrations.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
: [];
writeDevServerStatus();
return payload;
}
async function maybePreflightMigrations(options = {}) {
const interactive = options.interactive ?? mode === "watch";
const autoApply = options.autoApply ?? env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
const exitOnDecline = options.exitOnDecline ?? mode === "watch";
const payload = await refreshPendingMigrations();
if (payload.status !== "needsMigrations" || pendingMigrations.length === 0) {
return; return;
} }
const autoApply = env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
let shouldApply = autoApply; let shouldApply = autoApply;
if (!autoApply) { if (!autoApply && interactive) {
if (!stdin.isTTY || !stdout.isTTY) { if (!stdin.isTTY || !stdout.isTTY) {
shouldApply = true; shouldApply = true;
} else { } else {
@@ -159,7 +334,7 @@ async function maybePreflightMigrations() {
try { try {
const answer = ( const answer = (
await prompt.question( await prompt.question(
`Apply pending migrations (${formatPendingMigrationSummary(payload.pendingMigrations)}) now? (y/N): `, `Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `,
) )
) )
.trim() .trim()
@@ -172,11 +347,14 @@ async function maybePreflightMigrations() {
} }
if (!shouldApply) { if (!shouldApply) {
process.stderr.write( if (exitOnDecline) {
`[paperclip] Pending migrations detected (${formatPendingMigrationSummary(payload.pendingMigrations)}). ` + process.stderr.write(
"Refusing to start watch mode against a stale schema.\n", `[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). ` +
); "Refusing to start watch mode against a stale schema.\n",
process.exit(1); );
process.exit(1);
}
return;
} }
const migrate = spawn(pnpmBin, ["db:migrate"], { const migrate = spawn(pnpmBin, ["db:migrate"], {
@@ -188,15 +366,15 @@ async function maybePreflightMigrations() {
migrate.on("exit", (code, signal) => resolve({ code: code ?? 0, signal })); migrate.on("exit", (code, signal) => resolve({ code: code ?? 0, signal }));
}); });
if (exit.signal) { if (exit.signal) {
process.kill(process.pid, exit.signal); exitForSignal(exit.signal);
return; return;
} }
if (exit.code !== 0) { if (exit.code !== 0) {
process.exit(exit.code); process.exit(exit.code);
} }
}
await maybePreflightMigrations(); await refreshPendingMigrations();
}
async function buildPluginSdk() { async function buildPluginSdk() {
console.log("[paperclip] building plugin sdk..."); console.log("[paperclip] building plugin sdk...");
@@ -205,7 +383,7 @@ async function buildPluginSdk() {
{ stdio: "inherit" }, { stdio: "inherit" },
); );
if (result.signal) { if (result.signal) {
process.kill(process.pid, result.signal); exitForSignal(result.signal);
return; return;
} }
if (result.code !== 0) { if (result.code !== 0) {
@@ -214,19 +392,199 @@ async function buildPluginSdk() {
} }
} }
await buildPluginSdk(); async function markChildAsCurrent() {
previousSnapshot = collectWatchedSnapshot();
dirtyPaths = new Set();
lastChangedAt = null;
lastRestartAt = new Date().toISOString();
await refreshPendingMigrations();
}
const serverScript = mode === "watch" ? "dev:watch" : "dev"; async function scanForBackendChanges() {
const child = spawn( if (mode !== "dev" || scanInFlight || restartInFlight) return;
pnpmBin, scanInFlight = true;
["--filter", "@paperclipai/server", serverScript, ...forwardedArgs], try {
{ stdio: "inherit", env, shell: process.platform === "win32" }, const nextSnapshot = collectWatchedSnapshot();
); const changed = diffSnapshots(previousSnapshot, nextSnapshot);
previousSnapshot = nextSnapshot;
if (changed.length === 0) return;
child.on("exit", (code, signal) => { for (const relativePath of changed) {
if (signal) { dirtyPaths.add(relativePath);
process.kill(process.pid, signal); }
lastChangedAt = new Date().toISOString();
await refreshPendingMigrations();
} finally {
scanInFlight = false;
}
}
async function getDevHealthPayload() {
const serverPort = env.PORT ?? process.env.PORT ?? "3100";
const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`);
if (!response.ok) {
throw new Error(`Health request failed (${response.status})`);
}
return await response.json();
}
async function waitForChildExit() {
if (!childExitPromise) {
return { code: 0, signal: null };
}
return await childExitPromise;
}
async function stopChildForRestart() {
if (!child) return { code: 0, signal: null };
childExitWasExpected = true;
child.kill("SIGTERM");
const killTimer = setTimeout(() => {
if (child) {
child.kill("SIGKILL");
}
}, gracefulShutdownTimeoutMs);
try {
return await waitForChildExit();
} finally {
clearTimeout(killTimer);
}
}
async function startServerChild() {
await buildPluginSdk();
const serverScript = mode === "watch" ? "dev:watch" : "dev";
child = spawn(
pnpmBin,
["--filter", "@paperclipai/server", serverScript, ...forwardedArgs],
{ stdio: "inherit", env, shell: process.platform === "win32" },
);
childExitPromise = new Promise((resolve, reject) => {
child.on("error", reject);
child.on("exit", (code, signal) => {
const expected = childExitWasExpected;
childExitWasExpected = false;
child = null;
childExitPromise = null;
resolve({ code: code ?? 0, signal });
if (restartInFlight || expected || shuttingDown) {
return;
}
if (signal) {
exitForSignal(signal);
return;
}
process.exit(code ?? 0);
});
});
await markChildAsCurrent();
}
async function maybeAutoRestartChild() {
if (mode !== "dev" || restartInFlight || !child) return;
if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return;
restartInFlight = true;
let health;
try {
health = await getDevHealthPayload();
} catch {
restartInFlight = false;
return; return;
} }
process.exit(code ?? 0);
const devServer = health?.devServer;
if (!devServer?.enabled || devServer.autoRestartEnabled !== true) {
restartInFlight = false;
return;
}
if ((devServer.activeRunCount ?? 0) > 0) {
restartInFlight = false;
return;
}
try {
await maybePreflightMigrations({
autoApply: true,
interactive: false,
exitOnDecline: false,
});
await stopChildForRestart();
await startServerChild();
} catch (error) {
const err = toError(error, "Auto-restart failed");
process.stderr.write(`${err.stack ?? err.message}\n`);
process.exit(1);
} finally {
restartInFlight = false;
}
}
function installDevIntervals() {
if (mode !== "dev") return;
scanTimer = setInterval(() => {
void scanForBackendChanges();
}, scanIntervalMs);
autoRestartTimer = setInterval(() => {
void maybeAutoRestartChild();
}, autoRestartPollIntervalMs);
}
function clearDevIntervals() {
if (scanTimer) {
clearInterval(scanTimer);
scanTimer = null;
}
if (autoRestartTimer) {
clearInterval(autoRestartTimer);
autoRestartTimer = null;
}
}
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
clearDevIntervals();
clearDevServerStatus();
if (!child) {
if (signal) {
exitForSignal(signal);
return;
}
process.exit(0);
}
childExitWasExpected = true;
child.kill(signal);
const exit = await waitForChildExit();
if (exit.signal) {
exitForSignal(exit.signal);
return;
}
process.exit(exit.code ?? 0);
}
process.on("SIGINT", () => {
void shutdown("SIGINT");
}); });
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});
await maybePreflightMigrations();
await startServerChild();
installDevIntervals();
if (mode === "watch") {
const exit = await waitForChildExit();
if (exit.signal) {
exitForSignal(exit.signal);
}
process.exit(exit.code ?? 0);
}

View File

@@ -117,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
{ {
kind: "system", kind: "system",
ts, ts,
text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx", text: "file changes: update /Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx",
}, },
]); ]);
}); });

View File

@@ -0,0 +1,66 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
const tempDirs = [];
function createTempStatusFile(payload: unknown) {
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-status-"));
tempDirs.push(dir);
const filePath = path.join(dir, "dev-server-status.json");
writeFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8");
return filePath;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("dev server status helpers", () => {
it("reads and normalizes persisted supervisor state", () => {
const filePath = createTempStatusFile({
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 4,
changedPathsSample: ["server/src/app.ts", "packages/shared/src/index.ts"],
pendingMigrations: ["0040_restart_banner.sql"],
lastRestartAt: "2026-03-20T11:30:00.000Z",
});
expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toEqual({
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 4,
changedPathsSample: ["server/src/app.ts", "packages/shared/src/index.ts"],
pendingMigrations: ["0040_restart_banner.sql"],
lastRestartAt: "2026-03-20T11:30:00.000Z",
});
});
it("derives waiting-for-idle health state", () => {
const health = toDevServerHealthStatus(
{
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 2,
changedPathsSample: ["server/src/app.ts"],
pendingMigrations: [],
lastRestartAt: "2026-03-20T11:30:00.000Z",
},
{ autoRestartEnabled: true, activeRunCount: 3 },
);
expect(health).toMatchObject({
enabled: true,
restartRequired: true,
reason: "backend_changes",
autoRestartEnabled: true,
activeRunCount: 3,
waitingForIdle: true,
});
});
});

View File

@@ -5,7 +5,9 @@ import { errorHandler } from "../middleware/index.js";
import { instanceSettingsRoutes } from "../routes/instance-settings.js"; import { instanceSettingsRoutes } from "../routes/instance-settings.js";
const mockInstanceSettingsService = vi.hoisted(() => ({ const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(),
getExperimental: vi.fn(), getExperimental: vi.fn(),
updateGeneral: vi.fn(),
updateExperimental: vi.fn(), updateExperimental: vi.fn(),
listCompanyIds: vi.fn(), listCompanyIds: vi.fn(),
})); }));
@@ -31,13 +33,24 @@ function createApp(actor: any) {
describe("instance settings routes", () => { describe("instance settings routes", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({ mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false, enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
});
mockInstanceSettingsService.updateGeneral.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: true,
},
}); });
mockInstanceSettingsService.updateExperimental.mockResolvedValue({ mockInstanceSettingsService.updateExperimental.mockResolvedValue({
id: "instance-settings-1", id: "instance-settings-1",
experimental: { experimental: {
enableIsolatedWorkspaces: true, enableIsolatedWorkspaces: true,
autoRestartDevServerWhenIdle: false,
}, },
}); });
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
@@ -53,7 +66,10 @@ describe("instance settings routes", () => {
const getRes = await request(app).get("/api/instance/settings/experimental"); const getRes = await request(app).get("/api/instance/settings/experimental");
expect(getRes.status).toBe(200); expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ enableIsolatedWorkspaces: false }); expect(getRes.body).toEqual({
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
});
const patchRes = await request(app) const patchRes = await request(app)
.patch("/api/instance/settings/experimental") .patch("/api/instance/settings/experimental")
@@ -66,6 +82,47 @@ describe("instance settings routes", () => {
expect(mockLogActivity).toHaveBeenCalledTimes(2); expect(mockLogActivity).toHaveBeenCalledTimes(2);
}); });
it("allows local board users to update guarded dev-server auto-restart", async () => {
const app = createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
await request(app)
.patch("/api/instance/settings/experimental")
.send({ autoRestartDevServerWhenIdle: true })
.expect(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
autoRestartDevServerWhenIdle: true,
});
});
it("allows local board users to read and update general settings", async () => {
const app = createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
const getRes = await request(app).get("/api/instance/settings/general");
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ censorUsernameInLogs: false });
const patchRes = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(patchRes.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: true,
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("rejects non-admin board users", async () => { it("rejects non-admin board users", async () => {
const app = createApp({ const app = createApp({
type: "board", type: "board",
@@ -75,10 +132,10 @@ describe("instance settings routes", () => {
companyIds: ["company-1"], companyIds: ["company-1"],
}); });
const res = await request(app).get("/api/instance/settings/experimental"); const res = await request(app).get("/api/instance/settings/general");
expect(res.status).toBe(403); expect(res.status).toBe(403);
expect(mockInstanceSettingsService.getExperimental).not.toHaveBeenCalled(); expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
}); });
it("rejects agent callers", async () => { it("rejects agent callers", async () => {
@@ -90,10 +147,10 @@ describe("instance settings routes", () => {
}); });
const res = await request(app) const res = await request(app)
.patch("/api/instance/settings/experimental") .patch("/api/instance/settings/general")
.send({ enableIsolatedWorkspaces: true }); .send({ censorUsernameInLogs: true });
expect(res.status).toBe(403); expect(res.status).toBe(403);
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
CURRENT_USER_REDACTION_TOKEN, maskUserNameForLogs,
redactCurrentUserText, redactCurrentUserText,
redactCurrentUserValue, redactCurrentUserValue,
} from "../log-redaction.js"; } from "../log-redaction.js";
@@ -8,6 +8,7 @@ import {
describe("log redaction", () => { describe("log redaction", () => {
it("redacts the active username inside home-directory paths", () => { it("redacts the active username inside home-directory paths", () => {
const userName = "paperclipuser"; const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const input = [ const input = [
`cwd=/Users/${userName}/paperclip`, `cwd=/Users/${userName}/paperclip`,
`home=/home/${userName}/workspace`, `home=/home/${userName}/workspace`,
@@ -19,14 +20,15 @@ describe("log redaction", () => {
homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`], homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`],
}); });
expect(result).toContain(`cwd=/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`); expect(result).toContain(`cwd=/Users/${maskedUserName}/paperclip`);
expect(result).toContain(`home=/home/${CURRENT_USER_REDACTION_TOKEN}/workspace`); expect(result).toContain(`home=/home/${maskedUserName}/workspace`);
expect(result).toContain(`win=C:\\Users\\${CURRENT_USER_REDACTION_TOKEN}\\paperclip`); expect(result).toContain(`win=C:\\Users\\${maskedUserName}\\paperclip`);
expect(result).not.toContain(userName); expect(result).not.toContain(userName);
}); });
it("redacts standalone username mentions without mangling larger tokens", () => { it("redacts standalone username mentions without mangling larger tokens", () => {
const userName = "paperclipuser"; const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const result = redactCurrentUserText( const result = redactCurrentUserText(
`user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`, `user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`,
{ {
@@ -36,12 +38,13 @@ describe("log redaction", () => {
); );
expect(result).toBe( expect(result).toBe(
`user ${CURRENT_USER_REDACTION_TOKEN} said ${CURRENT_USER_REDACTION_TOKEN}/project should stay but apaperclipuserz should not change`, `user ${maskedUserName} said ${maskedUserName}/project should stay but apaperclipuserz should not change`,
); );
}); });
it("recursively redacts nested event payloads", () => { it("recursively redacts nested event payloads", () => {
const userName = "paperclipuser"; const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const result = redactCurrentUserValue({ const result = redactCurrentUserValue({
cwd: `/Users/${userName}/paperclip`, cwd: `/Users/${userName}/paperclip`,
prompt: `open /Users/${userName}/paperclip/ui`, prompt: `open /Users/${userName}/paperclip/ui`,
@@ -55,12 +58,17 @@ describe("log redaction", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
cwd: `/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`, cwd: `/Users/${maskedUserName}/paperclip`,
prompt: `open /Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip/ui`, prompt: `open /Users/${maskedUserName}/paperclip/ui`,
nested: { nested: {
author: CURRENT_USER_REDACTION_TOKEN, author: maskedUserName,
}, },
values: [CURRENT_USER_REDACTION_TOKEN, `/home/${CURRENT_USER_REDACTION_TOKEN}/project`], values: [maskedUserName, `/home/${maskedUserName}/project`],
}); });
}); });
it("skips redaction when disabled", () => {
const input = "cwd=/Users/paperclipuser/paperclip";
expect(redactCurrentUserText(input, { enabled: false })).toBe(input);
});
}); });

View File

@@ -0,0 +1,103 @@
import { existsSync, readFileSync } from "node:fs";
export type PersistedDevServerStatus = {
dirty: boolean;
lastChangedAt: string | null;
changedPathCount: number;
changedPathsSample: string[];
pendingMigrations: string[];
lastRestartAt: string | null;
};
export type DevServerHealthStatus = {
enabled: true;
restartRequired: boolean;
reason: "backend_changes" | "pending_migrations" | "backend_changes_and_pending_migrations" | null;
lastChangedAt: string | null;
changedPathCount: number;
changedPathsSample: string[];
pendingMigrations: string[];
autoRestartEnabled: boolean;
activeRunCount: number;
waitingForIdle: boolean;
lastRestartAt: string | null;
};
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function normalizeTimestamp(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function readPersistedDevServerStatus(
env: NodeJS.ProcessEnv = process.env,
): PersistedDevServerStatus | null {
const filePath = env.PAPERCLIP_DEV_SERVER_STATUS_FILE?.trim();
if (!filePath || !existsSync(filePath)) return null;
try {
const raw = JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5);
const pendingMigrations = normalizeStringArray(raw.pendingMigrations);
const changedPathCountRaw = raw.changedPathCount;
const changedPathCount =
typeof changedPathCountRaw === "number" && Number.isFinite(changedPathCountRaw)
? Math.max(0, Math.trunc(changedPathCountRaw))
: changedPathsSample.length;
const dirtyRaw = raw.dirty;
const dirty =
typeof dirtyRaw === "boolean"
? dirtyRaw
: changedPathCount > 0 || pendingMigrations.length > 0;
return {
dirty,
lastChangedAt: normalizeTimestamp(raw.lastChangedAt),
changedPathCount,
changedPathsSample,
pendingMigrations,
lastRestartAt: normalizeTimestamp(raw.lastRestartAt),
};
} catch {
return null;
}
}
export function toDevServerHealthStatus(
persisted: PersistedDevServerStatus,
opts: { autoRestartEnabled: boolean; activeRunCount: number },
): DevServerHealthStatus {
const hasPathChanges = persisted.changedPathCount > 0;
const hasPendingMigrations = persisted.pendingMigrations.length > 0;
const reason =
hasPathChanges && hasPendingMigrations
? "backend_changes_and_pending_migrations"
: hasPendingMigrations
? "pending_migrations"
: hasPathChanges
? "backend_changes"
: null;
const restartRequired = persisted.dirty || reason !== null;
return {
enabled: true,
restartRequired,
reason,
lastChangedAt: persisted.lastChangedAt,
changedPathCount: persisted.changedPathCount,
changedPathsSample: persisted.changedPathsSample,
pendingMigrations: persisted.pendingMigrations,
autoRestartEnabled: opts.autoRestartEnabled,
activeRunCount: opts.activeRunCount,
waitingForIdle: restartRequired && opts.autoRestartEnabled && opts.activeRunCount > 0,
lastRestartAt: persisted.lastRestartAt,
};
}

View File

@@ -1,8 +1,9 @@
import os from "node:os"; import os from "node:os";
export const CURRENT_USER_REDACTION_TOKEN = "[]"; export const CURRENT_USER_REDACTION_TOKEN = "*";
interface CurrentUserRedactionOptions { export interface CurrentUserRedactionOptions {
enabled?: boolean;
replacement?: string; replacement?: string;
userNames?: string[]; userNames?: string[];
homeDirs?: string[]; homeDirs?: string[];
@@ -39,6 +40,12 @@ function replaceLastPathSegment(pathValue: string, replacement: string) {
return `${normalized.slice(0, lastSeparator + 1)}${replacement}`; return `${normalized.slice(0, lastSeparator + 1)}${replacement}`;
} }
export function maskUserNameForLogs(value: string, fallback = CURRENT_USER_REDACTION_TOKEN) {
const trimmed = value.trim();
if (!trimmed) return fallback;
return `${trimmed[0]}${"*".repeat(Math.max(1, Array.from(trimmed).length - 1))}`;
}
function defaultUserNames() { function defaultUserNames() {
const candidates = [ const candidates = [
process.env.USER, process.env.USER,
@@ -99,21 +106,22 @@ function resolveCurrentUserCandidates(opts?: CurrentUserRedactionOptions) {
export function redactCurrentUserText(input: string, opts?: CurrentUserRedactionOptions) { export function redactCurrentUserText(input: string, opts?: CurrentUserRedactionOptions) {
if (!input) return input; if (!input) return input;
if (opts?.enabled === false) return input;
const { userNames, homeDirs, replacement } = resolveCurrentUserCandidates(opts); const { userNames, homeDirs, replacement } = resolveCurrentUserCandidates(opts);
let result = input; let result = input;
for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) { for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) {
const lastSegment = splitPathSegments(homeDir).pop() ?? ""; const lastSegment = splitPathSegments(homeDir).pop() ?? "";
const replacementDir = userNames.includes(lastSegment) const replacementDir = lastSegment
? replaceLastPathSegment(homeDir, replacement) ? replaceLastPathSegment(homeDir, maskUserNameForLogs(lastSegment, replacement))
: replacement; : replacement;
result = result.split(homeDir).join(replacementDir); result = result.split(homeDir).join(replacementDir);
} }
for (const userName of [...userNames].sort((a, b) => b.length - a.length)) { for (const userName of [...userNames].sort((a, b) => b.length - a.length)) {
const pattern = new RegExp(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g"); const pattern = new RegExp(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g");
result = result.replace(pattern, replacement); result = result.replace(pattern, maskUserNameForLogs(userName, replacement));
} }
return result; return result;

View File

@@ -36,6 +36,7 @@ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js"; import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import { import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
@@ -64,8 +65,15 @@ export function agentRoutes(db: Db) {
const issueApprovalsSvc = issueApprovalService(db); const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db); const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db); const workspaceOperations = workspaceOperationService(db);
const instanceSettings = instanceSettingsService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function getCurrentUserRedactionOptions() {
return {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
}
function canCreateAgents(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) { function canCreateAgents(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
if (!agent.permissions || typeof agent.permissions !== "object") return false; if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents); return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
@@ -1597,7 +1605,7 @@ export function agentRoutes(db: Db) {
return; return;
} }
assertCompanyAccess(req, run.companyId); assertCompanyAccess(req, run.companyId);
res.json(redactCurrentUserValue(run)); res.json(redactCurrentUserValue(run, await getCurrentUserRedactionOptions()));
}); });
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
@@ -1632,11 +1640,12 @@ export function agentRoutes(db: Db) {
const afterSeq = Number(req.query.afterSeq ?? 0); const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200); const limit = Number(req.query.limit ?? 200);
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200); const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const redactedEvents = events.map((event) => const redactedEvents = events.map((event) =>
redactCurrentUserValue({ redactCurrentUserValue({
...event, ...event,
payload: redactEventPayload(event.payload), payload: redactEventPayload(event.payload),
}), }, currentUserRedactionOptions),
); );
res.json(redactedEvents); res.json(redactedEvents);
}); });
@@ -1672,7 +1681,7 @@ export function agentRoutes(db: Db) {
const context = asRecord(run.contextSnapshot); const context = asRecord(run.contextSnapshot);
const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId); const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId);
const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId); const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId);
res.json(redactCurrentUserValue(operations)); res.json(redactCurrentUserValue(operations, await getCurrentUserRedactionOptions()));
}); });
router.get("/workspace-operations/:operationId/log", async (req, res) => { router.get("/workspace-operations/:operationId/log", async (req, res) => {
@@ -1768,7 +1777,7 @@ export function agentRoutes(db: Db) {
} }
res.json({ res.json({
...redactCurrentUserValue(run), ...redactCurrentUserValue(run, await getCurrentUserRedactionOptions()),
agentId: agent.id, agentId: agent.id,
agentName: agent.name, agentName: agent.name,
adapterType: agent.adapterType, adapterType: agent.adapterType,

View File

@@ -1,8 +1,10 @@
import { Router } from "express"; import { Router } from "express";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { and, count, eq, gt, isNull, sql } from "drizzle-orm"; import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { instanceUserRoles, invites } from "@paperclipai/db"; import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { serverVersion } from "../version.js"; import { serverVersion } from "../version.js";
export function healthRoutes( export function healthRoutes(
@@ -55,6 +57,23 @@ export function healthRoutes(
} }
} }
const persistedDevServerStatus = readPersistedDevServerStatus();
let devServer: ReturnType<typeof toDevServerHealthStatus> | undefined;
if (persistedDevServerStatus) {
const instanceSettings = instanceSettingsService(db);
const experimentalSettings = await instanceSettings.getExperimental();
const activeRunCount = await db
.select({ count: count() })
.from(heartbeatRuns)
.where(inArray(heartbeatRuns.status, ["queued", "running"]))
.then((rows) => Number(rows[0]?.count ?? 0));
devServer = toDevServerHealthStatus(persistedDevServerStatus, {
autoRestartEnabled: experimentalSettings.autoRestartDevServerWhenIdle ?? false,
activeRunCount,
});
}
res.json({ res.json({
status: "ok", status: "ok",
version: serverVersion, version: serverVersion,
@@ -66,6 +85,7 @@ export function healthRoutes(
features: { features: {
companyDeletionEnabled: opts.companyDeletionEnabled, companyDeletionEnabled: opts.companyDeletionEnabled,
}, },
...(devServer ? { devServer } : {}),
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { Router, type Request } from "express"; import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { patchInstanceExperimentalSettingsSchema } from "@paperclipai/shared"; import { patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSchema } from "@paperclipai/shared";
import { forbidden } from "../errors.js"; import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js"; import { validate } from "../middleware/validate.js";
import { instanceSettingsService, logActivity } from "../services/index.js"; import { instanceSettingsService, logActivity } from "../services/index.js";
@@ -20,6 +20,41 @@ export function instanceSettingsRoutes(db: Db) {
const router = Router(); const router = Router();
const svc = instanceSettingsService(db); const svc = instanceSettingsService(db);
router.get("/instance/settings/general", async (req, res) => {
assertCanManageInstanceSettings(req);
res.json(await svc.getGeneral());
});
router.patch(
"/instance/settings/general",
validate(patchInstanceGeneralSettingsSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
const updated = await svc.updateGeneral(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
await Promise.all(
companyIds.map((companyId) =>
logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "instance.settings.general_updated",
entityType: "instance_settings",
entityId: updated.id,
details: {
general: updated.general,
changedKeys: Object.keys(req.body).sort(),
},
}),
),
);
res.json(updated.general);
},
);
router.get("/instance/settings/experimental", async (req, res) => { router.get("/instance/settings/experimental", async (req, res) => {
assertCanManageInstanceSettings(req); assertCanManageInstanceSettings(req);
res.json(await svc.getExperimental()); res.json(await svc.getExperimental());

View File

@@ -8,6 +8,7 @@ import { redactCurrentUserValue } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js"; import { sanitizeRecord } from "../redaction.js";
import { logger } from "../middleware/logger.js"; import { logger } from "../middleware/logger.js";
import type { PluginEventBus } from "./plugin-event-bus.js"; import type { PluginEventBus } from "./plugin-event-bus.js";
import { instanceSettingsService } from "./instance-settings.js";
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES); const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
@@ -34,8 +35,13 @@ export interface LogActivityInput {
} }
export async function logActivity(db: Db, input: LogActivityInput) { export async function logActivity(db: Db, input: LogActivityInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
};
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null; const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null; const redactedDetails = sanitizedDetails
? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions)
: null;
await db.insert(activityLog).values({ await db.insert(activityLog).values({
companyId: input.companyId, companyId: input.companyId,
actorType: input.actorType, actorType: input.actorType,

View File

@@ -6,22 +6,24 @@ import { redactCurrentUserText } from "../log-redaction.js";
import { agentService } from "./agents.js"; import { agentService } from "./agents.js";
import { budgetService } from "./budgets.js"; import { budgetService } from "./budgets.js";
import { notifyHireApproved } from "./hire-hook.js"; import { notifyHireApproved } from "./hire-hook.js";
import { instanceSettingsService } from "./instance-settings.js";
function redactApprovalComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
export function approvalService(db: Db) { export function approvalService(db: Db) {
const agentsSvc = agentService(db); const agentsSvc = agentService(db);
const budgets = budgetService(db); const budgets = budgetService(db);
const instanceSettings = instanceSettingsService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]); const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses); const resolvableStatuses = Array.from(canResolveStatuses);
type ApprovalRecord = typeof approvals.$inferSelect; type ApprovalRecord = typeof approvals.$inferSelect;
type ResolutionResult = { approval: ApprovalRecord; applied: boolean }; type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
function redactApprovalComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function getExistingApproval(id: string) { async function getExistingApproval(id: string) {
const existing = await db const existing = await db
.select() .select()
@@ -230,6 +232,7 @@ export function approvalService(db: Db) {
listComments: async (approvalId: string) => { listComments: async (approvalId: string) => {
const existing = await getExistingApproval(approvalId); const existing = await getExistingApproval(approvalId);
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return db return db
.select() .select()
.from(approvalComments) .from(approvalComments)
@@ -240,7 +243,7 @@ export function approvalService(db: Db) {
), ),
) )
.orderBy(asc(approvalComments.createdAt)) .orderBy(asc(approvalComments.createdAt))
.then((comments) => comments.map(redactApprovalComment)); .then((comments) => comments.map((comment) => redactApprovalComment(comment, censorUsernameInLogs)));
}, },
addComment: async ( addComment: async (
@@ -249,7 +252,10 @@ export function approvalService(db: Db) {
actor: { agentId?: string; userId?: string }, actor: { agentId?: string; userId?: string },
) => { ) => {
const existing = await getExistingApproval(approvalId); const existing = await getExistingApproval(approvalId);
const redactedBody = redactCurrentUserText(body); const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
return db return db
.insert(approvalComments) .insert(approvalComments)
.values({ .values({
@@ -260,7 +266,7 @@ export function approvalService(db: Db) {
body: redactedBody, body: redactedBody,
}) })
.returning() .returning()
.then((rows) => redactApprovalComment(rows[0])); .then((rows) => redactApprovalComment(rows[0], currentUserRedactionOptions.enabled));
}, },
}; };
} }

View File

@@ -720,6 +720,9 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) { export function heartbeatService(db: Db) {
const instanceSettings = instanceSettingsService(db); const instanceSettings = instanceSettingsService(db);
const getCurrentUserRedactionOptions = async () => ({
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
});
const runLogStore = getRunLogStore(); const runLogStore = getRunLogStore();
const secretsSvc = secretService(db); const secretsSvc = secretService(db);
@@ -1318,8 +1321,13 @@ export function heartbeatService(db: Db) {
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
}, },
) { ) {
const sanitizedMessage = event.message ? redactCurrentUserText(event.message) : event.message; const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const sanitizedPayload = event.payload ? redactCurrentUserValue(event.payload) : event.payload; const sanitizedMessage = event.message
? redactCurrentUserText(event.message, currentUserRedactionOptions)
: event.message;
const sanitizedPayload = event.payload
? redactCurrentUserValue(event.payload, currentUserRedactionOptions)
: event.payload;
await db.insert(heartbeatRunEvents).values({ await db.insert(heartbeatRunEvents).values({
companyId: run.companyId, companyId: run.companyId,
@@ -2252,8 +2260,9 @@ export function heartbeatService(db: Db) {
}) })
.where(eq(heartbeatRuns.id, runId)); .where(eq(heartbeatRuns.id, runId));
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const onLog = async (stream: "stdout" | "stderr", chunk: string) => { const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
const sanitizedChunk = redactCurrentUserText(chunk); const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk); if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
const ts = new Date().toISOString(); const ts = new Date().toISOString();
@@ -2503,6 +2512,7 @@ export function heartbeatService(db: Db) {
? null ? null
: redactCurrentUserText( : redactCurrentUserText(
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"), adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
currentUserRedactionOptions,
), ),
errorCode: errorCode:
outcome === "timed_out" outcome === "timed_out"
@@ -2570,7 +2580,10 @@ export function heartbeatService(db: Db) {
} }
await finalizeAgentStatus(agent.id, outcome); await finalizeAgentStatus(agent.id, outcome);
} catch (err) { } catch (err) {
const message = redactCurrentUserText(err instanceof Error ? err.message : "Unknown adapter failure"); const message = redactCurrentUserText(
err instanceof Error ? err.message : "Unknown adapter failure",
await getCurrentUserRedactionOptions(),
);
logger.error({ err, runId }, "heartbeat execution failed"); logger.error({ err, runId }, "heartbeat execution failed");
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null; let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
@@ -3608,7 +3621,7 @@ export function heartbeatService(db: Db) {
store: run.logStore, store: run.logStore,
logRef: run.logRef, logRef: run.logRef,
...result, ...result,
content: redactCurrentUserText(result.content), content: redactCurrentUserText(result.content, await getCurrentUserRedactionOptions()),
}; };
}, },

View File

@@ -1,8 +1,11 @@
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db"; import { companies, instanceSettings } from "@paperclipai/db";
import { import {
instanceGeneralSettingsSchema,
type InstanceGeneralSettings,
instanceExperimentalSettingsSchema, instanceExperimentalSettingsSchema,
type InstanceExperimentalSettings, type InstanceExperimentalSettings,
type PatchInstanceGeneralSettings,
type InstanceSettings, type InstanceSettings,
type PatchInstanceExperimentalSettings, type PatchInstanceExperimentalSettings,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
@@ -10,21 +13,36 @@ import { eq } from "drizzle-orm";
const DEFAULT_SINGLETON_KEY = "default"; const DEFAULT_SINGLETON_KEY = "default";
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
};
}
return {
censorUsernameInLogs: false,
};
}
function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings { function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {}); const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
if (parsed.success) { if (parsed.success) {
return { return {
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
}; };
} }
return { return {
enableIsolatedWorkspaces: false, enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
}; };
} }
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings { function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
return { return {
id: row.id, id: row.id,
general: normalizeGeneralSettings(row.general),
experimental: normalizeExperimentalSettings(row.experimental), experimental: normalizeExperimentalSettings(row.experimental),
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
@@ -45,6 +63,7 @@ export function instanceSettingsService(db: Db) {
.insert(instanceSettings) .insert(instanceSettings)
.values({ .values({
singletonKey: DEFAULT_SINGLETON_KEY, singletonKey: DEFAULT_SINGLETON_KEY,
general: {},
experimental: {}, experimental: {},
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -63,11 +82,34 @@ export function instanceSettingsService(db: Db) {
return { return {
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()), get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
getGeneral: async (): Promise<InstanceGeneralSettings> => {
const row = await getOrCreateRow();
return normalizeGeneralSettings(row.general);
},
getExperimental: async (): Promise<InstanceExperimentalSettings> => { getExperimental: async (): Promise<InstanceExperimentalSettings> => {
const row = await getOrCreateRow(); const row = await getOrCreateRow();
return normalizeExperimentalSettings(row.experimental); return normalizeExperimentalSettings(row.experimental);
}, },
updateGeneral: async (patch: PatchInstanceGeneralSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextGeneral = normalizeGeneralSettings({
...normalizeGeneralSettings(current.general),
...patch,
});
const now = new Date();
const [updated] = await db
.update(instanceSettings)
.set({
general: { ...nextGeneral },
updatedAt: now,
})
.where(eq(instanceSettings.id, current.id))
.returning();
return toInstanceSettings(updated ?? current);
},
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => { updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow(); const current = await getOrCreateRow();
const nextExperimental = normalizeExperimentalSettings({ const nextExperimental = normalizeExperimentalSettings({

View File

@@ -97,13 +97,6 @@ type IssueUserContextInput = {
updatedAt: Date | string; updatedAt: Date | string;
}; };
function redactIssueComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId; if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null; return checkoutRunId == null;
@@ -320,6 +313,13 @@ function withActiveRuns(
export function issueService(db: Db) { export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db); const instanceSettings = instanceSettingsService(db);
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function assertAssignableAgent(companyId: string, agentId: string) { async function assertAssignableAgent(companyId: string, agentId: string) {
const assignee = await db const assignee = await db
.select({ .select({
@@ -1215,7 +1215,8 @@ export function issueService(db: Db) {
); );
const comments = limit ? await query.limit(limit) : await query; const comments = limit ? await query.limit(limit) : await query;
return comments.map(redactIssueComment); const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return comments.map((comment) => redactIssueComment(comment, censorUsernameInLogs));
}, },
getCommentCursor: async (issueId: string) => { getCommentCursor: async (issueId: string) => {
@@ -1247,14 +1248,15 @@ export function issueService(db: Db) {
}, },
getComment: (commentId: string) => getComment: (commentId: string) =>
db instanceSettings.getGeneral().then(({ censorUsernameInLogs }) =>
db
.select() .select()
.from(issueComments) .from(issueComments)
.where(eq(issueComments.id, commentId)) .where(eq(issueComments.id, commentId))
.then((rows) => { .then((rows) => {
const comment = rows[0] ?? null; const comment = rows[0] ?? null;
return comment ? redactIssueComment(comment) : null; return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
}), })),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => { addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db const issue = await db
@@ -1265,7 +1267,10 @@ export function issueService(db: Db) {
if (!issue) throw notFound("Issue not found"); if (!issue) throw notFound("Issue not found");
const redactedBody = redactCurrentUserText(body); const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
const [comment] = await db const [comment] = await db
.insert(issueComments) .insert(issueComments)
.values({ .values({
@@ -1283,7 +1288,7 @@ export function issueService(db: Db) {
.set({ updatedAt: new Date() }) .set({ updatedAt: new Date() })
.where(eq(issues.id, issueId)); .where(eq(issues.id, issueId));
return redactIssueComment(comment); return redactIssueComment(comment, currentUserRedactionOptions.enabled);
}, },
createAttachment: async (input: { createAttachment: async (input: {

View File

@@ -5,6 +5,7 @@ import type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationSta
import { asc, desc, eq, inArray, isNull, or, and } from "drizzle-orm"; import { asc, desc, eq, inArray, isNull, or, and } from "drizzle-orm";
import { notFound } from "../errors.js"; import { notFound } from "../errors.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import { instanceSettingsService } from "./instance-settings.js";
import { getWorkspaceOperationLogStore } from "./workspace-operation-log-store.js"; import { getWorkspaceOperationLogStore } from "./workspace-operation-log-store.js";
type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect; type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect;
@@ -69,6 +70,7 @@ export interface WorkspaceOperationRecorder {
} }
export function workspaceOperationService(db: Db) { export function workspaceOperationService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const logStore = getWorkspaceOperationLogStore(); const logStore = getWorkspaceOperationLogStore();
async function getById(id: string) { async function getById(id: string) {
@@ -105,6 +107,9 @@ export function workspaceOperationService(db: Db) {
}, },
async recordOperation(recordInput) { async recordOperation(recordInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const startedAt = new Date(); const startedAt = new Date();
const id = randomUUID(); const id = randomUUID();
const handle = await logStore.begin({ const handle = await logStore.begin({
@@ -116,7 +121,7 @@ export function workspaceOperationService(db: Db) {
let stderrExcerpt = ""; let stderrExcerpt = "";
const append = async (stream: "stdout" | "stderr" | "system", chunk: string | null | undefined) => { const append = async (stream: "stdout" | "stderr" | "system", chunk: string | null | undefined) => {
if (!chunk) return; if (!chunk) return;
const sanitizedChunk = redactCurrentUserText(chunk); const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk); if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
await logStore.append(handle, { await logStore.append(handle, {
@@ -137,7 +142,10 @@ export function workspaceOperationService(db: Db) {
status: "running", status: "running",
logStore: handle.store, logStore: handle.store,
logRef: handle.logRef, logRef: handle.logRef,
metadata: redactCurrentUserValue(recordInput.metadata ?? null) as Record<string, unknown> | null, metadata: redactCurrentUserValue(
recordInput.metadata ?? null,
currentUserRedactionOptions,
) as Record<string, unknown> | null,
startedAt, startedAt,
}); });
createdIds.push(id); createdIds.push(id);
@@ -162,6 +170,7 @@ export function workspaceOperationService(db: Db) {
logCompressed: finalized.compressed, logCompressed: finalized.compressed,
metadata: redactCurrentUserValue( metadata: redactCurrentUserValue(
combineMetadata(recordInput.metadata, result.metadata), combineMetadata(recordInput.metadata, result.metadata),
currentUserRedactionOptions,
) as Record<string, unknown> | null, ) as Record<string, unknown> | null,
finishedAt, finishedAt,
updatedAt: finishedAt, updatedAt: finishedAt,
@@ -241,7 +250,9 @@ export function workspaceOperationService(db: Db) {
store: operation.logStore, store: operation.logStore,
logRef: operation.logRef, logRef: operation.logRef,
...result, ...result,
content: redactCurrentUserText(result.content), content: redactCurrentUserText(result.content, {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
}),
}; };
}, },
}; };

View File

@@ -23,6 +23,7 @@ import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox"; import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings"; import { CompanySettings } from "./pages/CompanySettings";
import { DesignGuide } from "./pages/DesignGuide"; import { DesignGuide } from "./pages/DesignGuide";
import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings";
import { InstanceSettings } from "./pages/InstanceSettings"; import { InstanceSettings } from "./pages/InstanceSettings";
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings"; import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
import { PluginManager } from "./pages/PluginManager"; import { PluginManager } from "./pages/PluginManager";
@@ -171,7 +172,7 @@ function InboxRootRedirect() {
function LegacySettingsRedirect() { function LegacySettingsRedirect() {
const location = useLocation(); const location = useLocation();
return <Navigate to={`/instance/settings/heartbeats${location.search}${location.hash}`} replace />; return <Navigate to={`/instance/settings/general${location.search}${location.hash}`} replace />;
} }
function OnboardingRoutePage() { function OnboardingRoutePage() {
@@ -296,9 +297,10 @@ export function App() {
<Route element={<CloudAccessGate />}> <Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} /> <Route index element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<OnboardingRoutePage />} /> <Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="instance" element={<Navigate to="/instance/settings/heartbeats" replace />} /> <Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
<Route path="instance/settings" element={<Layout />}> <Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="heartbeats" replace />} /> <Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<InstanceGeneralSettings />} />
<Route path="heartbeats" element={<InstanceSettings />} /> <Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} /> <Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} /> <Route path="plugins" element={<PluginManager />} />

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { buildTranscript, type RunLogChunk } from "./transcript";
describe("buildTranscript", () => {
const ts = "2026-03-20T13:00:00.000Z";
const chunks: RunLogChunk[] = [
{ ts, stream: "stdout", chunk: "opened /Users/dotta/project\n" },
{ ts, stream: "stderr", chunk: "stderr /Users/dotta/project" },
];
it("defaults username censoring to off when options are omitted", () => {
const entries = buildTranscript(chunks, (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }]);
expect(entries).toEqual([
{ kind: "stdout", ts, text: "opened /Users/dotta/project" },
{ kind: "stderr", ts, text: "stderr /Users/dotta/project" },
]);
});
it("still redacts usernames when explicitly enabled", () => {
const entries = buildTranscript(chunks, (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }], {
censorUsernameInLogs: true,
});
expect(entries).toEqual([
{ kind: "stdout", ts, text: "opened /Users/d****/project" },
{ kind: "stderr", ts, text: "stderr /Users/d****/project" },
]);
});
});

View File

@@ -2,6 +2,7 @@ import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@papercl
import type { TranscriptEntry, StdoutLineParser } from "./types"; import type { TranscriptEntry, StdoutLineParser } from "./types";
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) { export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) { if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
@@ -21,17 +22,22 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
} }
} }
export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] { export function buildTranscript(
chunks: RunLogChunk[],
parser: StdoutLineParser,
opts?: TranscriptBuildOptions,
): TranscriptEntry[] {
const entries: TranscriptEntry[] = []; const entries: TranscriptEntry[] = [];
let stdoutBuffer = ""; let stdoutBuffer = "";
const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false };
for (const chunk of chunks) { for (const chunk of chunks) {
if (chunk.stream === "stderr") { if (chunk.stream === "stderr") {
entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) }); entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue; continue;
} }
if (chunk.stream === "system") { if (chunk.stream === "system") {
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) }); entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue; continue;
} }
@@ -41,14 +47,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map(redactTranscriptEntryPaths)); appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
} }
} }
const trailing = stdoutBuffer.trim(); const trailing = stdoutBuffer.trim();
if (trailing) { if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString(); const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
appendTranscriptEntries(entries, parser(trailing, ts).map(redactTranscriptEntryPaths)); appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
} }
return entries; return entries;

View File

@@ -1,3 +1,17 @@
export type DevServerHealthStatus = {
enabled: true;
restartRequired: boolean;
reason: "backend_changes" | "pending_migrations" | "backend_changes_and_pending_migrations" | null;
lastChangedAt: string | null;
changedPathCount: number;
changedPathsSample: string[];
pendingMigrations: string[];
autoRestartEnabled: boolean;
activeRunCount: number;
waitingForIdle: boolean;
lastRestartAt: string | null;
};
export type HealthStatus = { export type HealthStatus = {
status: "ok"; status: "ok";
version?: string; version?: string;
@@ -9,6 +23,7 @@ export type HealthStatus = {
features?: { features?: {
companyDeletionEnabled?: boolean; companyDeletionEnabled?: boolean;
}; };
devServer?: DevServerHealthStatus;
}; };
export const healthApi = { export const healthApi = {

View File

@@ -1,10 +1,16 @@
import type { import type {
InstanceExperimentalSettings, InstanceExperimentalSettings,
InstanceGeneralSettings,
PatchInstanceGeneralSettings,
PatchInstanceExperimentalSettings, PatchInstanceExperimentalSettings,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { api } from "./client"; import { api } from "./client";
export const instanceSettingsApi = { export const instanceSettingsApi = {
getGeneral: () =>
api.get<InstanceGeneralSettings>("/instance/settings/general"),
updateGeneral: (patch: PatchInstanceGeneralSettings) =>
api.patch<InstanceGeneralSettings>("/instance/settings/general", patch),
getExperimental: () => getExperimental: () =>
api.get<InstanceExperimentalSettings>("/instance/settings/experimental"), api.get<InstanceExperimentalSettings>("/instance/settings/experimental"),
updateExperimental: (patch: PatchInstanceExperimentalSettings) => updateExperimental: (patch: PatchInstanceExperimentalSettings) =>

View File

@@ -0,0 +1,89 @@
import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react";
import type { DevServerHealthStatus } from "../api/health";
function formatRelativeTimestamp(value: string | null): string | null {
if (!value) return null;
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) return null;
const deltaMs = Date.now() - timestamp;
if (deltaMs < 60_000) return "just now";
const deltaMinutes = Math.round(deltaMs / 60_000);
if (deltaMinutes < 60) return `${deltaMinutes}m ago`;
const deltaHours = Math.round(deltaMinutes / 60);
if (deltaHours < 24) return `${deltaHours}h ago`;
const deltaDays = Math.round(deltaHours / 24);
return `${deltaDays}d ago`;
}
function describeReason(devServer: DevServerHealthStatus): string {
if (devServer.reason === "backend_changes_and_pending_migrations") {
return "backend files changed and migrations are pending";
}
if (devServer.reason === "pending_migrations") {
return "pending migrations need a fresh boot";
}
return "backend files changed since this server booted";
}
export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) {
if (!devServer?.enabled || !devServer.restartRequired) return null;
const changedAt = formatRelativeTimestamp(devServer.lastChangedAt);
const sample = devServer.changedPathsSample.slice(0, 3);
return (
<div className="border-b border-amber-300/60 bg-amber-50 text-amber-950 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-100">
<div className="flex flex-col gap-3 px-3 py-2.5 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.18em]">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span>Restart Required</span>
{devServer.autoRestartEnabled ? (
<span className="rounded-full bg-amber-900/10 px-2 py-0.5 text-[10px] tracking-[0.14em] dark:bg-amber-100/10">
Auto-Restart On
</span>
) : null}
</div>
<p className="mt-1 text-sm">
{describeReason(devServer)}
{changedAt ? ` · updated ${changedAt}` : ""}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-amber-900/80 dark:text-amber-100/75">
{sample.length > 0 ? (
<span>
Changed: {sample.join(", ")}
{devServer.changedPathCount > sample.length ? ` +${devServer.changedPathCount - sample.length} more` : ""}
</span>
) : null}
{devServer.pendingMigrations.length > 0 ? (
<span>
Pending migrations: {devServer.pendingMigrations.slice(0, 2).join(", ")}
{devServer.pendingMigrations.length > 2 ? ` +${devServer.pendingMigrations.length - 2} more` : ""}
</span>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs font-medium">
{devServer.waitingForIdle ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<TimerReset className="h-3.5 w-3.5" />
<span>Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish</span>
</div>
) : devServer.autoRestartEnabled ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<RotateCcw className="h-3.5 w-3.5" />
<span>Auto-restart will trigger when the instance is idle</span>
</div>
) : (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<RotateCcw className="h-3.5 w-3.5" />
<span>Restart <code>pnpm dev:once</code> after the active work is safe to interrupt</span>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Clock3, FlaskConical, Puzzle, Settings } from "lucide-react"; import { Clock3, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
import { NavLink } from "@/lib/router"; import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins"; import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
@@ -22,6 +22,7 @@ export function InstanceSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2"> <nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings/general" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end /> <SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} /> <SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} /> <SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />

View File

@@ -15,6 +15,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
import { ToastViewport } from "./ToastViewport"; import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav"; import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner"; import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@@ -78,6 +79,11 @@ export function Layout() {
queryKey: queryKeys.health, queryKey: queryKeys.health,
queryFn: () => healthApi.get(), queryFn: () => healthApi.get(),
retry: false, retry: false,
refetchInterval: (query) => {
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
return data?.devServer?.enabled ? 2000 : false;
},
refetchIntervalInBackground: true,
}); });
useEffect(() => { useEffect(() => {
@@ -266,6 +272,7 @@ export function Layout() {
Skip to Main Content Skip to Main Content
</a> </a>
<WorktreeBanner /> <WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}> <div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
{isMobile && sidebarOpen && ( {isMobile && sidebarOpen && (
<button <button

View File

@@ -1,7 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclipai/shared"; import type { LiveEvent } from "@paperclipai/shared";
import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats"; import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters"; import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
const LOG_POLL_INTERVAL_MS = 2000; const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000; const LOG_READ_LIMIT_BYTES = 256_000;
@@ -65,6 +68,10 @@ export function useLiveRunTranscripts({
const seenChunkKeysRef = useRef(new Set<string>()); const seenChunkKeysRef = useRef(new Set<string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>()); const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>()); const logOffsetByRunRef = useRef(new Map<string, number>());
const { data: generalSettings } = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]); const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]);
const activeRunIds = useMemo( const activeRunIds = useMemo(
@@ -267,12 +274,18 @@ export function useLiveRunTranscripts({
const transcriptByRun = useMemo(() => { const transcriptByRun = useMemo(() => {
const next = new Map<string, TranscriptEntry[]>(); const next = new Map<string, TranscriptEntry[]>();
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
for (const run of runs) { for (const run of runs) {
const adapter = getUIAdapter(run.adapterType); const adapter = getUIAdapter(run.adapterType);
next.set(run.id, buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine)); next.set(
run.id,
buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine, {
censorUsernameInLogs,
}),
);
} }
return next; return next;
}, [chunksByRun, runs]); }, [chunksByRun, generalSettings?.censorUsernameInLogs, runs]);
return { return {
transcriptByRun, transcriptByRun,

View File

@@ -6,6 +6,9 @@ import {
describe("normalizeRememberedInstanceSettingsPath", () => { describe("normalizeRememberedInstanceSettingsPath", () => {
it("keeps known instance settings pages", () => { it("keeps known instance settings pages", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/general")).toBe(
"/instance/settings/general",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe( expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe(
"/instance/settings/experimental", "/instance/settings/experimental",
); );

View File

@@ -1,4 +1,4 @@
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats"; export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/general";
export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string { export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH; if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
@@ -9,6 +9,7 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null):
const hash = match?.[3] ?? ""; const hash = match?.[3] ?? "";
if ( if (
pathname === "/instance/settings/general" ||
pathname === "/instance/settings/heartbeats" || pathname === "/instance/settings/heartbeats" ||
pathname === "/instance/settings/plugins" || pathname === "/instance/settings/plugins" ||
pathname === "/instance/settings/experimental" pathname === "/instance/settings/experimental"

View File

@@ -68,6 +68,7 @@ export const queryKeys = {
session: ["auth", "session"] as const, session: ["auth", "session"] as const,
}, },
instance: { instance: {
generalSettings: ["instance", "general-settings"] as const,
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const, schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
experimentalSettings: ["instance", "experimental-settings"] as const, experimentalSettings: ["instance", "experimental-settings"] as const,
}, },

View File

@@ -10,6 +10,7 @@ import {
} from "../api/agents"; } from "../api/agents";
import { budgetsApi } from "../api/budgets"; import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts"; import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { activityApi } from "../api/activity"; import { activityApi } from "../api/activity";
@@ -95,13 +96,21 @@ const SECRET_ENV_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
function redactPathText(value: string, censorUsernameInLogs: boolean) {
return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs });
}
function redactPathValue<T>(value: T, censorUsernameInLogs: boolean): T {
return redactHomePathUserSegmentsInValue(value, { enabled: censorUsernameInLogs });
}
function shouldRedactSecretValue(key: string, value: unknown): boolean { function shouldRedactSecretValue(key: string, value: unknown): boolean {
if (SECRET_ENV_KEY_RE.test(key)) return true; if (SECRET_ENV_KEY_RE.test(key)) return true;
if (typeof value !== "string") return false; if (typeof value !== "string") return false;
return JWT_VALUE_RE.test(value); return JWT_VALUE_RE.test(value);
} }
function redactEnvValue(key: string, value: unknown): string { function redactEnvValue(key: string, value: unknown, censorUsernameInLogs: boolean): string {
if ( if (
typeof value === "object" && typeof value === "object" &&
value !== null && value !== null &&
@@ -112,15 +121,15 @@ function redactEnvValue(key: string, value: unknown): string {
} }
if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE; if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
if (typeof value === "string") return redactHomePathUserSegments(value); if (typeof value === "string") return redactPathText(value, censorUsernameInLogs);
try { try {
return JSON.stringify(redactHomePathUserSegmentsInValue(value)); return JSON.stringify(redactPathValue(value, censorUsernameInLogs));
} catch { } catch {
return redactHomePathUserSegments(String(value)); return redactPathText(String(value), censorUsernameInLogs);
} }
} }
function formatEnvForDisplay(envValue: unknown): string { function formatEnvForDisplay(envValue: unknown, censorUsernameInLogs: boolean): string {
const env = asRecord(envValue); const env = asRecord(envValue);
if (!env) return "<unable-to-parse>"; if (!env) return "<unable-to-parse>";
@@ -129,7 +138,7 @@ function formatEnvForDisplay(envValue: unknown): string {
return keys return keys
.sort() .sort()
.map((key) => `${key}=${redactEnvValue(key, env[key])}`) .map((key) => `${key}=${redactEnvValue(key, env[key], censorUsernameInLogs)}`)
.join("\n"); .join("\n");
} }
@@ -311,7 +320,13 @@ function WorkspaceOperationStatusBadge({ status }: { status: WorkspaceOperation[
); );
} }
function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperation }) { function WorkspaceOperationLogViewer({
operation,
censorUsernameInLogs,
}: {
operation: WorkspaceOperation;
censorUsernameInLogs: boolean;
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { data: logData, isLoading, error } = useQuery({ const { data: logData, isLoading, error } = useQuery({
queryKey: ["workspace-operation-log", operation.id], queryKey: ["workspace-operation-log", operation.id],
@@ -364,7 +379,7 @@ function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperat
> >
[{chunk.stream}] [{chunk.stream}]
</span> </span>
<span className="whitespace-pre-wrap break-all">{redactHomePathUserSegments(chunk.chunk)}</span> <span className="whitespace-pre-wrap break-all">{redactPathText(chunk.chunk, censorUsernameInLogs)}</span>
</div> </div>
))} ))}
</div> </div>
@@ -375,7 +390,13 @@ function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperat
); );
} }
function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOperation[] }) { function WorkspaceOperationsSection({
operations,
censorUsernameInLogs,
}: {
operations: WorkspaceOperation[];
censorUsernameInLogs: boolean;
}) {
if (operations.length === 0) return null; if (operations.length === 0) return null;
return ( return (
@@ -440,7 +461,7 @@ function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOpera
<div> <div>
<div className="mb-1 text-xs text-red-700 dark:text-red-300">stderr excerpt</div> <div className="mb-1 text-xs text-red-700 dark:text-red-300">stderr excerpt</div>
<pre className="rounded-md bg-red-50 p-2 text-xs whitespace-pre-wrap break-all text-red-800 dark:bg-neutral-950 dark:text-red-100"> <pre className="rounded-md bg-red-50 p-2 text-xs whitespace-pre-wrap break-all text-red-800 dark:bg-neutral-950 dark:text-red-100">
{redactHomePathUserSegments(operation.stderrExcerpt)} {redactPathText(operation.stderrExcerpt, censorUsernameInLogs)}
</pre> </pre>
</div> </div>
)} )}
@@ -448,11 +469,16 @@ function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOpera
<div> <div>
<div className="mb-1 text-xs text-muted-foreground">stdout excerpt</div> <div className="mb-1 text-xs text-muted-foreground">stdout excerpt</div>
<pre className="rounded-md bg-neutral-100 p-2 text-xs whitespace-pre-wrap break-all dark:bg-neutral-950"> <pre className="rounded-md bg-neutral-100 p-2 text-xs whitespace-pre-wrap break-all dark:bg-neutral-950">
{redactHomePathUserSegments(operation.stdoutExcerpt)} {redactPathText(operation.stdoutExcerpt, censorUsernameInLogs)}
</pre> </pre>
</div> </div>
)} )}
{operation.logRef && <WorkspaceOperationLogViewer operation={operation} />} {operation.logRef && (
<WorkspaceOperationLogViewer
operation={operation}
censorUsernameInLogs={censorUsernameInLogs}
/>
)}
</div> </div>
); );
})} })}
@@ -1434,10 +1460,14 @@ function ConfigurationTab({
Lets this agent create or hire agents and implicitly assign tasks. Lets this agent create or hire agents and implicitly assign tasks.
</p> </p>
</div> </div>
<Button <button
variant={canCreateAgents ? "default" : "outline"} type="button"
size="sm" role="switch"
className="h-7 px-2.5 text-xs" aria-checked={canCreateAgents}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
canCreateAgents ? "bg-green-600" : "bg-muted",
)}
onClick={() => onClick={() =>
updatePermissions.mutate({ updatePermissions.mutate({
canCreateAgents: !canCreateAgents, canCreateAgents: !canCreateAgents,
@@ -1446,8 +1476,13 @@ function ConfigurationTab({
} }
disabled={updatePermissions.isPending} disabled={updatePermissions.isPending}
> >
{canCreateAgents ? "Enabled" : "Disabled"} <span
</Button> className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
canCreateAgents ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div> </div>
<div className="flex items-center justify-between gap-4 text-sm"> <div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1"> <div className="space-y-1">
@@ -1461,10 +1496,8 @@ function ConfigurationTab({
role="switch" role="switch"
aria-checked={canAssignTasks} aria-checked={canAssignTasks}
className={cn( className={cn(
"relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
canAssignTasks canAssignTasks ? "bg-green-600" : "bg-muted",
? "bg-green-500 focus-visible:ring-green-500/70"
: "bg-input/50 focus-visible:ring-ring",
)} )}
onClick={() => onClick={() =>
updatePermissions.mutate({ updatePermissions.mutate({
@@ -1476,8 +1509,8 @@ function ConfigurationTab({
> >
<span <span
className={cn( className={cn(
"inline-block h-4 w-4 transform rounded-full bg-background transition-transform", "inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
canAssignTasks ? "translate-x-6" : "translate-x-1", canAssignTasks ? "translate-x-4.5" : "translate-x-0.5",
)} )}
/> />
</button> </button>
@@ -2465,13 +2498,21 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
}; };
}, [isLive, run.companyId, run.id, run.agentId]); }, [isLive, run.companyId, run.id, run.agentId]);
const censorUsernameInLogs = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.censorUsernameInLogs === true;
const adapterInvokePayload = useMemo(() => { const adapterInvokePayload = useMemo(() => {
const evt = events.find((e) => e.eventType === "adapter.invoke"); const evt = events.find((e) => e.eventType === "adapter.invoke");
return redactHomePathUserSegmentsInValue(asRecord(evt?.payload ?? null)); return redactPathValue(asRecord(evt?.payload ?? null), censorUsernameInLogs);
}, [events]); }, [censorUsernameInLogs, events]);
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
const transcript = useMemo(() => buildTranscript(logLines, adapter.parseStdoutLine), [logLines, adapter]); const transcript = useMemo(
() => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }),
[adapter, censorUsernameInLogs, logLines],
);
useEffect(() => { useEffect(() => {
setTranscriptMode("nice"); setTranscriptMode("nice");
@@ -2499,7 +2540,10 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<WorkspaceOperationsSection operations={workspaceOperations} /> <WorkspaceOperationsSection
operations={workspaceOperations}
censorUsernameInLogs={censorUsernameInLogs}
/>
{adapterInvokePayload && ( {adapterInvokePayload && (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2"> <div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Invocation</div> <div className="text-xs font-medium text-muted-foreground">Invocation</div>
@@ -2541,8 +2585,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div className="text-xs text-muted-foreground mb-1">Prompt</div> <div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap"> <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof adapterInvokePayload.prompt === "string" {typeof adapterInvokePayload.prompt === "string"
? redactHomePathUserSegments(adapterInvokePayload.prompt) ? redactPathText(adapterInvokePayload.prompt, censorUsernameInLogs)
: JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.prompt), null, 2)} : JSON.stringify(redactPathValue(adapterInvokePayload.prompt, censorUsernameInLogs), null, 2)}
</pre> </pre>
</div> </div>
)} )}
@@ -2550,7 +2594,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div> <div>
<div className="text-xs text-muted-foreground mb-1">Context</div> <div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap"> <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.context), null, 2)} {JSON.stringify(redactPathValue(adapterInvokePayload.context, censorUsernameInLogs), null, 2)}
</pre> </pre>
</div> </div>
)} )}
@@ -2558,7 +2602,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div> <div>
<div className="text-xs text-muted-foreground mb-1">Environment</div> <div className="text-xs text-muted-foreground mb-1">Environment</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono"> <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{formatEnvForDisplay(adapterInvokePayload.env)} {formatEnvForDisplay(adapterInvokePayload.env, censorUsernameInLogs)}
</pre> </pre>
</div> </div>
)} )}
@@ -2634,14 +2678,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{run.error && ( {run.error && (
<div className="text-xs text-red-600 dark:text-red-200"> <div className="text-xs text-red-600 dark:text-red-200">
<span className="text-red-700 dark:text-red-300">Error: </span> <span className="text-red-700 dark:text-red-300">Error: </span>
{redactHomePathUserSegments(run.error)} {redactPathText(run.error, censorUsernameInLogs)}
</div> </div>
)} )}
{run.stderrExcerpt && run.stderrExcerpt.trim() && ( {run.stderrExcerpt && run.stderrExcerpt.trim() && (
<div> <div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div> <div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100"> <pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{redactHomePathUserSegments(run.stderrExcerpt)} {redactPathText(run.stderrExcerpt, censorUsernameInLogs)}
</pre> </pre>
</div> </div>
)} )}
@@ -2649,7 +2693,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div> <div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div> <div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100"> <pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{JSON.stringify(redactHomePathUserSegmentsInValue(run.resultJson), null, 2)} {JSON.stringify(redactPathValue(run.resultJson, censorUsernameInLogs), null, 2)}
</pre> </pre>
</div> </div>
)} )}
@@ -2657,7 +2701,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div> <div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div> <div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100"> <pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{redactHomePathUserSegments(run.stdoutExcerpt)} {redactPathText(run.stdoutExcerpt, censorUsernameInLogs)}
</pre> </pre>
</div> </div>
)} )}
@@ -2684,9 +2728,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
</span> </span>
<span className={cn("break-all", color)}> <span className={cn("break-all", color)}>
{evt.message {evt.message
? redactHomePathUserSegments(evt.message) ? redactPathText(evt.message, censorUsernameInLogs)
: evt.payload : evt.payload
? JSON.stringify(redactHomePathUserSegmentsInValue(evt.payload)) ? JSON.stringify(redactPathValue(evt.payload, censorUsernameInLogs))
: ""} : ""}
</span> </span>
</div> </div>

View File

@@ -24,11 +24,14 @@ export function InstanceExperimentalSettings() {
}); });
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) => mutationFn: async (patch: { enableIsolatedWorkspaces?: boolean; autoRestartDevServerWhenIdle?: boolean }) =>
instanceSettingsApi.updateExperimental({ enableIsolatedWorkspaces: enabled }), instanceSettingsApi.updateExperimental(patch),
onSuccess: async () => { onSuccess: async () => {
setActionError(null); setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }); await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
]);
}, },
onError: (error) => { onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings."); setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
@@ -50,6 +53,7 @@ export function InstanceExperimentalSettings() {
} }
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true; const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
return ( return (
<div className="max-w-4xl space-y-6"> <div className="max-w-4xl space-y-6">
@@ -72,7 +76,7 @@ export function InstanceExperimentalSettings() {
<section className="rounded-xl border border-border bg-card p-5"> <section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<h2 className="text-sm font-semibold">Enabled Isolated Workspaces</h2> <h2 className="text-sm font-semibold">Enable Isolated Workspaces</h2>
<p className="max-w-2xl text-sm text-muted-foreground"> <p className="max-w-2xl text-sm text-muted-foreground">
Show execution workspace controls in project configuration and allow isolated workspace behavior for new Show execution workspace controls in project configuration and allow isolated workspace behavior for new
and existing issue runs. and existing issue runs.
@@ -83,15 +87,46 @@ export function InstanceExperimentalSettings() {
aria-label="Toggle isolated workspaces experimental setting" aria-label="Toggle isolated workspaces experimental setting"
disabled={toggleMutation.isPending} disabled={toggleMutation.isPending}
className={cn( className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60", "relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted", enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
)} )}
onClick={() => toggleMutation.mutate(!enableIsolatedWorkspaces)} onClick={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
> >
<span <span
className={cn( className={cn(
"inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform", "inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-6" : "translate-x-0.5", enableIsolatedWorkspaces ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Auto-Restart Dev Server When Idle</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
In `pnpm dev:once`, wait for all queued and running local agent runs to finish, then restart the server
automatically when backend changes or migrations make the current boot stale.
</p>
</div>
<button
type="button"
aria-label="Toggle guarded dev-server auto-restart"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
autoRestartDevServerWhenIdle ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
autoRestartDevServerWhenIdle ? "translate-x-4.5" : "translate-x-0.5",
)} )}
/> />
</button> </button>

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
export function InstanceGeneralSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "General" },
]);
}, [setBreadcrumbs]);
const generalQuery = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) =>
instanceSettingsApi.updateGeneral({ censorUsernameInLogs: enabled }),
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update general settings.");
},
});
if (generalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading general settings...</div>;
}
if (generalQuery.error) {
return (
<div className="text-sm text-destructive">
{generalQuery.error instanceof Error
? generalQuery.error.message
: "Failed to load general settings."}
</div>
);
}
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
return (
<div className="max-w-4xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">General</h1>
</div>
<p className="text-sm text-muted-foreground">
Configure instance-wide defaults that affect how operator-visible logs are displayed.
</p>
</div>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Censor username in logs</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Hide the username segment in home-directory paths and similar operator-visible log output. Standalone
username mentions outside of paths are not yet masked in the live transcript view. This is off by
default.
</p>
</div>
<button
type="button"
aria-label="Toggle username log censoring"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate(!censorUsernameInLogs)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
censorUsernameInLogs ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
</div>
);
}