feat(pipeline): E+F+G — file artifacts, git push, deploy URL
E - Dashboard drawer 산출물 섹션
- completed event payload 의 file / extractedFiles / deployUrl / repoUrl 추출
- 파일 배지 + 언어별 색상 (html/js/ts/css/json/md/url)
- URL 은 클릭 가능한 링크
F - Gitea auto-commit + push (narang/implement stage)
- sister-agent/src/git-ops.ts — ensureGiteaRepo + commitAndPush
Gitea API POST /api/v1/orgs/{org}/repos 로 repo 자동 생성
git init + add + commit + push (HTTPS + token in URL)
repo 이름: rails-<last-10-of-pipeline-id>
사용자: rails-agent <rails@hanarang.local>
- spawn.ts: implement stage 종료 시점에 commitAndPush 호출
gitResult.repoUrl/rawUrlBase/commit/filesCount 를 manager completed event 에 포함
- buildSuccessResult: implement HandoffMessage.selfTestReport 에 git 메타 포함
G - Deploy stage preview URL
- runner.ts extractStageText: implement 단계에서 rawUrlBase/repoUrl/filesCount 파싱해서 priorStages text 에 포함
- spawn.ts derivePreviewUrl(): priorStages 의 implement 텍스트에서 rawUrlBase 추출 + HTML 파일 경로 힌트 조합
- deploy manager completed event 에 deployUrl 포함
- 결국 dashboard drawer 에 클릭 가능한 preview URL 이 뜸
Env 설정:
- sister-agent/.env 에 GITEA_TOKEN, GITEA_BASE_URL, GITEA_ORG, GIT_USER_*
- start-sister-agent.sh 가 .env 를 source
- 4자매 LXC 전부 배포 (700 퍼미션)
This commit is contained in:
@@ -1 +1 @@
|
||||
1775819769
|
||||
1775820498
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"timestamp": "2026-04-10T11:16:09Z",
|
||||
"changed_file": "src/code-extractor.ts",
|
||||
"timestamp": "2026-04-10T11:32:37Z",
|
||||
"changed_file": "src/spawn.ts",
|
||||
"test_command": "npm test",
|
||||
"related_test": "",
|
||||
"recommendation": "テストの実行を推奨します"
|
||||
|
||||
228
sister-agent/src/git-ops.ts
Normal file
228
sister-agent/src/git-ops.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
|
||||
const GITEA_BASE_URL =
|
||||
process.env["GITEA_BASE_URL"] ?? "https://git.nabomhalang.co.kr";
|
||||
const GITEA_ORG = process.env["GITEA_ORG"] ?? "hanarang";
|
||||
const GITEA_TOKEN = process.env["GITEA_TOKEN"] ?? "";
|
||||
const GIT_USER_NAME = process.env["GIT_USER_NAME"] ?? "rails-agent";
|
||||
const GIT_USER_EMAIL = process.env["GIT_USER_EMAIL"] ?? "rails@hanarang.local";
|
||||
|
||||
export interface GitPushResult {
|
||||
ok: boolean;
|
||||
repoUrl: string;
|
||||
rawUrlBase: string;
|
||||
commit: string;
|
||||
filesCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function runCmd(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env: Record<string, string> = {},
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolveFn) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...env } as NodeJS.ProcessEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (b: Buffer) => (stdout += b.toString()));
|
||||
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
|
||||
child.on("exit", (code) => resolveFn({ code: code ?? -1, stdout, stderr }));
|
||||
child.on("error", () => resolveFn({ code: -1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureGiteaRepo(name: string, description: string): Promise<boolean> {
|
||||
if (!GITEA_TOKEN) return false;
|
||||
|
||||
// Check if org-level repo exists
|
||||
const checkUrl = `${GITEA_BASE_URL}/api/v1/repos/${GITEA_ORG}/${name}`;
|
||||
try {
|
||||
const res = await fetch(checkUrl, {
|
||||
headers: { Authorization: `token ${GITEA_TOKEN}` },
|
||||
});
|
||||
if (res.ok) return true;
|
||||
if (res.status !== 404) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create the repo under the org
|
||||
const createUrl = `${GITEA_BASE_URL}/api/v1/orgs/${GITEA_ORG}/repos`;
|
||||
try {
|
||||
const res = await fetch(createUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${GITEA_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: description.slice(0, 255),
|
||||
private: false,
|
||||
auto_init: false,
|
||||
default_branch: "main",
|
||||
}),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize git in the workspace dir and push everything to a pipeline-specific
|
||||
* repo on Gitea. Returns the repo URL and a commit hash on success.
|
||||
*
|
||||
* The repo name is derived from the pipeline id: `rails-<short>`.
|
||||
* If the repo doesn't exist, it's created via Gitea API.
|
||||
*/
|
||||
export async function commitAndPush(opts: {
|
||||
workspaceDir: string;
|
||||
pipelineId: string;
|
||||
projectName: string;
|
||||
stage: string;
|
||||
agentName: string;
|
||||
}): Promise<GitPushResult> {
|
||||
if (!GITEA_TOKEN) {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: "",
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: "GITEA_TOKEN not configured",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await access(opts.workspaceDir);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: "",
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: `workspace does not exist: ${opts.workspaceDir}`,
|
||||
};
|
||||
}
|
||||
|
||||
const repoName = `rails-${opts.pipelineId.slice(-10).toLowerCase()}`;
|
||||
const description = `Rails pipeline ${opts.pipelineId} — ${opts.projectName}`;
|
||||
const ok = await ensureGiteaRepo(repoName, description);
|
||||
if (!ok) {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: "",
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: `failed to ensure gitea repo ${repoName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const repoUrlHttps = `${GITEA_BASE_URL}/${GITEA_ORG}/${repoName}`;
|
||||
const pushUrl = `${GITEA_BASE_URL.replace(
|
||||
/^https:\/\//,
|
||||
`https://${GIT_USER_NAME}:${GITEA_TOKEN}@`,
|
||||
)}/${GITEA_ORG}/${repoName}.git`;
|
||||
|
||||
const env: Record<string, string> = {
|
||||
GIT_AUTHOR_NAME: GIT_USER_NAME,
|
||||
GIT_AUTHOR_EMAIL: GIT_USER_EMAIL,
|
||||
GIT_COMMITTER_NAME: GIT_USER_NAME,
|
||||
GIT_COMMITTER_EMAIL: GIT_USER_EMAIL,
|
||||
};
|
||||
|
||||
// git init (idempotent)
|
||||
await runCmd("git", ["init", "-b", "main"], opts.workspaceDir, env);
|
||||
await runCmd("git", ["config", "user.name", GIT_USER_NAME], opts.workspaceDir, env);
|
||||
await runCmd("git", ["config", "user.email", GIT_USER_EMAIL], opts.workspaceDir, env);
|
||||
|
||||
// Track all files
|
||||
const addResult = await runCmd("git", ["add", "-A"], opts.workspaceDir, env);
|
||||
if (addResult.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: repoUrlHttps,
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: `git add failed: ${addResult.stderr.slice(0, 300)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const msg = `${opts.agentName}/${opts.stage}: pipeline ${opts.pipelineId.slice(-10)}`;
|
||||
const commitResult = await runCmd(
|
||||
"git",
|
||||
["commit", "-m", msg, "--allow-empty"],
|
||||
opts.workspaceDir,
|
||||
env,
|
||||
);
|
||||
if (commitResult.code !== 0 && !commitResult.stdout.includes("nothing to commit")) {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: repoUrlHttps,
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: `git commit failed: ${commitResult.stderr.slice(0, 300)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Set remote + push
|
||||
await runCmd("git", ["remote", "remove", "origin"], opts.workspaceDir, env);
|
||||
await runCmd("git", ["remote", "add", "origin", pushUrl], opts.workspaceDir, env);
|
||||
|
||||
const pushResult = await runCmd(
|
||||
"git",
|
||||
["push", "-u", "origin", "main", "--force"],
|
||||
opts.workspaceDir,
|
||||
env,
|
||||
);
|
||||
if (pushResult.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: repoUrlHttps,
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: `git push failed: ${pushResult.stderr.slice(0, 400)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch latest commit hash for reporting
|
||||
const hashResult = await runCmd(
|
||||
"git",
|
||||
["rev-parse", "--short", "HEAD"],
|
||||
opts.workspaceDir,
|
||||
env,
|
||||
);
|
||||
const commit = hashResult.stdout.trim();
|
||||
|
||||
// Count files tracked in the commit
|
||||
const fileList = await runCmd(
|
||||
"git",
|
||||
["ls-files"],
|
||||
opts.workspaceDir,
|
||||
env,
|
||||
);
|
||||
const filesCount = fileList.stdout.trim().split("\n").filter(Boolean).length;
|
||||
|
||||
const rawUrlBase = `${repoUrlHttps}/raw/branch/main`;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
repoUrl: repoUrlHttps,
|
||||
rawUrlBase,
|
||||
commit,
|
||||
filesCount,
|
||||
};
|
||||
}
|
||||
@@ -18,8 +18,10 @@ import type { RailsClient } from "./rails-client.js";
|
||||
import { callLlm } from "./llm.js";
|
||||
import { buildPrompt } from "./prompts.js";
|
||||
import { extractCodeBlocks, saveExtractedFiles } from "./code-extractor.js";
|
||||
import { commitAndPush } from "./git-ops.js";
|
||||
|
||||
const USE_REAL_LLM = process.env["RAILS_USE_REAL_LLM"] !== "false";
|
||||
const ENABLE_GIT_PUSH = process.env["RAILS_ENABLE_GIT_PUSH"] !== "false";
|
||||
const WORKSPACE_ROOT =
|
||||
process.env["SISTER_WORKSPACE_DIR"] ??
|
||||
join(homedir(), "rails-projects");
|
||||
@@ -98,10 +100,36 @@ export async function executeInvocation(
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Git push on implement stage — publishes the workspace to Gitea
|
||||
let gitResult: Awaited<ReturnType<typeof commitAndPush>> | null = null;
|
||||
if (ENABLE_GIT_PUSH && req.stage === "implement") {
|
||||
gitResult = await commitAndPush({
|
||||
workspaceDir: join(WORKSPACE_ROOT, req.pipelineId),
|
||||
pipelineId: req.pipelineId,
|
||||
projectName: req.task.title,
|
||||
stage: req.stage,
|
||||
agentName,
|
||||
});
|
||||
}
|
||||
|
||||
// Deploy stage — derive a preview URL from the implement stage output
|
||||
let deployUrl = "";
|
||||
if (req.stage === "deploy") {
|
||||
deployUrl = derivePreviewUrl(req.priorStages ?? []);
|
||||
}
|
||||
|
||||
await rails.recordEvent(managerId, "completed", {
|
||||
verdict: "ok",
|
||||
childCount: childTexts.length,
|
||||
file: managerPath,
|
||||
...(gitResult?.ok && {
|
||||
repoUrl: gitResult.repoUrl,
|
||||
rawUrlBase: gitResult.rawUrlBase,
|
||||
commit: gitResult.commit,
|
||||
filesCount: gitResult.filesCount,
|
||||
}),
|
||||
...(gitResult && !gitResult.ok && { gitError: gitResult.error }),
|
||||
...(deployUrl && { deployUrl }),
|
||||
});
|
||||
|
||||
const aggregated = [managerWork.text, ...childTexts]
|
||||
@@ -109,7 +137,7 @@ export async function executeInvocation(
|
||||
.join("\n\n---\n\n")
|
||||
.slice(0, 6000);
|
||||
|
||||
return buildSuccessResult(req.stage, req.task, aggregated);
|
||||
return buildSuccessResult(req.stage, req.task, aggregated, gitResult);
|
||||
} catch (err) {
|
||||
const errorReason = err instanceof Error ? err.message : String(err);
|
||||
await rails.recordEvent(managerId, "failed", { errorReason });
|
||||
@@ -347,10 +375,40 @@ async function writeOutputFile(
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the prior-stage outputs looking for an implement-stage rawUrlBase,
|
||||
* then produce a preview URL pointing to the first HTML file (or just the
|
||||
* repo URL if we can't find one).
|
||||
*/
|
||||
function derivePreviewUrl(
|
||||
priorStages: Array<{ stage: string; text: string }>,
|
||||
): string {
|
||||
const impl = priorStages.find((s) => s.stage === "implement");
|
||||
if (!impl) return "";
|
||||
const rawBaseMatch = impl.text.match(/rawUrlBase=(\S+)/);
|
||||
const rawBase = rawBaseMatch?.[1];
|
||||
if (!rawBase) return "";
|
||||
|
||||
// Heuristic: if there's an index.html path referenced anywhere in the
|
||||
// priorStages text, link directly to it. Otherwise link to the repo root.
|
||||
const htmlMatch = impl.text.match(/([a-zA-Z0-9_\-./]+\.html)/);
|
||||
if (htmlMatch?.[1]) {
|
||||
return `${rawBase}/files/${htmlMatch[1]}`;
|
||||
}
|
||||
return rawBase.replace("/raw/branch/main", "");
|
||||
}
|
||||
|
||||
function buildSuccessResult(
|
||||
stage: InvokeRequest["stage"],
|
||||
task: InvokeRequest["task"],
|
||||
outputText?: string,
|
||||
gitResult?: {
|
||||
ok: boolean;
|
||||
repoUrl: string;
|
||||
rawUrlBase: string;
|
||||
commit: string;
|
||||
filesCount: number;
|
||||
} | null,
|
||||
): HandoffMessage {
|
||||
const summary = outputText?.slice(0, 2000) ?? "";
|
||||
switch (stage) {
|
||||
@@ -370,10 +428,17 @@ function buildSuccessResult(
|
||||
stage: "implement",
|
||||
verdict: "IMPL_DONE",
|
||||
payload: {
|
||||
branch: "feature/sister-agent",
|
||||
commits: ["llm"],
|
||||
branch: gitResult?.ok ? "main" : "feature/sister-agent",
|
||||
commits: gitResult?.ok && gitResult.commit ? [gitResult.commit] : ["llm"],
|
||||
workdir: task.workdir || "",
|
||||
selfTestReport: { summary },
|
||||
selfTestReport: {
|
||||
summary,
|
||||
...(gitResult?.ok && {
|
||||
repoUrl: gitResult.repoUrl,
|
||||
rawUrlBase: gitResult.rawUrlBase,
|
||||
filesCount: gitResult.filesCount,
|
||||
}),
|
||||
},
|
||||
},
|
||||
errorReason: "",
|
||||
};
|
||||
|
||||
@@ -198,9 +198,22 @@ function extractStageText(h: HandoffMessage): string {
|
||||
}
|
||||
return "";
|
||||
case "implement": {
|
||||
const summary =
|
||||
(h.payload?.selfTestReport as { summary?: string } | undefined)?.summary;
|
||||
return summary ?? "";
|
||||
const report = h.payload?.selfTestReport as
|
||||
| {
|
||||
summary?: string;
|
||||
repoUrl?: string;
|
||||
rawUrlBase?: string;
|
||||
filesCount?: number;
|
||||
}
|
||||
| undefined;
|
||||
const parts: string[] = [];
|
||||
if (report?.summary) parts.push(report.summary);
|
||||
if (report?.repoUrl) parts.push(`[git] repoUrl=${report.repoUrl}`);
|
||||
if (report?.rawUrlBase) parts.push(`[git] rawUrlBase=${report.rawUrlBase}`);
|
||||
if (typeof report?.filesCount === "number") {
|
||||
parts.push(`[git] filesCount=${report.filesCount}`);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
case "review": {
|
||||
if (h.payload?.issues && h.payload.issues.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user