feat(core): merge backup core changes with post-split functionality
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import type { Company } from "@paperclip/shared";
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type {
|
||||||
|
Company,
|
||||||
|
CompanyPortabilityExportResult,
|
||||||
|
CompanyPortabilityInclude,
|
||||||
|
CompanyPortabilityManifest,
|
||||||
|
CompanyPortabilityPreviewResult,
|
||||||
|
CompanyPortabilityImportResult,
|
||||||
|
} from "@paperclip/shared";
|
||||||
|
import { ApiRequestError } from "../../client/http.js";
|
||||||
import {
|
import {
|
||||||
addCommonClientOptions,
|
addCommonClientOptions,
|
||||||
formatInlineRecord,
|
formatInlineRecord,
|
||||||
@@ -10,6 +20,185 @@ import {
|
|||||||
} from "./common.js";
|
} from "./common.js";
|
||||||
|
|
||||||
interface CompanyCommandOptions extends BaseClientOptions {}
|
interface CompanyCommandOptions extends BaseClientOptions {}
|
||||||
|
type CompanyDeleteSelectorMode = "auto" | "id" | "prefix";
|
||||||
|
type CompanyImportTargetMode = "new" | "existing";
|
||||||
|
type CompanyCollisionMode = "rename" | "skip" | "replace";
|
||||||
|
|
||||||
|
interface CompanyDeleteOptions extends BaseClientOptions {
|
||||||
|
by?: CompanyDeleteSelectorMode;
|
||||||
|
yes?: boolean;
|
||||||
|
confirm?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyExportOptions extends BaseClientOptions {
|
||||||
|
out?: string;
|
||||||
|
include?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyImportOptions extends BaseClientOptions {
|
||||||
|
from?: string;
|
||||||
|
include?: string;
|
||||||
|
target?: CompanyImportTargetMode;
|
||||||
|
companyId?: string;
|
||||||
|
newCompanyName?: string;
|
||||||
|
agents?: string;
|
||||||
|
collision?: CompanyCollisionMode;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUuidLike(value: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSelector(input: string): string {
|
||||||
|
return input.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInclude(input: string | undefined): CompanyPortabilityInclude {
|
||||||
|
if (!input || !input.trim()) return { company: true, agents: true };
|
||||||
|
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
||||||
|
const include = {
|
||||||
|
company: values.includes("company"),
|
||||||
|
agents: values.includes("agents"),
|
||||||
|
};
|
||||||
|
if (!include.company && !include.agents) {
|
||||||
|
throw new Error("Invalid --include value. Use one or both of: company,agents");
|
||||||
|
}
|
||||||
|
return include;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAgents(input: string | undefined): "all" | string[] {
|
||||||
|
if (!input || !input.trim()) return "all";
|
||||||
|
const normalized = input.trim().toLowerCase();
|
||||||
|
if (normalized === "all") return "all";
|
||||||
|
const values = input.split(",").map((part) => part.trim()).filter(Boolean);
|
||||||
|
if (values.length === 0) return "all";
|
||||||
|
return Array.from(new Set(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(input: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(input.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGithubUrl(input: string): boolean {
|
||||||
|
return /^https?:\/\/github\.com\//i.test(input.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveInlineSourceFromPath(inputPath: string): Promise<{
|
||||||
|
manifest: CompanyPortabilityManifest;
|
||||||
|
files: Record<string, string>;
|
||||||
|
}> {
|
||||||
|
const resolved = path.resolve(inputPath);
|
||||||
|
const resolvedStat = await stat(resolved);
|
||||||
|
const manifestPath = resolvedStat.isDirectory()
|
||||||
|
? path.join(resolved, "paperclip.manifest.json")
|
||||||
|
: resolved;
|
||||||
|
const manifestBaseDir = path.dirname(manifestPath);
|
||||||
|
const manifestRaw = await readFile(manifestPath, "utf8");
|
||||||
|
const manifest = JSON.parse(manifestRaw) as CompanyPortabilityManifest;
|
||||||
|
const files: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (manifest.company?.path) {
|
||||||
|
const companyPath = manifest.company.path.replace(/\\/g, "/");
|
||||||
|
files[companyPath] = await readFile(path.join(manifestBaseDir, companyPath), "utf8");
|
||||||
|
}
|
||||||
|
for (const agent of manifest.agents ?? []) {
|
||||||
|
const agentPath = agent.path.replace(/\\/g, "/");
|
||||||
|
files[agentPath] = await readFile(path.join(manifestBaseDir, agentPath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { manifest, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise<void> {
|
||||||
|
const root = path.resolve(outDir);
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
const manifestPath = path.join(root, "paperclip.manifest.json");
|
||||||
|
await writeFile(manifestPath, JSON.stringify(exported.manifest, null, 2), "utf8");
|
||||||
|
for (const [relativePath, content] of Object.entries(exported.files)) {
|
||||||
|
const normalized = relativePath.replace(/\\/g, "/");
|
||||||
|
const filePath = path.join(root, normalized);
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, content, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesPrefix(company: Company, selector: string): boolean {
|
||||||
|
return company.issuePrefix.toUpperCase() === selector.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCompanyForDeletion(
|
||||||
|
companies: Company[],
|
||||||
|
selectorRaw: string,
|
||||||
|
by: CompanyDeleteSelectorMode = "auto",
|
||||||
|
): Company {
|
||||||
|
const selector = normalizeSelector(selectorRaw);
|
||||||
|
if (!selector) {
|
||||||
|
throw new Error("Company selector is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const idMatch = companies.find((company) => company.id === selector);
|
||||||
|
const prefixMatch = companies.find((company) => matchesPrefix(company, selector));
|
||||||
|
|
||||||
|
if (by === "id") {
|
||||||
|
if (!idMatch) {
|
||||||
|
throw new Error(`No company found by ID '${selector}'.`);
|
||||||
|
}
|
||||||
|
return idMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (by === "prefix") {
|
||||||
|
if (!prefixMatch) {
|
||||||
|
throw new Error(`No company found by shortname/prefix '${selector}'.`);
|
||||||
|
}
|
||||||
|
return prefixMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idMatch && prefixMatch && idMatch.id !== prefixMatch.id) {
|
||||||
|
throw new Error(
|
||||||
|
`Selector '${selector}' is ambiguous (matches both an ID and a shortname). Re-run with --by id or --by prefix.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idMatch) return idMatch;
|
||||||
|
if (prefixMatch) return prefixMatch;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No company found for selector '${selector}'. Use company ID or issue prefix (for example PAP).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertDeleteConfirmation(company: Company, opts: CompanyDeleteOptions): void {
|
||||||
|
if (!opts.yes) {
|
||||||
|
throw new Error("Deletion requires --yes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirm = opts.confirm?.trim();
|
||||||
|
if (!confirm) {
|
||||||
|
throw new Error(
|
||||||
|
"Deletion requires --confirm <value> where value matches the company ID or issue prefix.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmsById = confirm === company.id;
|
||||||
|
const confirmsByPrefix = confirm.toUpperCase() === company.issuePrefix.toUpperCase();
|
||||||
|
if (!confirmsById && !confirmsByPrefix) {
|
||||||
|
throw new Error(
|
||||||
|
`Confirmation '${confirm}' does not match target company. Expected ID '${company.id}' or prefix '${company.issuePrefix}'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDeleteFlags(opts: CompanyDeleteOptions): void {
|
||||||
|
if (!opts.yes) {
|
||||||
|
throw new Error("Deletion requires --yes.");
|
||||||
|
}
|
||||||
|
if (!opts.confirm?.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
"Deletion requires --confirm <value> where value matches the company ID or issue prefix.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerCompanyCommands(program: Command): void {
|
export function registerCompanyCommands(program: Command): void {
|
||||||
const company = program.command("company").description("Company operations");
|
const company = program.command("company").description("Company operations");
|
||||||
@@ -64,4 +253,220 @@ export function registerCompanyCommands(program: Command): void {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
company
|
||||||
|
.command("export")
|
||||||
|
.description("Export a company into portable manifest + markdown files")
|
||||||
|
.argument("<companyId>", "Company ID")
|
||||||
|
.requiredOption("--out <path>", "Output directory")
|
||||||
|
.option("--include <values>", "Comma-separated include set: company,agents", "company,agents")
|
||||||
|
.action(async (companyId: string, opts: CompanyExportOptions) => {
|
||||||
|
try {
|
||||||
|
const ctx = resolveCommandContext(opts);
|
||||||
|
const include = parseInclude(opts.include);
|
||||||
|
const exported = await ctx.api.post<CompanyPortabilityExportResult>(
|
||||||
|
`/api/companies/${companyId}/export`,
|
||||||
|
{ include },
|
||||||
|
);
|
||||||
|
if (!exported) {
|
||||||
|
throw new Error("Export request returned no data");
|
||||||
|
}
|
||||||
|
await writeExportToFolder(opts.out!, exported);
|
||||||
|
printOutput(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
out: path.resolve(opts.out!),
|
||||||
|
filesWritten: Object.keys(exported.files).length + 1,
|
||||||
|
warningCount: exported.warnings.length,
|
||||||
|
},
|
||||||
|
{ json: ctx.json },
|
||||||
|
);
|
||||||
|
if (!ctx.json && exported.warnings.length > 0) {
|
||||||
|
for (const warning of exported.warnings) {
|
||||||
|
console.log(`warning=${warning}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
company
|
||||||
|
.command("import")
|
||||||
|
.description("Import a portable company package from local path, URL, or GitHub")
|
||||||
|
.requiredOption("--from <pathOrUrl>", "Source path or URL")
|
||||||
|
.option("--include <values>", "Comma-separated include set: company,agents", "company,agents")
|
||||||
|
.option("--target <mode>", "Target mode: new | existing")
|
||||||
|
.option("-C, --company-id <id>", "Existing target company ID")
|
||||||
|
.option("--new-company-name <name>", "Name override for --target new")
|
||||||
|
.option("--agents <list>", "Comma-separated agent slugs to import, or all", "all")
|
||||||
|
.option("--collision <mode>", "Collision strategy: rename | skip | replace", "rename")
|
||||||
|
.option("--dry-run", "Run preview only without applying", false)
|
||||||
|
.action(async (opts: CompanyImportOptions) => {
|
||||||
|
try {
|
||||||
|
const ctx = resolveCommandContext(opts);
|
||||||
|
const from = (opts.from ?? "").trim();
|
||||||
|
if (!from) {
|
||||||
|
throw new Error("--from is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const include = parseInclude(opts.include);
|
||||||
|
const agents = parseAgents(opts.agents);
|
||||||
|
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
|
||||||
|
if (!["rename", "skip", "replace"].includes(collision)) {
|
||||||
|
throw new Error("Invalid --collision value. Use: rename, skip, replace");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferredTarget = opts.target ?? (opts.companyId || ctx.companyId ? "existing" : "new");
|
||||||
|
const target = inferredTarget.toLowerCase() as CompanyImportTargetMode;
|
||||||
|
if (!["new", "existing"].includes(target)) {
|
||||||
|
throw new Error("Invalid --target value. Use: new | existing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTargetCompanyId = opts.companyId?.trim() || ctx.companyId;
|
||||||
|
const targetPayload =
|
||||||
|
target === "existing"
|
||||||
|
? {
|
||||||
|
mode: "existing_company" as const,
|
||||||
|
companyId: existingTargetCompanyId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
mode: "new_company" as const,
|
||||||
|
newCompanyName: opts.newCompanyName?.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (targetPayload.mode === "existing_company" && !targetPayload.companyId) {
|
||||||
|
throw new Error("Target existing company requires --company-id (or context default companyId).");
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourcePayload:
|
||||||
|
| { type: "inline"; manifest: CompanyPortabilityManifest; files: Record<string, string> }
|
||||||
|
| { type: "url"; url: string }
|
||||||
|
| { type: "github"; url: string };
|
||||||
|
|
||||||
|
if (isHttpUrl(from)) {
|
||||||
|
sourcePayload = isGithubUrl(from)
|
||||||
|
? { type: "github", url: from }
|
||||||
|
: { type: "url", url: from };
|
||||||
|
} else {
|
||||||
|
const inline = await resolveInlineSourceFromPath(from);
|
||||||
|
sourcePayload = {
|
||||||
|
type: "inline",
|
||||||
|
manifest: inline.manifest,
|
||||||
|
files: inline.files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
source: sourcePayload,
|
||||||
|
include,
|
||||||
|
target: targetPayload,
|
||||||
|
agents,
|
||||||
|
collisionStrategy: collision,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.dryRun) {
|
||||||
|
const preview = await ctx.api.post<CompanyPortabilityPreviewResult>(
|
||||||
|
"/api/companies/import/preview",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
printOutput(preview, { json: ctx.json });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imported = await ctx.api.post<CompanyPortabilityImportResult>("/api/companies/import", payload);
|
||||||
|
printOutput(imported, { json: ctx.json });
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
company
|
||||||
|
.command("delete")
|
||||||
|
.description("Delete a company by ID or shortname/prefix (destructive)")
|
||||||
|
.argument("<selector>", "Company ID or issue prefix (for example PAP)")
|
||||||
|
.option(
|
||||||
|
"--by <mode>",
|
||||||
|
"Selector mode: auto | id | prefix",
|
||||||
|
"auto",
|
||||||
|
)
|
||||||
|
.option("--yes", "Required safety flag to confirm destructive action", false)
|
||||||
|
.option(
|
||||||
|
"--confirm <value>",
|
||||||
|
"Required safety value: target company ID or shortname/prefix",
|
||||||
|
)
|
||||||
|
.action(async (selector: string, opts: CompanyDeleteOptions) => {
|
||||||
|
try {
|
||||||
|
const by = (opts.by ?? "auto").trim().toLowerCase() as CompanyDeleteSelectorMode;
|
||||||
|
if (!["auto", "id", "prefix"].includes(by)) {
|
||||||
|
throw new Error(`Invalid --by mode '${opts.by}'. Expected one of: auto, id, prefix.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = resolveCommandContext(opts);
|
||||||
|
const normalizedSelector = normalizeSelector(selector);
|
||||||
|
assertDeleteFlags(opts);
|
||||||
|
|
||||||
|
let target: Company | null = null;
|
||||||
|
const shouldTryIdLookup = by === "id" || (by === "auto" && isUuidLike(normalizedSelector));
|
||||||
|
if (shouldTryIdLookup) {
|
||||||
|
const byId = await ctx.api.get<Company>(`/api/companies/${normalizedSelector}`, { ignoreNotFound: true });
|
||||||
|
if (byId) {
|
||||||
|
target = byId;
|
||||||
|
} else if (by === "id") {
|
||||||
|
throw new Error(`No company found by ID '${normalizedSelector}'.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target && ctx.companyId) {
|
||||||
|
const scoped = await ctx.api.get<Company>(`/api/companies/${ctx.companyId}`, { ignoreNotFound: true });
|
||||||
|
if (scoped) {
|
||||||
|
try {
|
||||||
|
target = resolveCompanyForDeletion([scoped], normalizedSelector, by);
|
||||||
|
} catch {
|
||||||
|
// Fallback to board-wide lookup below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
try {
|
||||||
|
const companies = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
|
||||||
|
target = resolveCompanyForDeletion(companies, normalizedSelector, by);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiRequestError && error.status === 403 && error.message.includes("Board access required")) {
|
||||||
|
throw new Error(
|
||||||
|
"Board access is required to resolve companies across the instance. Use a company ID/prefix for your current company, or run with board authentication.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(`No company found for selector '${normalizedSelector}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertDeleteConfirmation(target, opts);
|
||||||
|
|
||||||
|
await ctx.api.delete<{ ok: true }>(`/api/companies/${target.id}`);
|
||||||
|
|
||||||
|
printOutput(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
deletedCompanyId: target.id,
|
||||||
|
deletedCompanyName: target.name,
|
||||||
|
deletedCompanyPrefix: target.issuePrefix,
|
||||||
|
},
|
||||||
|
{ json: ctx.json },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export interface AdapterInvocationMeta {
|
|||||||
command: string;
|
command: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
commandArgs?: string[];
|
commandArgs?: string[];
|
||||||
|
commandNotes?: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
context?: Record<string, unknown>;
|
context?: Record<string, unknown>;
|
||||||
@@ -110,6 +111,12 @@ export interface AdapterEnvironmentTestContext {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
adapterType: string;
|
adapterType: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
|
deployment?: {
|
||||||
|
mode?: "local_trusted" | "authenticated";
|
||||||
|
exposure?: "private" | "public";
|
||||||
|
bindHost?: string | null;
|
||||||
|
allowedHostnames?: string[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerAdapterModule {
|
export interface ServerAdapterModule {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ Core fields:
|
|||||||
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
|
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
|
||||||
- chrome (boolean, optional): pass --chrome when running Claude
|
- chrome (boolean, optional): pass --chrome when running Claude
|
||||||
- promptTemplate (string, optional): run prompt template
|
- promptTemplate (string, optional): run prompt template
|
||||||
- bootstrapPromptTemplate (string, optional): first-run prompt template
|
|
||||||
- maxTurnsPerRun (number, optional): max turns for one run
|
- maxTurnsPerRun (number, optional): max turns for one run
|
||||||
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
|
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
|
||||||
- command (string, optional): defaults to "claude"
|
- command (string, optional): defaults to "claude"
|
||||||
|
|||||||
@@ -261,7 +261,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
config.promptTemplate,
|
config.promptTemplate,
|
||||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||||
);
|
);
|
||||||
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
|
|
||||||
const model = asString(config.model, "");
|
const model = asString(config.model, "");
|
||||||
const effort = asString(config.effort, "");
|
const effort = asString(config.effort, "");
|
||||||
const chrome = asBoolean(config.chrome, false);
|
const chrome = asBoolean(config.chrome, false);
|
||||||
@@ -269,6 +268,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||||
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||||
|
const commandNotes = instructionsFilePath
|
||||||
|
? [
|
||||||
|
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
const runtimeConfig = await buildClaudeRuntimeConfig({
|
const runtimeConfig = await buildClaudeRuntimeConfig({
|
||||||
runId,
|
runId,
|
||||||
@@ -316,8 +320,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
const prompt = renderTemplate(promptTemplate, {
|
||||||
const prompt = renderTemplate(template, {
|
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
runId,
|
runId,
|
||||||
@@ -367,6 +370,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
commandArgs: args,
|
commandArgs: args,
|
||||||
|
commandNotes,
|
||||||
env: redactEnvForLogs(env),
|
env: redactEnvForLogs(env),
|
||||||
prompt,
|
prompt,
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
|||||||
if (v.cwd) ac.cwd = v.cwd;
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
|
||||||
if (v.model) ac.model = v.model;
|
if (v.model) ac.model = v.model;
|
||||||
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
|
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
|
||||||
if (v.chrome) ac.chrome = true;
|
if (v.chrome) ac.chrome = true;
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ Core fields:
|
|||||||
- model (string, optional): Codex model id
|
- model (string, optional): Codex model id
|
||||||
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...
|
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...
|
||||||
- promptTemplate (string, optional): run prompt template
|
- promptTemplate (string, optional): run prompt template
|
||||||
- bootstrapPromptTemplate (string, optional): first-run prompt template
|
|
||||||
- search (boolean, optional): run codex with --search
|
- search (boolean, optional): run codex with --search
|
||||||
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
|
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
|
||||||
- command (string, optional): defaults to "codex"
|
- command (string, optional): defaults to "codex"
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
config.promptTemplate,
|
config.promptTemplate,
|
||||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||||
);
|
);
|
||||||
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
|
|
||||||
const command = asString(config.command, "codex");
|
const command = asString(config.command, "codex");
|
||||||
const model = asString(config.model, "");
|
const model = asString(config.model, "");
|
||||||
const modelReasoningEffort = asString(
|
const modelReasoningEffort = asString(
|
||||||
@@ -231,11 +230,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||||
|
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||||
let instructionsPrefix = "";
|
let instructionsPrefix = "";
|
||||||
if (instructionsFilePath) {
|
if (instructionsFilePath) {
|
||||||
try {
|
try {
|
||||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||||
const instructionsDir = `${path.dirname(instructionsFilePath)}/`;
|
|
||||||
instructionsPrefix =
|
instructionsPrefix =
|
||||||
`${instructionsContents}\n\n` +
|
`${instructionsContents}\n\n` +
|
||||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||||
@@ -252,8 +251,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
const commandNotes = (() => {
|
||||||
const renderedPrompt = renderTemplate(template, {
|
if (!instructionsFilePath) return [] as string[];
|
||||||
|
if (instructionsPrefix.length > 0) {
|
||||||
|
return [
|
||||||
|
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||||
|
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||||
|
];
|
||||||
|
})();
|
||||||
|
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
runId,
|
runId,
|
||||||
@@ -283,6 +293,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
adapterType: "codex_local",
|
adapterType: "codex_local",
|
||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
|
commandNotes,
|
||||||
commandArgs: args.map((value, idx) => {
|
commandArgs: args.map((value, idx) => {
|
||||||
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
|||||||
if (v.cwd) ac.cwd = v.cwd;
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
|
||||||
if (v.model) ac.model = v.model;
|
if (v.model) ac.model = v.model;
|
||||||
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
|
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
|
||||||
ac.timeoutSec = 0;
|
ac.timeoutSec = 0;
|
||||||
|
|||||||
@@ -16,6 +16,81 @@ function isLoopbackHost(hostname: string): boolean {
|
|||||||
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHostname(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (trimmed.startsWith("[")) {
|
||||||
|
const end = trimmed.indexOf("]");
|
||||||
|
return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
const firstColon = trimmed.indexOf(":");
|
||||||
|
if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase();
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushDeploymentDiagnostics(
|
||||||
|
checks: AdapterEnvironmentCheck[],
|
||||||
|
ctx: AdapterEnvironmentTestContext,
|
||||||
|
endpointUrl: URL | null,
|
||||||
|
) {
|
||||||
|
const mode = ctx.deployment?.mode;
|
||||||
|
const exposure = ctx.deployment?.exposure;
|
||||||
|
const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null);
|
||||||
|
const allowSet = new Set(
|
||||||
|
(ctx.deployment?.allowedHostnames ?? [])
|
||||||
|
.map((entry) => normalizeHostname(entry))
|
||||||
|
.filter((entry): entry is string => Boolean(entry)),
|
||||||
|
);
|
||||||
|
const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null;
|
||||||
|
|
||||||
|
if (!mode) return;
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_deployment_context",
|
||||||
|
level: "info",
|
||||||
|
message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === "authenticated" && exposure === "private") {
|
||||||
|
if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_private_bind_hostname_not_allowed",
|
||||||
|
level: "warn",
|
||||||
|
message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`,
|
||||||
|
hint: `Run pnpm paperclip allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bindHost || isLoopbackHost(bindHost)) {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_private_bind_loopback",
|
||||||
|
level: "warn",
|
||||||
|
message: "Paperclip is bound to loopback in authenticated/private mode.",
|
||||||
|
hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_private_no_allowed_hostnames",
|
||||||
|
level: "warn",
|
||||||
|
message: "No explicit allowed hostnames are configured for authenticated/private mode.",
|
||||||
|
hint: "Set one with pnpm paperclip allowed-hostname <host> when OpenClaw runs on another machine.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "authenticated" && exposure === "public" && endpointUrl && endpointUrl.protocol !== "https:") {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_public_http_endpoint",
|
||||||
|
level: "warn",
|
||||||
|
message: "OpenClaw endpoint uses HTTP in authenticated/public mode.",
|
||||||
|
hint: "Prefer HTTPS for public deployments.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function testEnvironment(
|
export async function testEnvironment(
|
||||||
ctx: AdapterEnvironmentTestContext,
|
ctx: AdapterEnvironmentTestContext,
|
||||||
): Promise<AdapterEnvironmentTestResult> {
|
): Promise<AdapterEnvironmentTestResult> {
|
||||||
@@ -75,6 +150,8 @@ export async function testEnvironment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushDeploymentDiagnostics(checks, ctx, url);
|
||||||
|
|
||||||
const method = asString(config.method, "POST").trim().toUpperCase() || "POST";
|
const method = asString(config.method, "POST").trim().toUpperCase() || "POST";
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "openclaw_method_configured",
|
code: "openclaw_method_configured",
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ export {
|
|||||||
} from "./validators/index.js";
|
} from "./validators/index.js";
|
||||||
|
|
||||||
export { API_PREFIX, API } from "./api.js";
|
export { API_PREFIX, API } from "./api.js";
|
||||||
export { normalizeAgentUrlKey, deriveAgentUrlKey } from "./agent-url-key.js";
|
export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js";
|
||||||
|
export { deriveProjectUrlKey, normalizeProjectUrlKey } from "./project-url-key.js";
|
||||||
export {
|
export {
|
||||||
PROJECT_MENTION_SCHEME,
|
PROJECT_MENTION_SCHEME,
|
||||||
buildProjectMentionHref,
|
buildProjectMentionHref,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Agent {
|
|||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
urlKey: string;
|
||||||
role: AgentRole;
|
role: AgentRole;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface ProjectWorkspace {
|
|||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
urlKey: string;
|
||||||
/** @deprecated Use goalIds / goals instead */
|
/** @deprecated Use goalIds / goals instead */
|
||||||
goalId: string | null;
|
goalId: string | null;
|
||||||
goalIds: string[];
|
goalIds: string[];
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ export {
|
|||||||
type CreateCompany,
|
type CreateCompany,
|
||||||
type UpdateCompany,
|
type UpdateCompany,
|
||||||
} from "./company.js";
|
} from "./company.js";
|
||||||
|
export {
|
||||||
|
portabilityIncludeSchema,
|
||||||
|
portabilitySecretRequirementSchema,
|
||||||
|
portabilityCompanyManifestEntrySchema,
|
||||||
|
portabilityAgentManifestEntrySchema,
|
||||||
|
portabilityManifestSchema,
|
||||||
|
portabilitySourceSchema,
|
||||||
|
portabilityTargetSchema,
|
||||||
|
portabilityAgentSelectionSchema,
|
||||||
|
portabilityCollisionStrategySchema,
|
||||||
|
companyPortabilityExportSchema,
|
||||||
|
companyPortabilityPreviewSchema,
|
||||||
|
companyPortabilityImportSchema,
|
||||||
|
type CompanyPortabilityExport,
|
||||||
|
type CompanyPortabilityPreview,
|
||||||
|
type CompanyPortabilityImport,
|
||||||
|
} from "./company-portability.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createAgentSchema,
|
createAgentSchema,
|
||||||
@@ -114,21 +131,3 @@ export {
|
|||||||
type UpdateMemberPermissions,
|
type UpdateMemberPermissions,
|
||||||
type UpdateUserCompanyAccess,
|
type UpdateUserCompanyAccess,
|
||||||
} from "./access.js";
|
} from "./access.js";
|
||||||
|
|
||||||
export {
|
|
||||||
portabilityIncludeSchema,
|
|
||||||
portabilitySecretRequirementSchema,
|
|
||||||
portabilityCompanyManifestEntrySchema,
|
|
||||||
portabilityAgentManifestEntrySchema,
|
|
||||||
portabilityManifestSchema,
|
|
||||||
portabilitySourceSchema,
|
|
||||||
portabilityTargetSchema,
|
|
||||||
portabilityAgentSelectionSchema,
|
|
||||||
portabilityCollisionStrategySchema,
|
|
||||||
companyPortabilityExportSchema,
|
|
||||||
companyPortabilityPreviewSchema,
|
|
||||||
companyPortabilityImportSchema,
|
|
||||||
type CompanyPortabilityExport,
|
|
||||||
type CompanyPortabilityPreview,
|
|
||||||
type CompanyPortabilityImport,
|
|
||||||
} from "./company-portability.js";
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export type CheckoutIssue = z.infer<typeof checkoutIssueSchema>;
|
|||||||
export const addIssueCommentSchema = z.object({
|
export const addIssueCommentSchema = z.object({
|
||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
reopen: z.boolean().optional(),
|
reopen: z.boolean().optional(),
|
||||||
|
interrupt: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AddIssueComment = z.infer<typeof addIssueCommentSchema>;
|
export type AddIssueComment = z.infer<typeof addIssueCommentSchema>;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export async function createApp(
|
|||||||
allowedHostnames: string[];
|
allowedHostnames: string[];
|
||||||
bindHost: string;
|
bindHost: string;
|
||||||
authReady: boolean;
|
authReady: boolean;
|
||||||
|
companyDeletionEnabled: boolean;
|
||||||
betterAuthHandler?: express.RequestHandler;
|
betterAuthHandler?: express.RequestHandler;
|
||||||
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
||||||
},
|
},
|
||||||
@@ -79,6 +80,7 @@ export async function createApp(
|
|||||||
deploymentMode: opts.deploymentMode,
|
deploymentMode: opts.deploymentMode,
|
||||||
deploymentExposure: opts.deploymentExposure,
|
deploymentExposure: opts.deploymentExposure,
|
||||||
authReady: opts.authReady,
|
authReady: opts.authReady,
|
||||||
|
companyDeletionEnabled: opts.companyDeletionEnabled,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
api.use("/companies", companyRoutes(db));
|
api.use("/companies", companyRoutes(db));
|
||||||
@@ -93,7 +95,14 @@ export async function createApp(
|
|||||||
api.use(activityRoutes(db));
|
api.use(activityRoutes(db));
|
||||||
api.use(dashboardRoutes(db));
|
api.use(dashboardRoutes(db));
|
||||||
api.use(sidebarBadgeRoutes(db));
|
api.use(sidebarBadgeRoutes(db));
|
||||||
api.use(accessRoutes(db));
|
api.use(
|
||||||
|
accessRoutes(db, {
|
||||||
|
deploymentMode: opts.deploymentMode,
|
||||||
|
deploymentExposure: opts.deploymentExposure,
|
||||||
|
bindHost: opts.bindHost,
|
||||||
|
allowedHostnames: opts.allowedHostnames,
|
||||||
|
}),
|
||||||
|
);
|
||||||
app.use("/api", api);
|
app.use("/api", api);
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface Config {
|
|||||||
storageS3ForcePathStyle: boolean;
|
storageS3ForcePathStyle: boolean;
|
||||||
heartbeatSchedulerEnabled: boolean;
|
heartbeatSchedulerEnabled: boolean;
|
||||||
heartbeatSchedulerIntervalMs: number;
|
heartbeatSchedulerIntervalMs: number;
|
||||||
|
companyDeletionEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(): Config {
|
export function loadConfig(): Config {
|
||||||
@@ -142,6 +143,11 @@ export function loadConfig(): Config {
|
|||||||
const allowedHostnames = Array.from(
|
const allowedHostnames = Array.from(
|
||||||
new Set((allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)),
|
new Set((allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)),
|
||||||
);
|
);
|
||||||
|
const companyDeletionEnvRaw = process.env.PAPERCLIP_ENABLE_COMPANY_DELETION;
|
||||||
|
const companyDeletionEnabled =
|
||||||
|
companyDeletionEnvRaw !== undefined
|
||||||
|
? companyDeletionEnvRaw === "true"
|
||||||
|
: deploymentMode === "local_trusted";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deploymentMode,
|
deploymentMode,
|
||||||
@@ -179,5 +185,6 @@ export function loadConfig(): Config {
|
|||||||
storageS3ForcePathStyle,
|
storageS3ForcePathStyle,
|
||||||
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
|
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
|
||||||
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
|
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
|
||||||
|
companyDeletionEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ const app = await createApp(db as any, {
|
|||||||
allowedHostnames: config.allowedHostnames,
|
allowedHostnames: config.allowedHostnames,
|
||||||
bindHost: config.host,
|
bindHost: config.host,
|
||||||
authReady,
|
authReady,
|
||||||
|
companyDeletionEnabled: config.companyDeletionEnabled,
|
||||||
betterAuthHandler,
|
betterAuthHandler,
|
||||||
resolveSession,
|
resolveSession,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
updateUserCompanyAccessSchema,
|
updateUserCompanyAccessSchema,
|
||||||
PERMISSION_KEYS,
|
PERMISSION_KEYS,
|
||||||
} from "@paperclip/shared";
|
} from "@paperclip/shared";
|
||||||
|
import type { DeploymentExposure, DeploymentMode } from "@paperclip/shared";
|
||||||
import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js";
|
import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { accessService, agentService, logActivity } from "../services/index.js";
|
import { accessService, agentService, logActivity } from "../services/index.js";
|
||||||
@@ -76,6 +77,218 @@ function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) {
|
|||||||
return safe;
|
return safe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type JoinDiagnostic = {
|
||||||
|
code: string;
|
||||||
|
level: "info" | "warn";
|
||||||
|
message: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(hostname: string): boolean {
|
||||||
|
const value = hostname.trim().toLowerCase();
|
||||||
|
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHostname(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (trimmed.startsWith("[")) {
|
||||||
|
const end = trimmed.indexOf("]");
|
||||||
|
return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
const firstColon = trimmed.indexOf(":");
|
||||||
|
if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase();
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaderMap(input: unknown): Record<string, string> | undefined {
|
||||||
|
if (!isPlainObject(input)) return undefined;
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(input)) {
|
||||||
|
if (typeof value !== "string") continue;
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
if (!trimmedKey || !trimmedValue) continue;
|
||||||
|
out[trimmedKey] = trimmedValue;
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJoinConnectivityDiagnostics(input: {
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
deploymentExposure: DeploymentExposure;
|
||||||
|
bindHost: string;
|
||||||
|
allowedHostnames: string[];
|
||||||
|
callbackUrl: URL | null;
|
||||||
|
}): JoinDiagnostic[] {
|
||||||
|
const diagnostics: JoinDiagnostic[] = [];
|
||||||
|
const bindHost = normalizeHostname(input.bindHost);
|
||||||
|
const callbackHost = input.callbackUrl ? normalizeHostname(input.callbackUrl.hostname) : null;
|
||||||
|
const allowSet = new Set(
|
||||||
|
input.allowedHostnames
|
||||||
|
.map((entry) => normalizeHostname(entry))
|
||||||
|
.filter((entry): entry is string => Boolean(entry)),
|
||||||
|
);
|
||||||
|
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_deployment_context",
|
||||||
|
level: "info",
|
||||||
|
message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.deploymentMode === "authenticated" && input.deploymentExposure === "private") {
|
||||||
|
if (!bindHost || isLoopbackHost(bindHost)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_private_bind_loopback",
|
||||||
|
level: "warn",
|
||||||
|
message: "Paperclip is bound to loopback in authenticated/private mode.",
|
||||||
|
hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_private_bind_not_allowed",
|
||||||
|
level: "warn",
|
||||||
|
message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`,
|
||||||
|
hint: `Run pnpm paperclip allowed-hostname ${bindHost}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_private_allowed_hostnames_empty",
|
||||||
|
level: "warn",
|
||||||
|
message: "No explicit allowed hostnames are configured for authenticated/private mode.",
|
||||||
|
hint: "Set one with pnpm paperclip allowed-hostname <host> when OpenClaw runs off-host.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.deploymentMode === "authenticated" &&
|
||||||
|
input.deploymentExposure === "public" &&
|
||||||
|
input.callbackUrl &&
|
||||||
|
input.callbackUrl.protocol !== "https:"
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_public_http_callback",
|
||||||
|
level: "warn",
|
||||||
|
message: "OpenClaw callback URL uses HTTP in authenticated/public mode.",
|
||||||
|
hint: "Prefer HTTPS for public deployments.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgentDefaultsForJoin(input: {
|
||||||
|
adapterType: string | null;
|
||||||
|
defaultsPayload: unknown;
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
deploymentExposure: DeploymentExposure;
|
||||||
|
bindHost: string;
|
||||||
|
allowedHostnames: string[];
|
||||||
|
}) {
|
||||||
|
const diagnostics: JoinDiagnostic[] = [];
|
||||||
|
if (input.adapterType !== "openclaw") {
|
||||||
|
const normalized = isPlainObject(input.defaultsPayload)
|
||||||
|
? (input.defaultsPayload as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
return { normalized, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(input.defaultsPayload)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_callback_config_missing",
|
||||||
|
level: "warn",
|
||||||
|
message: "No OpenClaw callback config was provided in agentDefaultsPayload.",
|
||||||
|
hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw webhook immediately after approval.",
|
||||||
|
});
|
||||||
|
return { normalized: null as Record<string, unknown> | null, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = input.defaultsPayload as Record<string, unknown>;
|
||||||
|
const normalized: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
let callbackUrl: URL | null = null;
|
||||||
|
const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : "";
|
||||||
|
if (!rawUrl) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_callback_url_missing",
|
||||||
|
level: "warn",
|
||||||
|
message: "OpenClaw callback URL is missing.",
|
||||||
|
hint: "Set agentDefaultsPayload.url to your OpenClaw webhook endpoint.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
callbackUrl = new URL(rawUrl);
|
||||||
|
if (callbackUrl.protocol !== "http:" && callbackUrl.protocol !== "https:") {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_callback_url_protocol",
|
||||||
|
level: "warn",
|
||||||
|
message: `Unsupported callback protocol: ${callbackUrl.protocol}`,
|
||||||
|
hint: "Use http:// or https://.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
normalized.url = callbackUrl.toString();
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_callback_url_configured",
|
||||||
|
level: "info",
|
||||||
|
message: `Callback endpoint set to ${callbackUrl.toString()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isLoopbackHost(callbackUrl.hostname)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_callback_loopback",
|
||||||
|
level: "warn",
|
||||||
|
message: "OpenClaw callback endpoint uses loopback hostname.",
|
||||||
|
hint: "Use a reachable hostname/IP when OpenClaw runs on another machine.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_callback_url_invalid",
|
||||||
|
level: "warn",
|
||||||
|
message: `Invalid callback URL: ${rawUrl}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMethod = typeof defaults.method === "string" ? defaults.method.trim().toUpperCase() : "";
|
||||||
|
normalized.method = rawMethod || "POST";
|
||||||
|
|
||||||
|
if (typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)) {
|
||||||
|
normalized.timeoutSec = Math.max(1, Math.min(120, Math.floor(defaults.timeoutSec)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = normalizeHeaderMap(defaults.headers);
|
||||||
|
if (headers) normalized.headers = headers;
|
||||||
|
|
||||||
|
if (typeof defaults.webhookAuthHeader === "string" && defaults.webhookAuthHeader.trim()) {
|
||||||
|
normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(defaults.payloadTemplate)) {
|
||||||
|
normalized.payloadTemplate = defaults.payloadTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics.push(
|
||||||
|
...buildJoinConnectivityDiagnostics({
|
||||||
|
deploymentMode: input.deploymentMode,
|
||||||
|
deploymentExposure: input.deploymentExposure,
|
||||||
|
bindHost: input.bindHost,
|
||||||
|
allowedHostnames: input.allowedHostnames,
|
||||||
|
callbackUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { normalized, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
function toInviteSummaryResponse(req: Request, token: string, invite: typeof invites.$inferSelect) {
|
function toInviteSummaryResponse(req: Request, token: string, invite: typeof invites.$inferSelect) {
|
||||||
const baseUrl = requestBaseUrl(req);
|
const baseUrl = requestBaseUrl(req);
|
||||||
const onboardingPath = `/api/invites/${token}/onboarding`;
|
const onboardingPath = `/api/invites/${token}/onboarding`;
|
||||||
@@ -92,7 +305,17 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInviteOnboardingManifest(req: Request, token: string, invite: typeof invites.$inferSelect) {
|
function buildInviteOnboardingManifest(
|
||||||
|
req: Request,
|
||||||
|
token: string,
|
||||||
|
invite: typeof invites.$inferSelect,
|
||||||
|
opts: {
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
deploymentExposure: DeploymentExposure;
|
||||||
|
bindHost: string;
|
||||||
|
allowedHostnames: string[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
const baseUrl = requestBaseUrl(req);
|
const baseUrl = requestBaseUrl(req);
|
||||||
const skillPath = "/api/skills/paperclip";
|
const skillPath = "/api/skills/paperclip";
|
||||||
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
|
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
|
||||||
@@ -125,6 +348,16 @@ function buildInviteOnboardingManifest(req: Request, token: string, invite: type
|
|||||||
claimSecret: "one-time claim secret returned when the join request is created",
|
claimSecret: "one-time claim secret returned when the join request is created",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
connectivity: {
|
||||||
|
deploymentMode: opts.deploymentMode,
|
||||||
|
deploymentExposure: opts.deploymentExposure,
|
||||||
|
bindHost: opts.bindHost,
|
||||||
|
allowedHostnames: opts.allowedHostnames,
|
||||||
|
guidance:
|
||||||
|
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"
|
||||||
|
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclip allowed-hostname <host>`."
|
||||||
|
: "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims.",
|
||||||
|
},
|
||||||
skill: {
|
skill: {
|
||||||
name: "paperclip",
|
name: "paperclip",
|
||||||
path: skillPath,
|
path: skillPath,
|
||||||
@@ -194,7 +427,15 @@ function grantsFromDefaults(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function accessRoutes(db: Db) {
|
export function accessRoutes(
|
||||||
|
db: Db,
|
||||||
|
opts: {
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
deploymentExposure: DeploymentExposure;
|
||||||
|
bindHost: string;
|
||||||
|
allowedHostnames: string[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const access = accessService(db);
|
const access = accessService(db);
|
||||||
const agents = agentService(db);
|
const agents = agentService(db);
|
||||||
@@ -341,7 +582,7 @@ export function accessRoutes(db: Db) {
|
|||||||
throw notFound("Invite not found");
|
throw notFound("Invite not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(buildInviteOnboardingManifest(req, token, invite));
|
res.json(buildInviteOnboardingManifest(req, token, invite, opts));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
|
router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
|
||||||
@@ -401,6 +642,17 @@ export function accessRoutes(db: Db) {
|
|||||||
throw badRequest("agentName is required for agent join requests");
|
throw badRequest("agentName is required for agent join requests");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const joinDefaults = requestType === "agent"
|
||||||
|
? normalizeAgentDefaultsForJoin({
|
||||||
|
adapterType: req.body.adapterType ?? null,
|
||||||
|
defaultsPayload: req.body.agentDefaultsPayload ?? null,
|
||||||
|
deploymentMode: opts.deploymentMode,
|
||||||
|
deploymentExposure: opts.deploymentExposure,
|
||||||
|
bindHost: opts.bindHost,
|
||||||
|
allowedHostnames: opts.allowedHostnames,
|
||||||
|
})
|
||||||
|
: { normalized: null as Record<string, unknown> | null, diagnostics: [] as JoinDiagnostic[] };
|
||||||
|
|
||||||
const claimSecret = requestType === "agent" ? createClaimSecret() : null;
|
const claimSecret = requestType === "agent" ? createClaimSecret() : null;
|
||||||
const claimSecretHash = claimSecret ? hashToken(claimSecret) : null;
|
const claimSecretHash = claimSecret ? hashToken(claimSecret) : null;
|
||||||
const claimSecretExpiresAt = claimSecret
|
const claimSecretExpiresAt = claimSecret
|
||||||
@@ -427,7 +679,7 @@ export function accessRoutes(db: Db) {
|
|||||||
agentName: requestType === "agent" ? req.body.agentName : null,
|
agentName: requestType === "agent" ? req.body.agentName : null,
|
||||||
adapterType: requestType === "agent" ? req.body.adapterType ?? null : null,
|
adapterType: requestType === "agent" ? req.body.adapterType ?? null : null,
|
||||||
capabilities: requestType === "agent" ? req.body.capabilities ?? null : null,
|
capabilities: requestType === "agent" ? req.body.capabilities ?? null : null,
|
||||||
agentDefaultsPayload: requestType === "agent" ? req.body.agentDefaultsPayload ?? null : null,
|
agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null,
|
||||||
claimSecretHash,
|
claimSecretHash,
|
||||||
claimSecretExpiresAt,
|
claimSecretExpiresAt,
|
||||||
})
|
})
|
||||||
@@ -451,16 +703,20 @@ export function accessRoutes(db: Db) {
|
|||||||
|
|
||||||
const response = toJoinRequestResponse(created);
|
const response = toJoinRequestResponse(created);
|
||||||
if (claimSecret) {
|
if (claimSecret) {
|
||||||
const onboardingManifest = buildInviteOnboardingManifest(req, token, invite);
|
const onboardingManifest = buildInviteOnboardingManifest(req, token, invite, opts);
|
||||||
res.status(202).json({
|
res.status(202).json({
|
||||||
...response,
|
...response,
|
||||||
claimSecret,
|
claimSecret,
|
||||||
claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`,
|
claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`,
|
||||||
onboarding: onboardingManifest.onboarding,
|
onboarding: onboardingManifest.onboarding,
|
||||||
|
diagnostics: joinDefaults.diagnostics,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.status(202).json(response);
|
res.status(202).json({
|
||||||
|
...response,
|
||||||
|
...(joinDefaults.diagnostics.length > 0 ? { diagnostics: joinDefaults.diagnostics } : {}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/invites/:inviteId/revoke", async (req, res) => {
|
router.post("/invites/:inviteId/revoke", async (req, res) => {
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ export function healthRoutes(
|
|||||||
deploymentMode: DeploymentMode;
|
deploymentMode: DeploymentMode;
|
||||||
deploymentExposure: DeploymentExposure;
|
deploymentExposure: DeploymentExposure;
|
||||||
authReady: boolean;
|
authReady: boolean;
|
||||||
|
companyDeletionEnabled: boolean;
|
||||||
} = {
|
} = {
|
||||||
deploymentMode: "local_trusted",
|
deploymentMode: "local_trusted",
|
||||||
deploymentExposure: "private",
|
deploymentExposure: "private",
|
||||||
authReady: true,
|
authReady: true,
|
||||||
|
companyDeletionEnabled: true,
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -40,6 +42,9 @@ export function healthRoutes(
|
|||||||
deploymentExposure: opts.deploymentExposure,
|
deploymentExposure: opts.deploymentExposure,
|
||||||
authReady: opts.authReady,
|
authReady: opts.authReady,
|
||||||
bootstrapStatus,
|
bootstrapStatus,
|
||||||
|
features: {
|
||||||
|
companyDeletionEnabled: opts.companyDeletionEnabled,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -710,9 +710,11 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const reopenRequested = req.body.reopen === true;
|
const reopenRequested = req.body.reopen === true;
|
||||||
|
const interruptRequested = req.body.interrupt === true;
|
||||||
const isClosed = issue.status === "done" || issue.status === "cancelled";
|
const isClosed = issue.status === "done" || issue.status === "cancelled";
|
||||||
let reopened = false;
|
let reopened = false;
|
||||||
let reopenFromStatus: string | null = null;
|
let reopenFromStatus: string | null = null;
|
||||||
|
let interruptedRunId: string | null = null;
|
||||||
let currentIssue = issue;
|
let currentIssue = issue;
|
||||||
|
|
||||||
if (reopenRequested && isClosed) {
|
if (reopenRequested && isClosed) {
|
||||||
@@ -744,6 +746,52 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (interruptRequested) {
|
||||||
|
if (req.actor.type !== "board") {
|
||||||
|
res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let runToInterrupt = currentIssue.executionRunId
|
||||||
|
? await heartbeat.getRun(currentIssue.executionRunId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!runToInterrupt || runToInterrupt.status !== "running") &&
|
||||||
|
currentIssue.assigneeAgentId
|
||||||
|
) {
|
||||||
|
const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId);
|
||||||
|
const activeIssueId =
|
||||||
|
activeRun &&
|
||||||
|
activeRun.contextSnapshot &&
|
||||||
|
typeof activeRun.contextSnapshot === "object" &&
|
||||||
|
typeof (activeRun.contextSnapshot as Record<string, unknown>).issueId === "string"
|
||||||
|
? ((activeRun.contextSnapshot as Record<string, unknown>).issueId as string)
|
||||||
|
: null;
|
||||||
|
if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) {
|
||||||
|
runToInterrupt = activeRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runToInterrupt && runToInterrupt.status === "running") {
|
||||||
|
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
|
||||||
|
if (cancelled) {
|
||||||
|
interruptedRunId = cancelled.id;
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: cancelled.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "heartbeat.cancelled",
|
||||||
|
entityType: "heartbeat_run",
|
||||||
|
entityId: cancelled.id,
|
||||||
|
details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: currentIssue.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const comment = await svc.addComment(id, req.body.body, {
|
const comment = await svc.addComment(id, req.body.body, {
|
||||||
agentId: actor.agentId ?? undefined,
|
agentId: actor.agentId ?? undefined,
|
||||||
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
||||||
@@ -763,6 +811,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
bodySnippet: comment.body.slice(0, 120),
|
bodySnippet: comment.body.slice(0, 120),
|
||||||
identifier: currentIssue.identifier,
|
identifier: currentIssue.identifier,
|
||||||
issueTitle: currentIssue.title,
|
issueTitle: currentIssue.title,
|
||||||
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -781,6 +831,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
reopenedFrom: reopenFromStatus,
|
reopenedFrom: reopenFromStatus,
|
||||||
mutation: "comment",
|
mutation: "comment",
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
},
|
},
|
||||||
requestedByActorType: actor.actorType,
|
requestedByActorType: actor.actorType,
|
||||||
requestedByActorId: actor.actorId,
|
requestedByActorId: actor.actorId,
|
||||||
@@ -791,6 +842,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
source: "issue.comment.reopen",
|
source: "issue.comment.reopen",
|
||||||
wakeReason: "issue_reopened_via_comment",
|
wakeReason: "issue_reopened_via_comment",
|
||||||
reopenedFrom: reopenFromStatus,
|
reopenedFrom: reopenFromStatus,
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -802,6 +854,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
issueId: currentIssue.id,
|
issueId: currentIssue.id,
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
mutation: "comment",
|
mutation: "comment",
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
},
|
},
|
||||||
requestedByActorType: actor.actorType,
|
requestedByActorType: actor.actorType,
|
||||||
requestedByActorId: actor.actorId,
|
requestedByActorId: actor.actorId,
|
||||||
@@ -811,6 +864,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
source: "issue.comment",
|
source: "issue.comment",
|
||||||
wakeReason: "issue_commented",
|
wakeReason: "issue_commented",
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,57 @@
|
|||||||
import { Router } from "express";
|
import { Router, type Request } from "express";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
import {
|
import {
|
||||||
createProjectSchema,
|
createProjectSchema,
|
||||||
createProjectWorkspaceSchema,
|
createProjectWorkspaceSchema,
|
||||||
|
isUuidLike,
|
||||||
updateProjectSchema,
|
updateProjectSchema,
|
||||||
updateProjectWorkspaceSchema,
|
updateProjectWorkspaceSchema,
|
||||||
} from "@paperclip/shared";
|
} from "@paperclip/shared";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { projectService, logActivity } from "../services/index.js";
|
import { projectService, logActivity } from "../services/index.js";
|
||||||
|
import { conflict } from "../errors.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
|
||||||
export function projectRoutes(db: Db) {
|
export function projectRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = projectService(db);
|
const svc = projectService(db);
|
||||||
|
|
||||||
|
async function resolveCompanyIdForProjectReference(req: Request) {
|
||||||
|
const companyIdQuery = req.query.companyId;
|
||||||
|
const requestedCompanyId =
|
||||||
|
typeof companyIdQuery === "string" && companyIdQuery.trim().length > 0
|
||||||
|
? companyIdQuery.trim()
|
||||||
|
: null;
|
||||||
|
if (requestedCompanyId) {
|
||||||
|
assertCompanyAccess(req, requestedCompanyId);
|
||||||
|
return requestedCompanyId;
|
||||||
|
}
|
||||||
|
if (req.actor.type === "agent" && req.actor.companyId) {
|
||||||
|
return req.actor.companyId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeProjectReference(req: Request, rawId: string) {
|
||||||
|
if (isUuidLike(rawId)) return rawId;
|
||||||
|
const companyId = await resolveCompanyIdForProjectReference(req);
|
||||||
|
if (!companyId) return rawId;
|
||||||
|
const resolved = await svc.resolveByReference(companyId, rawId);
|
||||||
|
if (resolved.ambiguous) {
|
||||||
|
throw conflict("Project shortname is ambiguous in this company. Use the project ID.");
|
||||||
|
}
|
||||||
|
return resolved.project?.id ?? rawId;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.param("id", async (req, _res, next, rawId) => {
|
||||||
|
try {
|
||||||
|
req.params.id = await normalizeProjectReference(req, rawId);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/companies/:companyId/projects", async (req, res) => {
|
router.get("/companies/:companyId/projects", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
heartbeatRunEvents,
|
heartbeatRunEvents,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
} from "@paperclip/db";
|
} from "@paperclip/db";
|
||||||
|
import { isUuidLike, normalizeAgentUrlKey } from "@paperclip/shared";
|
||||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||||
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
||||||
@@ -140,13 +141,20 @@ function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$infe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function agentService(db: Db) {
|
export function agentService(db: Db) {
|
||||||
function normalizeAgentRow(row: typeof agents.$inferSelect) {
|
function withUrlKey<T extends { id: string; name: string }>(row: T) {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
permissions: normalizeAgentPermissions(row.permissions, row.role),
|
urlKey: normalizeAgentUrlKey(row.name) ?? row.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAgentRow(row: typeof agents.$inferSelect) {
|
||||||
|
return withUrlKey({
|
||||||
|
...row,
|
||||||
|
permissions: normalizeAgentPermissions(row.permissions, row.role),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function getById(id: string) {
|
async function getById(id: string) {
|
||||||
const row = await db
|
const row = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -502,5 +510,37 @@ export function agentService(db: Db) {
|
|||||||
.select()
|
.select()
|
||||||
.from(heartbeatRuns)
|
.from(heartbeatRuns)
|
||||||
.where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))),
|
.where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))),
|
||||||
|
|
||||||
|
resolveByReference: async (companyId: string, reference: string) => {
|
||||||
|
const raw = reference.trim();
|
||||||
|
if (raw.length === 0) {
|
||||||
|
return { agent: null, ambiguous: false } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUuidLike(raw)) {
|
||||||
|
const byId = await getById(raw);
|
||||||
|
if (!byId || byId.companyId !== companyId) {
|
||||||
|
return { agent: null, ambiguous: false } as const;
|
||||||
|
}
|
||||||
|
return { agent: byId, ambiguous: false } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlKey = normalizeAgentUrlKey(raw);
|
||||||
|
if (!urlKey) {
|
||||||
|
return { agent: null, ambiguous: false } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||||
|
const matches = rows
|
||||||
|
.map(normalizeAgentRow)
|
||||||
|
.filter((agent) => agent.urlKey === urlKey && agent.status !== "terminated");
|
||||||
|
if (matches.length === 1) {
|
||||||
|
return { agent: matches[0] ?? null, ambiguous: false } as const;
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
return { agent: null, ambiguous: true } as const;
|
||||||
|
}
|
||||||
|
return { agent: null, ambiguous: false } as const;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1606,8 +1606,14 @@ export function heartbeatService(db: Db) {
|
|||||||
const executionAgentNameKey =
|
const executionAgentNameKey =
|
||||||
normalizeAgentNameKey(issue.executionAgentNameKey) ??
|
normalizeAgentNameKey(issue.executionAgentNameKey) ??
|
||||||
normalizeAgentNameKey(executionAgent?.name);
|
normalizeAgentNameKey(executionAgent?.name);
|
||||||
|
const isSameExecutionAgent =
|
||||||
|
Boolean(executionAgentNameKey) && executionAgentNameKey === agentNameKey;
|
||||||
|
const shouldQueueFollowupForCommentWake =
|
||||||
|
Boolean(wakeCommentId) &&
|
||||||
|
activeExecutionRun.status === "running" &&
|
||||||
|
isSameExecutionAgent;
|
||||||
|
|
||||||
if (executionAgentNameKey && executionAgentNameKey === agentNameKey) {
|
if (isSameExecutionAgent && !shouldQueueFollowupForCommentWake) {
|
||||||
const mergedContextSnapshot = mergeCoalescedContextSnapshot(
|
const mergedContextSnapshot = mergeCoalescedContextSnapshot(
|
||||||
activeExecutionRun.contextSnapshot,
|
activeExecutionRun.contextSnapshot,
|
||||||
enrichedContextSnapshot,
|
enrichedContextSnapshot,
|
||||||
@@ -1647,6 +1653,47 @@ export function heartbeatService(db: Db) {
|
|||||||
[DEFERRED_WAKE_CONTEXT_KEY]: enrichedContextSnapshot,
|
[DEFERRED_WAKE_CONTEXT_KEY]: enrichedContextSnapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const existingDeferred = await tx
|
||||||
|
.select()
|
||||||
|
.from(agentWakeupRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(agentWakeupRequests.companyId, agent.companyId),
|
||||||
|
eq(agentWakeupRequests.agentId, agentId),
|
||||||
|
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||||
|
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issue.id}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(agentWakeupRequests.requestedAt))
|
||||||
|
.limit(1)
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
|
if (existingDeferred) {
|
||||||
|
const existingDeferredPayload = parseObject(existingDeferred.payload);
|
||||||
|
const existingDeferredContext = parseObject(existingDeferredPayload[DEFERRED_WAKE_CONTEXT_KEY]);
|
||||||
|
const mergedDeferredContext = mergeCoalescedContextSnapshot(
|
||||||
|
existingDeferredContext,
|
||||||
|
enrichedContextSnapshot,
|
||||||
|
);
|
||||||
|
const mergedDeferredPayload = {
|
||||||
|
...existingDeferredPayload,
|
||||||
|
...(payload ?? {}),
|
||||||
|
issueId,
|
||||||
|
[DEFERRED_WAKE_CONTEXT_KEY]: mergedDeferredContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(agentWakeupRequests)
|
||||||
|
.set({
|
||||||
|
payload: mergedDeferredPayload,
|
||||||
|
coalescedCount: (existingDeferred.coalescedCount ?? 0) + 1,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(agentWakeupRequests.id, existingDeferred.id));
|
||||||
|
|
||||||
|
return { kind: "deferred" as const };
|
||||||
|
}
|
||||||
|
|
||||||
await tx.insert(agentWakeupRequests).values({
|
await tx.insert(agentWakeupRequests).values({
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
agentId,
|
agentId,
|
||||||
|
|||||||
@@ -56,7 +56,18 @@ export interface IssueFilters {
|
|||||||
|
|
||||||
type IssueRow = typeof issues.$inferSelect;
|
type IssueRow = typeof issues.$inferSelect;
|
||||||
type IssueLabelRow = typeof labels.$inferSelect;
|
type IssueLabelRow = typeof labels.$inferSelect;
|
||||||
|
type IssueActiveRunRow = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
agentId: string;
|
||||||
|
invocationSource: string;
|
||||||
|
triggerDetail: string | null;
|
||||||
|
startedAt: Date | null;
|
||||||
|
finishedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] };
|
type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] };
|
||||||
|
type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null };
|
||||||
|
|
||||||
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;
|
||||||
@@ -103,6 +114,53 @@ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWith
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||||
|
|
||||||
|
async function activeRunMapForIssues(
|
||||||
|
dbOrTx: any,
|
||||||
|
issueRows: IssueWithLabels[],
|
||||||
|
): Promise<Map<string, IssueActiveRunRow>> {
|
||||||
|
const map = new Map<string, IssueActiveRunRow>();
|
||||||
|
const runIds = issueRows
|
||||||
|
.map((row) => row.executionRunId)
|
||||||
|
.filter((id): id is string => id != null);
|
||||||
|
if (runIds.length === 0) return map;
|
||||||
|
|
||||||
|
const rows = await dbOrTx
|
||||||
|
.select({
|
||||||
|
id: heartbeatRuns.id,
|
||||||
|
status: heartbeatRuns.status,
|
||||||
|
agentId: heartbeatRuns.agentId,
|
||||||
|
invocationSource: heartbeatRuns.invocationSource,
|
||||||
|
triggerDetail: heartbeatRuns.triggerDetail,
|
||||||
|
startedAt: heartbeatRuns.startedAt,
|
||||||
|
finishedAt: heartbeatRuns.finishedAt,
|
||||||
|
createdAt: heartbeatRuns.createdAt,
|
||||||
|
})
|
||||||
|
.from(heartbeatRuns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(heartbeatRuns.id, runIds),
|
||||||
|
inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
map.set(row.id, row);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withActiveRuns(
|
||||||
|
issueRows: IssueWithLabels[],
|
||||||
|
runMap: Map<string, IssueActiveRunRow>,
|
||||||
|
): IssueWithLabelsAndRun[] {
|
||||||
|
return issueRows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
activeRun: row.executionRunId ? (runMap.get(row.executionRunId) ?? null) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function issueService(db: Db) {
|
export function issueService(db: Db) {
|
||||||
async function assertAssignableAgent(companyId: string, agentId: string) {
|
async function assertAssignableAgent(companyId: string, agentId: string) {
|
||||||
const assignee = await db
|
const assignee = await db
|
||||||
@@ -293,7 +351,9 @@ export function issueService(db: Db) {
|
|||||||
.from(issues)
|
.from(issues)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
|
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
|
||||||
return withIssueLabels(db, rows);
|
const withLabels = await withIssueLabels(db, rows);
|
||||||
|
const runMap = await activeRunMapForIssues(db, withLabels);
|
||||||
|
return withActiveRuns(withLabels, runMap);
|
||||||
},
|
},
|
||||||
|
|
||||||
getById: async (id: string) => {
|
getById: async (id: string) => {
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
import { projects, projectGoals, goals, projectWorkspaces } from "@paperclip/db";
|
import { projects, projectGoals, goals, projectWorkspaces } from "@paperclip/db";
|
||||||
import { PROJECT_COLORS, type ProjectGoalRef, type ProjectWorkspace } from "@paperclip/shared";
|
import {
|
||||||
|
PROJECT_COLORS,
|
||||||
|
deriveProjectUrlKey,
|
||||||
|
isUuidLike,
|
||||||
|
normalizeProjectUrlKey,
|
||||||
|
type ProjectGoalRef,
|
||||||
|
type ProjectWorkspace,
|
||||||
|
} from "@paperclip/shared";
|
||||||
|
|
||||||
type ProjectRow = typeof projects.$inferSelect;
|
type ProjectRow = typeof projects.$inferSelect;
|
||||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||||
@@ -17,6 +24,7 @@ type CreateWorkspaceInput = {
|
|||||||
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
||||||
|
|
||||||
interface ProjectWithGoals extends ProjectRow {
|
interface ProjectWithGoals extends ProjectRow {
|
||||||
|
urlKey: string;
|
||||||
goalIds: string[];
|
goalIds: string[];
|
||||||
goals: ProjectGoalRef[];
|
goals: ProjectGoalRef[];
|
||||||
workspaces: ProjectWorkspace[];
|
workspaces: ProjectWorkspace[];
|
||||||
@@ -52,7 +60,12 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
|
|||||||
|
|
||||||
return rows.map((r) => {
|
return rows.map((r) => {
|
||||||
const g = map.get(r.id) ?? [];
|
const g = map.get(r.id) ?? [];
|
||||||
return { ...r, goalIds: g.map((x) => x.id), goals: g } as ProjectWithGoals;
|
return {
|
||||||
|
...r,
|
||||||
|
urlKey: deriveProjectUrlKey(r.name, r.id),
|
||||||
|
goalIds: g.map((x) => x.id),
|
||||||
|
goals: g,
|
||||||
|
} as ProjectWithGoals;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +327,11 @@ export function projectService(db: Db) {
|
|||||||
.delete(projects)
|
.delete(projects)
|
||||||
.where(eq(projects.id, id))
|
.where(eq(projects.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0] ?? null),
|
.then((rows) => {
|
||||||
|
const row = rows[0] ?? null;
|
||||||
|
if (!row) return null;
|
||||||
|
return { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) };
|
||||||
|
}),
|
||||||
|
|
||||||
listWorkspaces: async (projectId: string): Promise<ProjectWorkspace[]> => {
|
listWorkspaces: async (projectId: string): Promise<ProjectWorkspace[]> => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -555,5 +572,47 @@ export function projectService(db: Db) {
|
|||||||
|
|
||||||
return removed ? toWorkspace(removed) : null;
|
return removed ? toWorkspace(removed) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resolveByReference: async (companyId: string, reference: string) => {
|
||||||
|
const raw = reference.trim();
|
||||||
|
if (raw.length === 0) {
|
||||||
|
return { project: null, ambiguous: false } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUuidLike(raw)) {
|
||||||
|
const row = await db
|
||||||
|
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||||
|
.from(projects)
|
||||||
|
.where(and(eq(projects.id, raw), eq(projects.companyId, companyId)))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!row) return { project: null, ambiguous: false } as const;
|
||||||
|
return {
|
||||||
|
project: { id: row.id, companyId: row.companyId, urlKey: deriveProjectUrlKey(row.name, row.id) },
|
||||||
|
ambiguous: false,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlKey = normalizeProjectUrlKey(raw);
|
||||||
|
if (!urlKey) {
|
||||||
|
return { project: null, ambiguous: false } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.companyId, companyId));
|
||||||
|
const matches = rows.filter((row) => deriveProjectUrlKey(row.name, row.id) === urlKey);
|
||||||
|
if (matches.length === 1) {
|
||||||
|
const match = matches[0]!;
|
||||||
|
return {
|
||||||
|
project: { id: match.id, companyId: match.companyId, urlKey: deriveProjectUrlKey(match.name, match.id) },
|
||||||
|
ambiguous: false,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
return { project: null, ambiguous: true } as const;
|
||||||
|
}
|
||||||
|
return { project: null, ambiguous: false } as const;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user