paperclip/server/src/services/instance-settings.ts

156 lines
4.9 KiB
TypeScript
Raw Normal View History

import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db";
import {
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
DEFAULT_BACKUP_RETENTION,
instanceGeneralSettingsSchema,
type InstanceGeneralSettings,
instanceExperimentalSettingsSchema,
type InstanceExperimentalSettings,
type PatchInstanceGeneralSettings,
type InstanceSettings,
type PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { eq } from "drizzle-orm";
const DEFAULT_SINGLETON_KEY = "default";
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
keyboardShortcuts: parsed.data.keyboardShortcuts ?? false,
feedbackDataSharingPreference:
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION,
};
}
return {
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
backupRetention: DEFAULT_BACKUP_RETENTION,
};
}
function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
};
}
return {
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
};
}
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
return {
id: row.id,
general: normalizeGeneralSettings(row.general),
experimental: normalizeExperimentalSettings(row.experimental),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function instanceSettingsService(db: Db) {
async function getOrCreateRow() {
const existing = await db
.select()
.from(instanceSettings)
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
.then((rows) => rows[0] ?? null);
if (existing) return existing;
const now = new Date();
const [created] = await db
.insert(instanceSettings)
.values({
singletonKey: DEFAULT_SINGLETON_KEY,
general: {},
experimental: {},
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [instanceSettings.singletonKey],
set: {
updatedAt: now,
},
})
.returning();
[codex] Improve agent runtime recovery and governance (#4086) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The heartbeat runtime, agent import path, and agent configuration defaults determine whether work is dispatched safely and predictably. > - Several accumulated fixes all touched agent execution recovery, wake routing, import behavior, and runtime concurrency defaults. > - Those changes need to land together so the heartbeat service and agent creation defaults stay internally consistent. > - This pull request groups the runtime/governance changes from the split branch into one standalone branch. > - The benefit is safer recovery for stranded runs, bounded high-volume reads, imported-agent approval correctness, skill-template support, and a clearer default concurrency policy. ## What Changed - Fixed stranded continuation recovery so successful automatic retries are requeued instead of incorrectly blocking the issue. - Bounded high-volume issue/log reads across issue, heartbeat, agent, project, and workspace paths. - Fixed imported-agent approval and instruction-path permission handling. - Quarantined seeded worktree execution state during worktree provisioning. - Queued approval follow-up wakes and hardened SQL_ASCII heartbeat output handling. - Added reusable agent instruction templates for hiring flows. - Set the default max concurrent agent runs to five and updated related UI/tests/docs. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/company-portability.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/heartbeat-comment-wake-batching.test.ts server/src/__tests__/heartbeat-list.test.ts server/src/__tests__/issues-service.test.ts server/src/__tests__/agent-permissions-routes.test.ts packages/adapter-utils/src/server-utils.test.ts ui/src/lib/new-agent-runtime-config.test.ts` - Split integration check: merged this branch first, followed by the other [PAP-1614](/PAP/issues/PAP-1614) branches, with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Medium risk: touches heartbeat recovery, queueing, and issue list bounds in central runtime paths. - Imported-agent and concurrency default behavior changes may affect existing automation that assumes one-at-a-time default runs. - No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:19:48 -05:00
if (created) return created;
const raced = await db
.select()
.from(instanceSettings)
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
.then((rows) => rows[0] ?? null);
if (raced) return raced;
throw new Error("Failed to initialize instance settings row");
}
return {
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
getGeneral: async (): Promise<InstanceGeneralSettings> => {
const row = await getOrCreateRow();
return normalizeGeneralSettings(row.general);
},
getExperimental: async (): Promise<InstanceExperimentalSettings> => {
const row = await getOrCreateRow();
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> => {
const current = await getOrCreateRow();
const nextExperimental = normalizeExperimentalSettings({
...normalizeExperimentalSettings(current.experimental),
...patch,
});
const now = new Date();
const [updated] = await db
.update(instanceSettings)
.set({
experimental: { ...nextExperimental },
updatedAt: now,
})
.where(eq(instanceSettings.id, current.id))
.returning();
return toInstanceSettings(updated ?? current);
},
listCompanyIds: async (): Promise<string[]> =>
db
.select({ id: companies.id })
.from(companies)
.then((rows) => rows.map((row) => row.id)),
};
}