feat(gsd-uok): add turn-level git transaction modes and closeout gates
This commit is contained in:
parent
a2cc151bc9
commit
d6c93ef07f
9 changed files with 422 additions and 14 deletions
|
|
@ -29,9 +29,10 @@ import { rebuildState } from "./doctor.js";
|
|||
import { parseUnitId } from "./unit-id.js";
|
||||
import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js";
|
||||
import {
|
||||
autoCommitCurrentBranch,
|
||||
runTurnGitAction,
|
||||
type TaskCommitContext,
|
||||
} from "./worktree.js";
|
||||
type TurnGitActionMode,
|
||||
} from "./git-service.js";
|
||||
import {
|
||||
verifyExpectedArtifact,
|
||||
resolveExpectedArtifactPath,
|
||||
|
|
@ -68,6 +69,7 @@ import { writePreExecutionEvidence } from "./verification-evidence.js";
|
|||
import { ensureCodebaseMapFresh } from "./codebase-generator.js";
|
||||
import { resolveUokFlags } from "./uok/flags.js";
|
||||
import { UokGateRunner } from "./uok/gate-runner.js";
|
||||
import { writeTurnGitTransaction } from "./uok/gitops.js";
|
||||
|
||||
/** Maximum verification retry attempts before escalating to blocker placeholder (#2653). */
|
||||
const MAX_VERIFICATION_RETRIES = 3;
|
||||
|
|
@ -359,10 +361,161 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
|
|||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
// Auto-commit
|
||||
const prefs = loadEffectiveGSDPreferences()?.preferences;
|
||||
const uokFlags = resolveUokFlags(prefs);
|
||||
|
||||
// Turn-level git action (commit | snapshot | status-only)
|
||||
if (s.currentUnit) {
|
||||
const unit = s.currentUnit;
|
||||
await autoCommitUnit(s.basePath, unit.type, unit.id, ctx);
|
||||
const turnAction: TurnGitActionMode = uokFlags.gitops ? uokFlags.gitopsTurnAction : "commit";
|
||||
const traceId = s.currentTraceId ?? `turn:${unit.startedAt}`;
|
||||
const turnId = s.currentTurnId ?? `${unit.type}/${unit.id}/${unit.startedAt}`;
|
||||
s.lastGitActionFailure = null;
|
||||
s.lastGitActionStatus = null;
|
||||
try {
|
||||
let taskContext: TaskCommitContext | undefined;
|
||||
|
||||
if (turnAction === "commit" && s.currentUnit.type === "execute-task") {
|
||||
const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
|
||||
if (mid && sid && tid) {
|
||||
const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY");
|
||||
if (summaryPath) {
|
||||
try {
|
||||
const summaryContent = await loadFile(summaryPath);
|
||||
if (summaryContent) {
|
||||
const summary = parseSummary(summaryContent);
|
||||
// Look up GitHub issue number for commit linking
|
||||
let ghIssueNumber: number | undefined;
|
||||
try {
|
||||
const { getTaskIssueNumberForCommit } = await import("../github-sync/sync.js");
|
||||
ghIssueNumber = getTaskIssueNumberForCommit(s.basePath, mid, sid, tid) ?? undefined;
|
||||
} catch (err) {
|
||||
// GitHub sync not available — skip
|
||||
logWarning("engine", `GitHub issue lookup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
taskContext = {
|
||||
taskId: `${sid}/${tid}`,
|
||||
taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid,
|
||||
oneLiner: summary.oneLiner || undefined,
|
||||
keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined,
|
||||
issueNumber: ghIssueNumber,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog("postUnit", { phase: "task-summary-parse", error: String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate the nativeHasChanges cache before auto-commit (#1853).
|
||||
// The cache has a 10-second TTL and is keyed by basePath. A stale
|
||||
// `false` result causes autoCommit to skip staging entirely, leaving
|
||||
// code files only in the working tree where they are destroyed by
|
||||
// `git worktree remove --force` during teardown.
|
||||
_resetHasChangesCache();
|
||||
|
||||
const skipLifecycleCommit =
|
||||
turnAction === "commit" && LIFECYCLE_ONLY_UNITS.has(s.currentUnit.type);
|
||||
|
||||
if (skipLifecycleCommit) {
|
||||
debugLog("postUnit", {
|
||||
phase: "git-action-skipped",
|
||||
reason: "lifecycle-only-unit",
|
||||
unitType: s.currentUnit.type,
|
||||
unitId: s.currentUnit.id,
|
||||
});
|
||||
} else {
|
||||
const gitResult = runTurnGitAction({
|
||||
basePath: s.basePath,
|
||||
action: turnAction,
|
||||
unitType: s.currentUnit.type,
|
||||
unitId: s.currentUnit.id,
|
||||
taskContext,
|
||||
});
|
||||
|
||||
if (uokFlags.gitops) {
|
||||
writeTurnGitTransaction({
|
||||
basePath: s.basePath,
|
||||
traceId,
|
||||
turnId,
|
||||
unitType: unit.type,
|
||||
unitId: unit.id,
|
||||
stage: "publish",
|
||||
action: turnAction,
|
||||
push: uokFlags.gitopsTurnPush,
|
||||
status: gitResult.status,
|
||||
error: gitResult.error,
|
||||
metadata: {
|
||||
dirty: gitResult.dirty,
|
||||
commitMessage: gitResult.commitMessage,
|
||||
snapshotLabel: gitResult.snapshotLabel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (gitResult.status === "failed") {
|
||||
s.lastGitActionFailure = gitResult.error ?? `git ${turnAction} failed`;
|
||||
s.lastGitActionStatus = "failed";
|
||||
if (uokFlags.gitops && uokFlags.gates) {
|
||||
const parsed = parseUnitId(unit.id);
|
||||
const gateRunner = new UokGateRunner();
|
||||
gateRunner.register({
|
||||
id: "closeout-git-action",
|
||||
type: "closeout",
|
||||
execute: async () => ({
|
||||
outcome: "fail",
|
||||
failureClass: "git",
|
||||
rationale: `turn git action "${turnAction}" failed`,
|
||||
findings: gitResult.error ?? "unknown git failure",
|
||||
}),
|
||||
});
|
||||
await gateRunner.run("closeout-git-action", {
|
||||
basePath: s.basePath,
|
||||
traceId,
|
||||
turnId,
|
||||
milestoneId: parsed.milestone ?? undefined,
|
||||
sliceId: parsed.slice ?? undefined,
|
||||
taskId: parsed.task ?? undefined,
|
||||
unitType: unit.type,
|
||||
unitId: unit.id,
|
||||
});
|
||||
}
|
||||
|
||||
const failureMsg = `Git ${turnAction} failed: ${(gitResult.error ?? "unknown error").split("\n")[0]}`;
|
||||
if (uokFlags.gitops) {
|
||||
ctx.ui.notify(failureMsg, "error");
|
||||
await pauseAuto(ctx, pi);
|
||||
return "dispatched";
|
||||
}
|
||||
ctx.ui.notify(failureMsg, "warning");
|
||||
debugLog("postUnit", {
|
||||
phase: "git-action-failed-nonblocking",
|
||||
action: turnAction,
|
||||
error: gitResult.error ?? "unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
s.lastGitActionStatus = "ok";
|
||||
|
||||
if (turnAction === "commit" && gitResult.commitMessage) {
|
||||
ctx.ui.notify(`Committed: ${gitResult.commitMessage.split("\n")[0]}`, "info");
|
||||
} else if (turnAction === "snapshot" && gitResult.snapshotLabel) {
|
||||
ctx.ui.notify(`Snapshot recorded: ${gitResult.snapshotLabel}`, "info");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
s.lastGitActionFailure = message;
|
||||
s.lastGitActionStatus = "failed";
|
||||
debugLog("postUnit", { phase: "git-action", error: message, action: turnAction });
|
||||
ctx.ui.notify(`Git ${turnAction} failed: ${message.split("\n")[0]}`, uokFlags.gitops ? "error" : "warning");
|
||||
if (uokFlags.gitops) {
|
||||
await pauseAuto(ctx, pi);
|
||||
return "dispatched";
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub sync (non-blocking, opt-in)
|
||||
await runSafely("postUnit", "github-sync", async () => {
|
||||
|
|
@ -871,6 +1024,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
|
|||
s.currentUnit &&
|
||||
s.currentUnit.type === "plan-slice"
|
||||
) {
|
||||
const currentUnit = s.currentUnit;
|
||||
let preExecPauseNeeded = false;
|
||||
await runSafely("postUnitPostVerification", "pre-execution-checks", async () => {
|
||||
const prefs = loadEffectiveGSDPreferences()?.preferences;
|
||||
|
|
@ -890,7 +1044,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
|
|||
}
|
||||
|
||||
// Parse the unit ID to get milestone/slice IDs
|
||||
const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit!.id);
|
||||
const { milestone: mid, slice: sid } = parseUnitId(currentUnit.id);
|
||||
if (!mid || !sid) {
|
||||
debugLog("postUnitPostVerification", {
|
||||
phase: "pre-execution-checks",
|
||||
|
|
@ -957,12 +1111,12 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
|
|||
});
|
||||
await gateRunner.run("pre-execution-checks", {
|
||||
basePath: s.basePath,
|
||||
traceId: `pre-execution:${s.currentUnit.id}`,
|
||||
turnId: s.currentUnit.id,
|
||||
traceId: `pre-execution:${currentUnit.id}`,
|
||||
turnId: currentUnit.id,
|
||||
milestoneId: mid,
|
||||
sliceId: sid,
|
||||
unitType: s.currentUnit.type,
|
||||
unitId: s.currentUnit.id,
|
||||
unitType: currentUnit.type,
|
||||
unitId: currentUnit.id,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent";
|
|||
import { snapshotUnitMetrics } from "./metrics.js";
|
||||
import { saveActivityLog } from "./activity-log.js";
|
||||
import { logWarning } from "./workflow-logger.js";
|
||||
import { writeTurnGitTransaction } from "./uok/gitops.js";
|
||||
|
||||
export interface CloseoutOptions {
|
||||
promptCharCount?: number;
|
||||
|
|
@ -15,6 +16,12 @@ export interface CloseoutOptions {
|
|||
tier?: string;
|
||||
modelDowngraded?: boolean;
|
||||
continueHereFired?: boolean;
|
||||
traceId?: string;
|
||||
turnId?: string;
|
||||
gitAction?: "commit" | "snapshot" | "status-only";
|
||||
gitPush?: boolean;
|
||||
gitStatus?: "ok" | "failed";
|
||||
gitError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,6 +54,23 @@ export async function closeoutUnit(
|
|||
}
|
||||
}
|
||||
|
||||
if (opts?.traceId && opts.turnId && opts.gitAction && opts.gitStatus) {
|
||||
writeTurnGitTransaction({
|
||||
basePath,
|
||||
traceId: opts.traceId,
|
||||
turnId: opts.turnId,
|
||||
unitType,
|
||||
unitId,
|
||||
stage: "record",
|
||||
action: opts.gitAction,
|
||||
push: opts.gitPush === true,
|
||||
status: opts.gitStatus,
|
||||
error: opts.gitError,
|
||||
metadata: {
|
||||
activityFile,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return activityFile ?? undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ import { bootstrapAutoSession, openProjectDbIfPresent, type BootstrapDeps } from
|
|||
import { initHealthWidget } from "./health-widget.js";
|
||||
import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight, type LoopDeps, type ErrorContext } from "./auto-loop.js";
|
||||
import { runAutoLoopWithUok } from "./uok/kernel.js";
|
||||
import { resolveUokFlags } from "./uok/flags.js";
|
||||
// Slice-level parallelism (#2340)
|
||||
import { getEligibleSlices } from "./slice-parallel-eligibility.js";
|
||||
import { startSliceParallel } from "./slice-parallel-orchestrator.js";
|
||||
|
|
@ -606,11 +607,29 @@ function buildSnapshotOpts(
|
|||
continueHereFired?: boolean;
|
||||
promptCharCount?: number;
|
||||
baselineCharCount?: number;
|
||||
traceId?: string;
|
||||
turnId?: string;
|
||||
gitAction?: "commit" | "snapshot" | "status-only";
|
||||
gitPush?: boolean;
|
||||
gitStatus?: "ok" | "failed";
|
||||
gitError?: string;
|
||||
} & Record<string, unknown> {
|
||||
const prefs = loadEffectiveGSDPreferences()?.preferences;
|
||||
const uokFlags = resolveUokFlags(prefs);
|
||||
return {
|
||||
...(s.autoStartTime > 0 ? { autoSessionKey: String(s.autoStartTime) } : {}),
|
||||
promptCharCount: s.lastPromptCharCount,
|
||||
baselineCharCount: s.lastBaselineCharCount,
|
||||
traceId: s.currentTraceId ?? undefined,
|
||||
turnId: s.currentTurnId ?? undefined,
|
||||
...(uokFlags.gitops
|
||||
? {
|
||||
gitAction: uokFlags.gitopsTurnAction,
|
||||
gitPush: uokFlags.gitopsTurnPush,
|
||||
gitStatus: s.lastGitActionStatus ?? undefined,
|
||||
gitError: s.lastGitActionFailure ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
...(s.currentUnitRouting ?? {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,13 +131,15 @@ export async function autoLoop(
|
|||
let seqCounter = 0;
|
||||
const nextSeq = () => ++seqCounter;
|
||||
const turnId = randomUUID();
|
||||
s.currentTraceId = flowId;
|
||||
s.currentTurnId = turnId;
|
||||
const turnStartedAt = new Date().toISOString();
|
||||
let observedUnitType: string | undefined;
|
||||
let observedUnitId: string | undefined;
|
||||
let turnFinished = false;
|
||||
const finishTurn = (
|
||||
status: "completed" | "failed" | "paused" | "stopped" | "skipped" | "retry",
|
||||
failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" | "closeout" = "none",
|
||||
failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" | "closeout" | "git" = "none",
|
||||
error?: string,
|
||||
): void => {
|
||||
if (turnFinished) return;
|
||||
|
|
@ -155,6 +157,8 @@ export async function autoLoop(
|
|||
startedAt: turnStartedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
s.currentTraceId = null;
|
||||
s.currentTurnId = null;
|
||||
};
|
||||
deps.uokObserver?.onTurnStart({
|
||||
traceId: flowId,
|
||||
|
|
@ -483,7 +487,10 @@ export async function autoLoop(
|
|||
unitId: iterData.unitId,
|
||||
});
|
||||
if (finalizeResult.action === "break") {
|
||||
finishTurn("stopped", "closeout", "finalize-break");
|
||||
const finalizeFailureClass = finalizeResult.reason === "git-closeout-failure"
|
||||
? "git"
|
||||
: "closeout";
|
||||
finishTurn("stopped", finalizeFailureClass, "finalize-break");
|
||||
break;
|
||||
}
|
||||
if (finalizeResult.action === "continue") {
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,8 @@ export async function runUnitPhase(
|
|||
// unit in the same Node process (see workflow-logger.ts module header).
|
||||
_resetLogs();
|
||||
s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
|
||||
s.lastGitActionFailure = null;
|
||||
s.lastGitActionStatus = null;
|
||||
setCurrentPhase(unitType);
|
||||
s.lastToolInvocationError = null; // #2883: clear stale error from previous unit
|
||||
const unitStartSeq = ic.nextSeq();
|
||||
|
|
@ -1727,11 +1729,15 @@ export async function runFinalize(
|
|||
|
||||
const preResult = preResultGuard.value;
|
||||
if (preResult === "dispatched") {
|
||||
const dispatchedReason = s.lastGitActionFailure
|
||||
? "git-closeout-failure"
|
||||
: "pre-verification-dispatched";
|
||||
debugLog("autoLoop", {
|
||||
phase: "exit",
|
||||
reason: "pre-verification-dispatched",
|
||||
reason: dispatchedReason,
|
||||
gitError: s.lastGitActionFailure ?? undefined,
|
||||
});
|
||||
return { action: "break", reason: "pre-verification-dispatched" };
|
||||
return { action: "break", reason: dispatchedReason };
|
||||
}
|
||||
if (preResult === "retry") {
|
||||
if (sidecarItem) {
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ export class AutoSession {
|
|||
|
||||
// ── Current unit ─────────────────────────────────────────────────────────
|
||||
currentUnit: CurrentUnit | null = null;
|
||||
currentTraceId: string | null = null;
|
||||
currentTurnId: string | null = null;
|
||||
currentUnitRouting: UnitRouting | null = null;
|
||||
currentMilestoneId: string | null = null;
|
||||
|
||||
|
|
@ -137,6 +139,10 @@ export class AutoSession {
|
|||
/** Set when a GSD tool execution ends with isError due to malformed/truncated
|
||||
* JSON arguments. Checked by postUnitPreVerification to break retry loops. */
|
||||
lastToolInvocationError: string | null = null;
|
||||
/** Set when turn-level git action fails during closeout. */
|
||||
lastGitActionFailure: string | null = null;
|
||||
/** Last turn-level git action status captured during finalize. */
|
||||
lastGitActionStatus: "ok" | "failed" | null = null;
|
||||
|
||||
// ── Isolation degradation ────────────────────────────────────────────
|
||||
/** Set to true when worktree creation fails; prevents merge of nonexistent branch. */
|
||||
|
|
@ -219,6 +225,8 @@ export class AutoSession {
|
|||
|
||||
// Unit
|
||||
this.currentUnit = null;
|
||||
this.currentTraceId = null;
|
||||
this.currentTurnId = null;
|
||||
this.currentUnitRouting = null;
|
||||
this.currentMilestoneId = null;
|
||||
|
||||
|
|
@ -250,6 +258,8 @@ export class AutoSession {
|
|||
this.rewriteAttemptCount = 0;
|
||||
this.consecutiveCompleteBootstraps = 0;
|
||||
this.lastToolInvocationError = null;
|
||||
this.lastGitActionFailure = null;
|
||||
this.lastGitActionStatus = null;
|
||||
this.isolationDegraded = false;
|
||||
this.milestoneMergedInPhases = false;
|
||||
this.checkpointSha = null;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
nativeAddPaths,
|
||||
nativeResetSoft,
|
||||
nativeCommitSubject,
|
||||
_resetHasChangesCache,
|
||||
} from "./native-git-bridge.js";
|
||||
import { GSDError, GSD_MERGE_CONFLICT, GSD_GIT_ERROR } from "./errors.js";
|
||||
import { getErrorMessage } from "./error-utils.js";
|
||||
|
|
@ -93,6 +94,17 @@ export interface CommitOptions {
|
|||
allowEmpty?: boolean;
|
||||
}
|
||||
|
||||
export type TurnGitActionMode = "commit" | "snapshot" | "status-only";
|
||||
|
||||
export interface TurnGitActionResult {
|
||||
action: TurnGitActionMode;
|
||||
status: "ok" | "failed";
|
||||
commitMessage?: string;
|
||||
snapshotLabel?: string;
|
||||
dirty?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── Meaningful Commit Message Generation ───────────────────────────────────
|
||||
|
||||
/** Context for generating a meaningful commit message from task execution results. */
|
||||
|
|
@ -822,6 +834,62 @@ export function createGitService(basePath: string): GitServiceImpl {
|
|||
return new GitServiceImpl(basePath, gitPrefs);
|
||||
}
|
||||
|
||||
function buildTurnSnapshotLabel(unitType: string, unitId: string): string {
|
||||
const raw = `${unitType}/${unitId}`.trim();
|
||||
if (!raw) return "turn";
|
||||
return raw
|
||||
.replace(/[^a-zA-Z0-9._/-]/g, "-")
|
||||
.replace(/\/{2,}/g, "/")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^[-/]+|[-/]+$/g, "") || "turn";
|
||||
}
|
||||
|
||||
export function runTurnGitAction(args: {
|
||||
basePath: string;
|
||||
action: TurnGitActionMode;
|
||||
unitType: string;
|
||||
unitId: string;
|
||||
taskContext?: TaskCommitContext;
|
||||
}): TurnGitActionResult {
|
||||
try {
|
||||
// Force fresh working-tree status per turn; nativeHasChanges caches briefly.
|
||||
_resetHasChangesCache();
|
||||
if (args.action === "status-only") {
|
||||
return {
|
||||
action: args.action,
|
||||
status: "ok",
|
||||
dirty: nativeHasChanges(args.basePath),
|
||||
};
|
||||
}
|
||||
|
||||
const git = createGitService(args.basePath);
|
||||
if (args.action === "snapshot") {
|
||||
const label = buildTurnSnapshotLabel(args.unitType, args.unitId);
|
||||
git.createSnapshot(label);
|
||||
return {
|
||||
action: args.action,
|
||||
status: "ok",
|
||||
snapshotLabel: label,
|
||||
dirty: nativeHasChanges(args.basePath),
|
||||
};
|
||||
}
|
||||
|
||||
const commitMessage = git.autoCommit(args.unitType, args.unitId, [], args.taskContext) ?? undefined;
|
||||
return {
|
||||
action: args.action,
|
||||
status: "ok",
|
||||
commitMessage,
|
||||
dirty: nativeHasChanges(args.basePath),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
action: args.action,
|
||||
status: "failed",
|
||||
error: getErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Commit Type Inference ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { runTurnGitAction } from "../git-service.ts";
|
||||
|
||||
function run(cmd: string, cwd: string): string {
|
||||
return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
||||
function makeRepo(): string {
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-uok-gitops-"));
|
||||
run("git init", repo);
|
||||
run('git config user.email "test@example.com"', repo);
|
||||
run('git config user.name "Test User"', repo);
|
||||
writeFileSync(join(repo, "README.md"), "# Test\n", "utf-8");
|
||||
run("git add README.md", repo);
|
||||
run('git commit -m "chore: init"', repo);
|
||||
return repo;
|
||||
}
|
||||
|
||||
test("uok gitops turn action status-only reports working tree dirtiness", () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
const clean = runTurnGitAction({
|
||||
basePath: repo,
|
||||
action: "status-only",
|
||||
unitType: "execute-task",
|
||||
unitId: "M001/S01/T01",
|
||||
});
|
||||
assert.equal(clean.status, "ok");
|
||||
assert.equal(clean.dirty, false);
|
||||
|
||||
writeFileSync(join(repo, "README.md"), "# Dirty\n", "utf-8");
|
||||
const dirty = runTurnGitAction({
|
||||
basePath: repo,
|
||||
action: "status-only",
|
||||
unitType: "execute-task",
|
||||
unitId: "M001/S01/T01",
|
||||
});
|
||||
assert.equal(dirty.status, "ok");
|
||||
assert.equal(dirty.dirty, true);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uok gitops turn action snapshot writes snapshot refs", () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
const result = runTurnGitAction({
|
||||
basePath: repo,
|
||||
action: "snapshot",
|
||||
unitType: "execute-task",
|
||||
unitId: "M001/S01/T01",
|
||||
});
|
||||
assert.equal(result.status, "ok");
|
||||
assert.ok(result.snapshotLabel?.includes("execute-task/M001/S01/T01"));
|
||||
const refs = run("git for-each-ref refs/gsd/snapshots/ --format='%(refname)'", repo);
|
||||
assert.ok(refs.includes("refs/gsd/snapshots/execute-task/M001/S01/T01/"));
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uok gitops turn action commit creates commit with unit trailer", () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
writeFileSync(join(repo, "feature.ts"), "export const x = 1;\n", "utf-8");
|
||||
const result = runTurnGitAction({
|
||||
basePath: repo,
|
||||
action: "commit",
|
||||
unitType: "execute-task",
|
||||
unitId: "M001/S01/T02",
|
||||
});
|
||||
assert.equal(result.status, "ok");
|
||||
assert.ok(result.commitMessage?.includes("chore: auto-commit after execute-task"));
|
||||
const body = run("git log -1 --pretty=%B", repo);
|
||||
assert.ok(body.includes("GSD-Unit: M001/S01/T02"));
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
35
src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts
Normal file
35
src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const gsdDir = join(__dirname, "..");
|
||||
|
||||
test("post-unit pre-verification selects turn git action from UOK gitops flags", () => {
|
||||
const source = readFileSync(join(gsdDir, "auto-post-unit.ts"), "utf-8");
|
||||
assert.ok(
|
||||
source.includes("const turnAction: TurnGitActionMode = uokFlags.gitops ? uokFlags.gitopsTurnAction : \"commit\""),
|
||||
"postUnitPreVerification should derive turn action from uok.gitops.turn_action when enabled",
|
||||
);
|
||||
});
|
||||
|
||||
test("post-unit pre-verification routes git failures through closeout gate", () => {
|
||||
const source = readFileSync(join(gsdDir, "auto-post-unit.ts"), "utf-8");
|
||||
assert.ok(
|
||||
source.includes('id: "closeout-git-action"') &&
|
||||
source.includes('type: "closeout"') &&
|
||||
source.includes('failureClass: "git"'),
|
||||
"git failures should be persisted via a closeout gate with failureClass=git",
|
||||
);
|
||||
});
|
||||
|
||||
test("auto snapshot opts carry trace/turn IDs for turn closeout records", () => {
|
||||
const source = readFileSync(join(gsdDir, "auto.ts"), "utf-8");
|
||||
assert.ok(
|
||||
source.includes("traceId: s.currentTraceId ?? undefined") &&
|
||||
source.includes("turnId: s.currentTurnId ?? undefined"),
|
||||
"buildSnapshotOpts should pass trace/turn IDs into closeout options",
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue