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:
2026-04-10 20:35:04 +09:00
parent 1c25fb3b5b
commit 17b2f2b232
5 changed files with 316 additions and 10 deletions

View File

@@ -1 +1 @@
1775819769
1775820498

View File

@@ -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
View 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,
};
}

View File

@@ -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: "",
};

View File

@@ -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) {