mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 23:01:36 +00:00
Integrate OpenHandoff factory workspace (#212)
This commit is contained in:
parent
3d9476ed0b
commit
bf282199b5
251 changed files with 42824 additions and 692 deletions
128
factory/packages/backend/src/services/create-flow.ts
Normal file
128
factory/packages/backend/src/services/create-flow.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
export interface ResolveCreateFlowDecisionInput {
|
||||
task: string;
|
||||
explicitTitle?: string;
|
||||
explicitBranchName?: string;
|
||||
localBranches: string[];
|
||||
handoffBranches: string[];
|
||||
}
|
||||
|
||||
export interface ResolveCreateFlowDecisionResult {
|
||||
title: string;
|
||||
branchName: string;
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(input: string): string {
|
||||
const lines = input
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
return lines[0] ?? "";
|
||||
}
|
||||
|
||||
export function deriveFallbackTitle(task: string, explicitTitle?: string): string {
|
||||
const source = (explicitTitle && explicitTitle.trim()) || firstNonEmptyLine(task) || "update handoff";
|
||||
const explicitPrefixMatch = source.match(/^\s*(feat|fix|docs|refactor):\s+(.+)$/i);
|
||||
if (explicitPrefixMatch) {
|
||||
const explicitTypePrefix = explicitPrefixMatch[1]!.toLowerCase();
|
||||
const explicitSummary = explicitPrefixMatch[2]!
|
||||
.split("")
|
||||
.map((char) => (/^[a-zA-Z0-9 -]$/.test(char) ? char : " "))
|
||||
.join("")
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 0)
|
||||
.join(" ")
|
||||
.slice(0, 62)
|
||||
.trim();
|
||||
|
||||
return `${explicitTypePrefix}: ${explicitSummary || "update handoff"}`;
|
||||
}
|
||||
|
||||
const lowered = source.toLowerCase();
|
||||
|
||||
const typePrefix = lowered.includes("fix") || lowered.includes("bug")
|
||||
? "fix"
|
||||
: lowered.includes("doc") || lowered.includes("readme")
|
||||
? "docs"
|
||||
: lowered.includes("refactor")
|
||||
? "refactor"
|
||||
: "feat";
|
||||
|
||||
const cleaned = source
|
||||
.split("")
|
||||
.map((char) => (/^[a-zA-Z0-9 -]$/.test(char) ? char : " "))
|
||||
.join("")
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 0)
|
||||
.join(" ");
|
||||
|
||||
const summary = (cleaned || "update handoff").slice(0, 62).trim();
|
||||
return `${typePrefix}: ${summary}`.trim();
|
||||
}
|
||||
|
||||
export function sanitizeBranchName(input: string): string {
|
||||
const normalized = input
|
||||
.toLowerCase()
|
||||
.split("")
|
||||
.map((char) => (/^[a-z0-9]$/.test(char) ? char : "-"))
|
||||
.join("");
|
||||
|
||||
let result = "";
|
||||
let previousDash = false;
|
||||
for (const char of normalized) {
|
||||
if (char === "-") {
|
||||
if (!previousDash && result.length > 0) {
|
||||
result += char;
|
||||
}
|
||||
previousDash = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
result += char;
|
||||
previousDash = false;
|
||||
}
|
||||
|
||||
const trimmed = result.replace(/-+$/g, "");
|
||||
if (trimmed.length <= 50) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.slice(0, 50).replace(/-+$/g, "");
|
||||
}
|
||||
|
||||
export function resolveCreateFlowDecision(
|
||||
input: ResolveCreateFlowDecisionInput
|
||||
): ResolveCreateFlowDecisionResult {
|
||||
const explicitBranch = input.explicitBranchName?.trim();
|
||||
const title = deriveFallbackTitle(input.task, input.explicitTitle);
|
||||
const generatedBase = sanitizeBranchName(title) || "handoff";
|
||||
|
||||
const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase;
|
||||
|
||||
const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0));
|
||||
const existingHandoffBranches = new Set(
|
||||
input.handoffBranches.map((value) => value.trim()).filter((value) => value.length > 0)
|
||||
);
|
||||
const conflicts = (name: string): boolean =>
|
||||
existingBranches.has(name) || existingHandoffBranches.has(name);
|
||||
|
||||
if (explicitBranch && conflicts(branchBase)) {
|
||||
throw new Error(
|
||||
`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`
|
||||
);
|
||||
}
|
||||
|
||||
if (explicitBranch) {
|
||||
return { title, branchName: branchBase };
|
||||
}
|
||||
|
||||
let candidate = branchBase;
|
||||
let index = 2;
|
||||
while (conflicts(candidate)) {
|
||||
candidate = `${branchBase}-${index}`;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
branchName: candidate
|
||||
};
|
||||
}
|
||||
25
factory/packages/backend/src/services/openhandoff-paths.ts
Normal file
25
factory/packages/backend/src/services/openhandoff-paths.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { AppConfig } from "@openhandoff/shared";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
function expandPath(input: string): string {
|
||||
if (input.startsWith("~/")) {
|
||||
return `${homedir()}/${input.slice(2)}`;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function openhandoffDataDir(config: AppConfig): string {
|
||||
// Keep data collocated with the backend DB by default.
|
||||
const dbPath = expandPath(config.backend.dbPath);
|
||||
return resolve(dirname(dbPath));
|
||||
}
|
||||
|
||||
export function openhandoffRepoClonePath(
|
||||
config: AppConfig,
|
||||
workspaceId: string,
|
||||
repoId: string
|
||||
): string {
|
||||
return resolve(join(openhandoffDataDir(config), "repos", workspaceId, repoId));
|
||||
}
|
||||
|
||||
16
factory/packages/backend/src/services/queue.ts
Normal file
16
factory/packages/backend/src/services/queue.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
interface QueueSendResult {
|
||||
status: "completed" | "timedOut";
|
||||
response?: unknown;
|
||||
}
|
||||
|
||||
export function expectQueueResponse<T>(result: QueueSendResult | void): T {
|
||||
if (!result || result.status === "timedOut") {
|
||||
throw new Error("Queue command timed out");
|
||||
}
|
||||
return result.response as T;
|
||||
}
|
||||
|
||||
export function normalizeMessages<T>(input: T | T[] | null | undefined): T[] {
|
||||
if (!input) return [];
|
||||
return Array.isArray(input) ? input : [input];
|
||||
}
|
||||
45
factory/packages/backend/src/services/repo-git-lock.ts
Normal file
45
factory/packages/backend/src/services/repo-git-lock.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
interface RepoLockState {
|
||||
locked: boolean;
|
||||
waiters: Array<() => void>;
|
||||
}
|
||||
|
||||
const repoLocks = new Map<string, RepoLockState>();
|
||||
|
||||
async function acquireRepoLock(repoPath: string): Promise<() => void> {
|
||||
let state = repoLocks.get(repoPath);
|
||||
if (!state) {
|
||||
state = { locked: false, waiters: [] };
|
||||
repoLocks.set(repoPath, state);
|
||||
}
|
||||
|
||||
if (!state.locked) {
|
||||
state.locked = true;
|
||||
return () => releaseRepoLock(repoPath, state);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
state!.waiters.push(resolve);
|
||||
});
|
||||
|
||||
return () => releaseRepoLock(repoPath, state!);
|
||||
}
|
||||
|
||||
function releaseRepoLock(repoPath: string, state: RepoLockState): void {
|
||||
const next = state.waiters.shift();
|
||||
if (next) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
state.locked = false;
|
||||
repoLocks.delete(repoPath);
|
||||
}
|
||||
|
||||
export async function withRepoGitLock<T>(repoPath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const release = await acquireRepoLock(repoPath);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
50
factory/packages/backend/src/services/repo.ts
Normal file
50
factory/packages/backend/src/services/repo.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
export function normalizeRemoteUrl(remoteUrl: string): string {
|
||||
let value = remoteUrl.trim();
|
||||
if (!value) return "";
|
||||
|
||||
// Strip trailing slashes to make hashing stable.
|
||||
value = value.replace(/\/+$/, "");
|
||||
|
||||
// GitHub shorthand: owner/repo -> https://github.com/owner/repo.git
|
||||
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) {
|
||||
return `https://github.com/${value}.git`;
|
||||
}
|
||||
|
||||
// If a user pastes "github.com/owner/repo", treat it as HTTPS.
|
||||
if (/^(?:www\.)?github\.com\/.+/i.test(value)) {
|
||||
value = `https://${value.replace(/^www\./i, "")}`;
|
||||
}
|
||||
|
||||
// Canonicalize GitHub URLs to the repo clone URL (drop /tree/*, issues, etc).
|
||||
// This makes "https://github.com/owner/repo" and ".../tree/main" map to the same repoId.
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
const url = new URL(value);
|
||||
const hostname = url.hostname.replace(/^www\./i, "");
|
||||
if (hostname.toLowerCase() === "github.com") {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
const owner = parts[0]!;
|
||||
const repo = parts[1]!;
|
||||
const base = `${url.protocol}//${hostname}/${owner}/${repo.replace(/\.git$/i, "")}.git`;
|
||||
return base;
|
||||
}
|
||||
}
|
||||
// Drop query/fragment for stability.
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
} catch {
|
||||
// ignore parse failures; fall through to raw value
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function repoIdFromRemote(remoteUrl: string): string {
|
||||
const normalized = normalizeRemoteUrl(remoteUrl);
|
||||
return createHash("sha1").update(normalized).digest("hex").slice(0, 16);
|
||||
}
|
||||
60
factory/packages/backend/src/services/tmux.ts
Normal file
60
factory/packages/backend/src/services/tmux.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
|
||||
const SYMBOL_RUNNING = "▶";
|
||||
const SYMBOL_IDLE = "✓";
|
||||
|
||||
function stripStatusPrefix(windowName: string): string {
|
||||
return windowName
|
||||
.trimStart()
|
||||
.replace(new RegExp(`^${SYMBOL_RUNNING}\\s+`), "")
|
||||
.replace(new RegExp(`^${SYMBOL_IDLE}\\s+`), "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function setWindowStatus(branchName: string, status: string): number {
|
||||
let symbol: string;
|
||||
if (status === "running") {
|
||||
symbol = SYMBOL_RUNNING;
|
||||
} else if (status === "idle") {
|
||||
symbol = SYMBOL_IDLE;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync(
|
||||
"tmux",
|
||||
["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
||||
);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lines = stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
let count = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(":", 3);
|
||||
if (parts.length !== 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionName = parts[0] ?? "";
|
||||
const windowId = parts[1] ?? "";
|
||||
const windowName = parts[2] ?? "";
|
||||
const clean = stripStatusPrefix(windowName);
|
||||
if (clean !== branchName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newName = `${symbol} ${branchName}`;
|
||||
spawnSync("tmux", ["rename-window", "-t", `${sessionName}:${windowId}`, newName], {
|
||||
stdio: "ignore"
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue