mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Merge remote-tracking branch 'public-gh/master' into PAP-989-multi-user-implementation-using-plan-from-pap-958
* public-gh/master: (32 commits) fix(dev-runner): tighten worktree env bootstrap fix(inbox): avoid refetching on filter-only changes fix(codex-local): avoid fast mode in env probe fix: isolate dev runner worktree env Broaden comment matches in issue search Add inbox issue search fallback feat(codex-local): add fast mode support Harden tailnet bind setup Fix dev runner workspace import regression Introduce bind presets for deployment setup fix: address PR 3355 review regressions test(ui): fix heartbeat run fixture drift fix: restore inbox optimistic run fixture fix: keep thread polish independent of quicklook routing fix: keep runtime UI changes self-contained fix: keep inbox quicklook and tests standalone fix: use CLI tsx entrypoint for workspace preflight chore: improve worktree tooling and security docs feat: polish issue thread markdown and references fix: harden heartbeat and adapter runtime workflows ... # Conflicts: # cli/src/__tests__/worktree.test.ts # scripts/ensure-workspace-package-links.ts # server/src/__tests__/claude-local-execute.test.ts # server/src/__tests__/issues-service.test.ts # ui/src/components/Layout.tsx
This commit is contained in:
commit
e6fd9d29db
219 files changed
+24351
-2690
No files matched your search
+78
-8
@@ -6,6 +6,7 @@ import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
||||
import { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts";
|
||||
import {
|
||||
findAdoptableLocalService,
|
||||
removeLocalServiceRegistryRecord,
|
||||
@@ -13,6 +14,19 @@ import {
|
||||
writeLocalServiceRegistryRecord,
|
||||
} from "../server/src/services/local-service-supervisor.ts";
|
||||
|
||||
// Keep these values local so the dev runner can boot from the server package's
|
||||
// tsx context without requiring workspace package resolution first.
|
||||
const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const;
|
||||
type BindMode = (typeof BIND_MODES)[number];
|
||||
|
||||
const worktreeEnvBootstrap = bootstrapDevRunnerWorktreeEnv(repoRoot, process.env);
|
||||
if (worktreeEnvBootstrap.missingEnv) {
|
||||
console.error(
|
||||
`[paperclip] linked git worktree at ${repoRoot} is missing ${path.relative(repoRoot, worktreeEnvBootstrap.envPath)}. Run \`paperclipai worktree init\` in this worktree before \`pnpm dev\`.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||
const cliArgs = process.argv.slice(3);
|
||||
const scanIntervalMs = 1500;
|
||||
@@ -61,13 +75,36 @@ const tailscaleAuthFlagNames = new Set([
|
||||
]);
|
||||
|
||||
let tailscaleAuth = false;
|
||||
let bindMode: BindMode | null = null;
|
||||
let bindHost: string | null = null;
|
||||
const forwardedArgs: string[] = [];
|
||||
|
||||
for (const arg of cliArgs) {
|
||||
for (let index = 0; index < cliArgs.length; index += 1) {
|
||||
const arg = cliArgs[index];
|
||||
if (tailscaleAuthFlagNames.has(arg)) {
|
||||
tailscaleAuth = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--bind") {
|
||||
const value = cliArgs[index + 1];
|
||||
if (!value || value.startsWith("--") || !BIND_MODES.includes(value as BindMode)) {
|
||||
console.error(`[paperclip] invalid --bind value. Use one of: ${BIND_MODES.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
bindMode = value as BindMode;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--bind-host") {
|
||||
const value = cliArgs[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
console.error("[paperclip] --bind-host requires a value");
|
||||
process.exit(1);
|
||||
}
|
||||
bindHost = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
forwardedArgs.push(arg);
|
||||
}
|
||||
|
||||
@@ -77,6 +114,16 @@ if (process.env.npm_config_tailscale_auth === "true") {
|
||||
if (process.env.npm_config_authenticated_private === "true") {
|
||||
tailscaleAuth = true;
|
||||
}
|
||||
if (!bindMode && process.env.npm_config_bind && BIND_MODES.includes(process.env.npm_config_bind as BindMode)) {
|
||||
bindMode = process.env.npm_config_bind as BindMode;
|
||||
}
|
||||
if (!bindHost && process.env.npm_config_bind_host) {
|
||||
bindHost = process.env.npm_config_bind_host;
|
||||
}
|
||||
if (bindMode === "custom" && !bindHost) {
|
||||
console.error("[paperclip] --bind custom requires --bind-host <host>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
@@ -93,13 +140,36 @@ if (mode === "watch") {
|
||||
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
|
||||
}
|
||||
|
||||
if (tailscaleAuth) {
|
||||
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
||||
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
|
||||
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
|
||||
env.HOST = "0.0.0.0";
|
||||
console.log("[paperclip] dev mode: authenticated/private (tailscale-friendly) on 0.0.0.0");
|
||||
if (tailscaleAuth || bindMode) {
|
||||
const effectiveBind = bindMode ?? "lan";
|
||||
if (tailscaleAuth) {
|
||||
console.log("[paperclip] note: --tailscale-auth/--authenticated-private are legacy aliases for --bind lan");
|
||||
}
|
||||
env.PAPERCLIP_BIND = effectiveBind;
|
||||
if (bindHost) {
|
||||
env.PAPERCLIP_BIND_HOST = bindHost;
|
||||
} else {
|
||||
delete env.PAPERCLIP_BIND_HOST;
|
||||
}
|
||||
if (effectiveBind === "loopback" && !tailscaleAuth) {
|
||||
delete env.PAPERCLIP_DEPLOYMENT_MODE;
|
||||
delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
||||
delete env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
||||
console.log("[paperclip] dev mode: local_trusted (bind=loopback)");
|
||||
} else {
|
||||
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
||||
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
|
||||
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
|
||||
console.log(
|
||||
`[paperclip] dev mode: authenticated/private (bind=${effectiveBind}${bindHost ? `:${bindHost}` : ""})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
delete env.PAPERCLIP_BIND;
|
||||
delete env.PAPERCLIP_BIND_HOST;
|
||||
delete env.PAPERCLIP_DEPLOYMENT_MODE;
|
||||
delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
||||
delete env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
||||
console.log("[paperclip] dev mode: local_trusted (default)");
|
||||
}
|
||||
|
||||
@@ -107,7 +177,7 @@ const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) |
|
||||
const devService = createDevServiceIdentity({
|
||||
mode,
|
||||
forwardedArgs,
|
||||
tailscaleAuth,
|
||||
networkProfile: tailscaleAuth ? `legacy:${bindMode ?? "lan"}` : (bindMode ?? "default"),
|
||||
port: serverPort,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)
|
||||
export function createDevServiceIdentity(input: {
|
||||
mode: "watch" | "dev";
|
||||
forwardedArgs: string[];
|
||||
tailscaleAuth: boolean;
|
||||
networkProfile: string;
|
||||
port: number;
|
||||
}) {
|
||||
const envFingerprint = createHash("sha256")
|
||||
@@ -16,7 +16,7 @@ export function createDevServiceIdentity(input: {
|
||||
JSON.stringify({
|
||||
mode: input.mode,
|
||||
forwardedArgs: input.forwardedArgs,
|
||||
tailscaleAuth: input.tailscaleAuth,
|
||||
networkProfile: input.networkProfile,
|
||||
port: input.port,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { repoRoot } from "./dev-service-profile.ts";
|
||||
|
||||
type WorkspaceLinkMismatch = {
|
||||
workspaceDir: string;
|
||||
packageName: string;
|
||||
expectedPath: string;
|
||||
actualPath: string | null;
|
||||
@@ -14,44 +15,77 @@ function readJsonFile(filePath: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolveWorkspacePackagePath(packageName: string): string | null {
|
||||
if (packageName === "@paperclipai/adapter-utils") {
|
||||
return path.join(repoRoot, "packages", "adapter-utils");
|
||||
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
||||
const packagePaths = new Map<string, string>();
|
||||
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
|
||||
|
||||
function visit(dirPath: string) {
|
||||
const packageJsonPath = path.join(dirPath, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
|
||||
packagePaths.set(packageJson.name, dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (ignoredDirNames.has(entry.name)) continue;
|
||||
visit(path.join(dirPath, entry.name));
|
||||
}
|
||||
}
|
||||
if (packageName === "@paperclipai/db") {
|
||||
return path.join(repoRoot, "packages", "db");
|
||||
}
|
||||
if (packageName === "@paperclipai/shared") {
|
||||
return path.join(repoRoot, "packages", "shared");
|
||||
}
|
||||
if (packageName === "@paperclipai/plugin-sdk") {
|
||||
return path.join(repoRoot, "packages", "plugins", "sdk");
|
||||
}
|
||||
if (packageName.startsWith("@paperclipai/adapter-")) {
|
||||
return path.join(repoRoot, "packages", "adapters", packageName.slice("@paperclipai/adapter-".length));
|
||||
}
|
||||
return null;
|
||||
|
||||
visit(path.join(rootDir, "packages"));
|
||||
visit(path.join(rootDir, "server"));
|
||||
visit(path.join(rootDir, "ui"));
|
||||
visit(path.join(rootDir, "cli"));
|
||||
|
||||
return packagePaths;
|
||||
}
|
||||
|
||||
function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
|
||||
const serverPackageJson = readJsonFile(path.join(repoRoot, "server", "package.json"));
|
||||
function isLinkedGitWorktreeCheckout(rootDir: string) {
|
||||
const gitMetadataPath = path.join(rootDir, ".git");
|
||||
if (!existsSync(gitMetadataPath)) return false;
|
||||
|
||||
const stat = lstatSync(gitMetadataPath);
|
||||
if (!stat.isFile()) return false;
|
||||
|
||||
return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:");
|
||||
}
|
||||
|
||||
if (!isLinkedGitWorktreeCheckout(repoRoot)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
|
||||
const workspaceDirs = Array.from(
|
||||
new Set(
|
||||
Array.from(workspacePackagePaths.values())
|
||||
.map((packagePath) => path.relative(repoRoot, packagePath))
|
||||
.filter((workspaceDir) => workspaceDir.length > 0),
|
||||
),
|
||||
).sort();
|
||||
|
||||
function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] {
|
||||
const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json"));
|
||||
const dependencies = {
|
||||
...(serverPackageJson.dependencies as Record<string, unknown> | undefined),
|
||||
...(serverPackageJson.devDependencies as Record<string, unknown> | undefined),
|
||||
...(packageJson.dependencies as Record<string, unknown> | undefined),
|
||||
...(packageJson.devDependencies as Record<string, unknown> | undefined),
|
||||
};
|
||||
const mismatches: WorkspaceLinkMismatch[] = [];
|
||||
|
||||
for (const [packageName, version] of Object.entries(dependencies)) {
|
||||
if (typeof version !== "string" || !version.startsWith("workspace:")) continue;
|
||||
|
||||
const expectedPath = resolveWorkspacePackagePath(packageName);
|
||||
const expectedPath = workspacePackagePaths.get(packageName);
|
||||
if (!expectedPath) continue;
|
||||
|
||||
const linkPath = path.join(repoRoot, "server", "node_modules", ...packageName.split("/"));
|
||||
const linkPath = path.join(repoRoot, workspaceDir, "node_modules", ...packageName.split("/"));
|
||||
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
|
||||
if (actualPath === path.resolve(expectedPath)) continue;
|
||||
|
||||
mismatches.push({
|
||||
workspaceDir,
|
||||
packageName,
|
||||
expectedPath: path.resolve(expectedPath),
|
||||
actualPath,
|
||||
@@ -61,53 +95,32 @@ function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], cwd: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`${command} ${args.join(" ")} failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureServerWorkspaceLinksCurrent() {
|
||||
const mismatches = findServerWorkspaceLinkMismatches();
|
||||
async function ensureWorkspaceLinksCurrent(workspaceDir: string) {
|
||||
const mismatches = findWorkspaceLinkMismatches(workspaceDir);
|
||||
if (mismatches.length === 0) return;
|
||||
|
||||
console.log("[paperclip] detected stale workspace package links for server; relinking dependencies...");
|
||||
console.log(`[paperclip] detected stale workspace package links for ${workspaceDir}; relinking dependencies...`);
|
||||
for (const mismatch of mismatches) {
|
||||
console.log(
|
||||
`[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
await runCommand(
|
||||
pnpmBin,
|
||||
["install", "--force", "--config.confirmModulesPurge=false"],
|
||||
repoRoot,
|
||||
);
|
||||
for (const mismatch of mismatches) {
|
||||
const linkPath = path.join(repoRoot, mismatch.workspaceDir, "node_modules", ...mismatch.packageName.split("/"));
|
||||
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
await fs.symlink(mismatch.expectedPath, linkPath);
|
||||
}
|
||||
|
||||
const remainingMismatches = findServerWorkspaceLinkMismatches();
|
||||
const remainingMismatches = findWorkspaceLinkMismatches(workspaceDir);
|
||||
if (remainingMismatches.length === 0) return;
|
||||
|
||||
throw new Error(
|
||||
`Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
|
||||
`Workspace relink did not repair all ${workspaceDir} package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
await ensureServerWorkspaceLinksCurrent();
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
await ensureWorkspaceLinksCurrent(workspaceDir);
|
||||
}
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/paperclip-issue-update.sh [--issue-id ID] [--status STATUS] [--comment TEXT] [--dry-run]
|
||||
|
||||
Reads a multiline markdown comment from stdin when stdin is piped. This preserves
|
||||
newlines when building the JSON payload for PATCH /api/issues/{issueId}.
|
||||
|
||||
Examples:
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD'
|
||||
Investigating formatting
|
||||
|
||||
- Pulled the raw comment body
|
||||
- Comparing it with the run transcript
|
||||
MD
|
||||
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done --dry-run <<'MD'
|
||||
Done
|
||||
|
||||
- Fixed the issue update helper
|
||||
MD
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
printf 'Missing required command: %s\n' "$1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
issue_id="${PAPERCLIP_TASK_ID:-}"
|
||||
status=""
|
||||
comment_arg=""
|
||||
dry_run=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--issue-id)
|
||||
issue_id="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--status)
|
||||
status="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--comment)
|
||||
comment_arg="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$issue_id" ]]; then
|
||||
printf 'Missing issue id. Pass --issue-id or set PAPERCLIP_TASK_ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
comment=""
|
||||
if [[ -n "$comment_arg" ]]; then
|
||||
comment="$comment_arg"
|
||||
elif [[ ! -t 0 ]]; then
|
||||
comment="$(cat)"
|
||||
fi
|
||||
|
||||
require_command jq
|
||||
|
||||
payload="$(
|
||||
jq -nc \
|
||||
--arg status "$status" \
|
||||
--arg comment "$comment" \
|
||||
'
|
||||
(if $status == "" then {} else {status: $status} end) +
|
||||
(if $comment == "" then {} else {comment: $comment} end)
|
||||
'
|
||||
)"
|
||||
|
||||
if [[ "$dry_run" == "1" ]]; then
|
||||
printf '%s\n' "$payload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
|
||||
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -sS -X PATCH \
|
||||
"$PAPERCLIP_API_URL/api/issues/$issue_id" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary "$payload"
|
||||
@@ -246,6 +246,8 @@ async function main() {
|
||||
server: {
|
||||
deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted",
|
||||
exposure: sourceConfig?.server?.exposure ?? "private",
|
||||
...(sourceConfig?.server?.bind ? { bind: sourceConfig.server.bind } : {}),
|
||||
...(sourceConfig?.server?.customBindHost ? { customBindHost: sourceConfig.server.customBindHost } : {}),
|
||||
host: sourceConfig?.server?.host ?? "127.0.0.1",
|
||||
port: serverPort,
|
||||
allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [],
|
||||
@@ -337,20 +339,6 @@ else
|
||||
write_fallback_worktree_config
|
||||
fi
|
||||
|
||||
disable_seeded_routines() {
|
||||
local company_id="${PAPERCLIP_COMPANY_ID:-}"
|
||||
if [[ -z "$company_id" ]]; then
|
||||
echo "PAPERCLIP_COMPANY_ID not set; skipping routine disable post-step." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! run_paperclipai_command routines disable-all --config "$worktree_config_path" --company-id "$company_id"; then
|
||||
echo "paperclipai CLI not available in this workspace; skipping routine disable post-step." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
disable_seeded_routines
|
||||
|
||||
list_base_node_modules_paths() {
|
||||
cd "$base_cwd" &&
|
||||
find . \
|
||||
|
||||
Reference in new issue
Block a user