Merge remote-tracking branch 'public-gh/master' into paperclip-company-import-export
* public-gh/master: fix: address greptile follow-up feedback docs: clarify quickstart npx usage Add guarded dev restart handling Fix PAP-576 settings toggles and transcript default Add username log censor setting fix: use standard toggle component for permission controls # Conflicts: # server/src/routes/agents.ts # ui/src/pages/AgentDetail.tsx
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }];
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/db/src/migrations/0039_curly_maria_hill.sql
Normal file
1
packages/db/src/migrations/0039_curly_maria_hill.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "instance_settings" ADD COLUMN "general" jsonb DEFAULT '{}'::jsonb NOT NULL;
|
||||||
10308
packages/db/src/migrations/meta/0039_snapshot.json
Normal file
10308
packages/db/src/migrations/meta/0039_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export type {
|
|||||||
AgentSkillSnapshot,
|
AgentSkillSnapshot,
|
||||||
AgentSkillSyncRequest,
|
AgentSkillSyncRequest,
|
||||||
InstanceExperimentalSettings,
|
InstanceExperimentalSettings,
|
||||||
|
InstanceGeneralSettings,
|
||||||
InstanceSettings,
|
InstanceSettings,
|
||||||
Agent,
|
Agent,
|
||||||
AgentAccessState,
|
AgentAccessState,
|
||||||
@@ -286,6 +287,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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
CompanySkillSourceType,
|
CompanySkillSourceType,
|
||||||
CompanySkillTrustLevel,
|
CompanySkillTrustLevel,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export {
|
export {
|
||||||
|
instanceGeneralSettingsSchema,
|
||||||
|
patchInstanceGeneralSettingsSchema,
|
||||||
|
type InstanceGeneralSettings,
|
||||||
|
type PatchInstanceGeneralSettings,
|
||||||
instanceExperimentalSettingsSchema,
|
instanceExperimentalSettingsSchema,
|
||||||
patchInstanceExperimentalSettingsSchema,
|
patchInstanceExperimentalSettingsSchema,
|
||||||
type InstanceExperimentalSettings,
|
type InstanceExperimentalSettings,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
66
server/src/__tests__/dev-server-status.test.ts
Normal file
66
server/src/__tests__/dev-server-status.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
103
server/src/dev-server-status.ts
Normal file
103
server/src/dev-server-status.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ 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 { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
|
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.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,
|
||||||
@@ -84,8 +85,15 @@ export function agentRoutes(db: Db) {
|
|||||||
const instructions = agentInstructionsService();
|
const instructions = agentInstructionsService();
|
||||||
const companySkills = companySkillService(db);
|
const companySkills = companySkillService(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);
|
||||||
@@ -2084,7 +2092,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) => {
|
||||||
@@ -2119,11 +2127,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);
|
||||||
});
|
});
|
||||||
@@ -2159,7 +2168,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) => {
|
||||||
@@ -2255,7 +2264,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,
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -721,6 +721,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);
|
||||||
@@ -1320,8 +1323,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,
|
||||||
@@ -2259,8 +2267,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();
|
||||||
@@ -2510,6 +2519,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"
|
||||||
@@ -2577,7 +2587,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;
|
||||||
@@ -3615,7 +3628,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()),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { CompanySkills } from "./pages/CompanySkills";
|
|||||||
import { CompanyExport } from "./pages/CompanyExport";
|
import { CompanyExport } from "./pages/CompanyExport";
|
||||||
import { CompanyImport } from "./pages/CompanyImport";
|
import { CompanyImport } from "./pages/CompanyImport";
|
||||||
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";
|
||||||
@@ -177,7 +178,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() {
|
||||||
@@ -302,9 +303,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 />} />
|
||||||
|
|||||||
30
ui/src/adapters/transcript.test.ts
Normal file
30
ui/src/adapters/transcript.test.ts
Normal 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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
89
ui/src/components/DevRestartBanner.tsx
Normal file
89
ui/src/components/DevRestartBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -80,6 +80,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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
agentsApi,
|
agentsApi,
|
||||||
type AgentKey,
|
type AgentKey,
|
||||||
type ClaudeLoginResult,
|
type ClaudeLoginResult,
|
||||||
type AvailableSkill,
|
|
||||||
type AgentPermissionUpdate,
|
type AgentPermissionUpdate,
|
||||||
} from "../api/agents";
|
} from "../api/agents";
|
||||||
import { companySkillsApi } from "../api/companySkills";
|
import { companySkillsApi } from "../api/companySkills";
|
||||||
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";
|
||||||
@@ -110,13 +110,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 &&
|
||||||
@@ -127,11 +135,11 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +147,7 @@ function isMarkdown(pathValue: string) {
|
|||||||
return pathValue.toLowerCase().endsWith(".md");
|
return pathValue.toLowerCase().endsWith(".md");
|
||||||
}
|
}
|
||||||
|
|
||||||
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>";
|
||||||
|
|
||||||
@@ -148,7 +156,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");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +347,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],
|
||||||
@@ -392,7 +406,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>
|
||||||
@@ -403,7 +417,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 (
|
||||||
@@ -468,7 +488,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>
|
||||||
)}
|
)}
|
||||||
@@ -476,11 +496,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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1494,10 +1519,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,
|
||||||
@@ -1506,8 +1535,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">
|
||||||
@@ -1521,10 +1555,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({
|
||||||
@@ -1536,8 +1568,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>
|
||||||
@@ -3558,13 +3590,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");
|
||||||
@@ -3592,7 +3632,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>
|
||||||
@@ -3634,8 +3677,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>
|
||||||
)}
|
)}
|
||||||
@@ -3643,7 +3686,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>
|
||||||
)}
|
)}
|
||||||
@@ -3651,7 +3694,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>
|
||||||
)}
|
)}
|
||||||
@@ -3727,14 +3770,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>
|
||||||
)}
|
)}
|
||||||
@@ -3742,7 +3785,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>
|
||||||
)}
|
)}
|
||||||
@@ -3750,7 +3793,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>
|
||||||
)}
|
)}
|
||||||
@@ -3777,9 +3820,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
103
ui/src/pages/InstanceGeneralSettings.tsx
Normal file
103
ui/src/pages/InstanceGeneralSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user