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:
Dotta committed 2026-04-11 09:12:41 -05:00
commit e6fd9d29db
219 files changed
+24351 -2690

No files matched your search

+78 -8
View File
@@ -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,
});
+2 -2
View File
@@ -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,
}),
)
+72 -59
View File
@@ -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);
}
+110
View File
@@ -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"
+2 -14
View File
@@ -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 . \