gitvan
Version:
Autonomic Git-native development automation platform with AI-powered workflows
435 lines (393 loc) • 10.9 kB
JavaScript
// src/jobs/runner.mjs
// GitVan v2 — Job Runner with Locking and Receipts
// Handles job execution, concurrency control, and audit trails
import { createHash } from "node:crypto";
import { useGit } from "../composables/git/index.mjs";
import { defineJob } from "./define.mjs";
import { createJobHooks } from "./hooks.mjs";
/**
* Job execution result
*/
export class JobResult {
constructor(options = {}) {
this.id = options.id;
this.fingerprint = options.fingerprint;
this.startedAt = options.startedAt;
this.finishedAt = options.finishedAt;
this.head = options.head;
this.ok = options.ok;
this.error = options.error;
this.artifacts = options.artifacts || [];
this.duration = options.duration;
}
toJSON() {
return {
id: this.id,
fingerprint: this.fingerprint,
startedAt: this.startedAt,
finishedAt: this.finishedAt,
head: this.head,
ok: this.ok,
error: this.error?.message,
artifacts: this.artifacts,
duration: this.duration,
};
}
}
/**
* Job execution context
*/
export class JobExecutionContext {
constructor(jobDef, options = {}) {
this.jobDef = jobDef;
this.git = useGit();
this.root = options.root || process.cwd();
this.nowISO = options.nowISO || new Date().toISOString();
this.env = options.env || process.env;
this.logger = options.logger || console;
this.trigger = options.trigger;
this.payload = options.payload || {};
}
async buildContext() {
const git = this.git;
return {
root: this.root,
nowISO: this.nowISO,
env: this.env,
git: {
head: await git.currentHead(),
branch: await git.currentBranch(),
isSigned: await this.isCommitSigned(),
},
trigger: this.trigger,
logger: this.logger,
payload: this.payload,
};
}
async isCommitSigned() {
try {
const git = this.git;
const head = await git.currentHead();
const commitInfo = await git.run(["show", "--show-signature", head]);
return commitInfo.includes("Good signature");
} catch {
return false;
}
}
}
/**
* Job runner with locking and receipts
*/
export class JobRunner {
constructor(options = {}) {
this.git = useGit();
this.receiptsRef = options.receiptsRef || "refs/notes/gitvan/results";
this.locksRef = options.locksRef || "refs/gitvan/locks";
this.executionsRef = options.executionsRef || "refs/gitvan/executions";
this.hooks = options.hooks || createJobHooks();
}
/**
* Generate execution fingerprint
*/
generateFingerprint(jobDef, head, payload, trigger) {
const payloadHash = payload
? createHash("sha256").update(JSON.stringify(payload)).digest("hex")
: "";
const triggerKind = trigger?.kind || "cli";
const data = `${jobDef.id}@${head}@${payloadHash}@${jobDef.version}@${triggerKind}`;
return createHash("sha256").update(data).digest("hex");
}
/**
* Encode job ID for use in Git ref names
*/
encodeJobId(jobId) {
return jobId.replace(/:/g, "-").replace(/[^a-zA-Z0-9\-_]/g, "_");
}
/**
* Acquire job lock
*/
async acquireLock(jobId, fingerprint, force = false) {
const encodedJobId = this.encodeJobId(jobId);
const lockRef = `${this.locksRef}/${encodedJobId}`;
if (force) {
// Force mode: create new lock with timestamp
const timestamp = Date.now();
const forceFingerprint = `${fingerprint}-force-${timestamp}`;
await this.git.runVoid([
"update-ref",
lockRef,
await this.git.currentHead(),
]);
await this.git.noteAdd(
lockRef,
forceFingerprint,
await this.git.currentHead()
);
return forceFingerprint;
}
// Normal mode: try to acquire lock
try {
await this.git.runVoid(["show-ref", "--verify", "--quiet", lockRef]);
// Lock exists, return false
return false;
} catch {
// Lock doesn't exist, create it
await this.git.runVoid([
"update-ref",
lockRef,
await this.git.currentHead(),
]);
await this.git.noteAdd(
lockRef,
fingerprint,
await this.git.currentHead()
);
// Call lock acquire hook
await this.hooks.callHook("lock:acquire", { id: jobId, fingerprint });
return fingerprint;
}
}
/**
* Release job lock
*/
async releaseLock(jobId) {
const encodedJobId = this.encodeJobId(jobId);
const lockRef = `${this.locksRef}/${encodedJobId}`;
try {
await this.git.runVoid(["update-ref", "-d", lockRef]);
// Call lock release hook
await this.hooks.callHook("lock:release", { id: jobId });
} catch (error) {
// Lock might not exist, which is fine
if (!error.message.includes("doesn't exist")) {
throw error;
}
}
}
/**
* Write execution receipt
*/
async writeReceipt(result) {
const receipt = {
id: result.id,
fingerprint: result.fingerprint,
startedAt: result.startedAt,
finishedAt: result.finishedAt,
head: result.head,
ok: result.ok,
error: result.error?.message,
artifacts: result.artifacts,
duration: result.duration,
};
const receiptJson = JSON.stringify(receipt, null, 2);
try {
await this.git.noteAdd(this.receiptsRef, receiptJson, result.head);
// Call receipt write hook
await this.hooks.callHook("receipt:write", {
id: result.id,
note: receipt,
ref: this.receiptsRef,
});
} catch (error) {
// If note already exists, append to it
try {
await this.git.noteAppend(
this.receiptsRef,
`\n---\n${receiptJson}`,
result.head
);
} catch (appendError) {
this.git.logger?.warn(
`Failed to write receipt: ${appendError.message}`
);
}
}
// Call receipt hook
if (this.hooks["receipt:write"]) {
try {
await this.hooks["receipt:write"]({
id: result.id,
note: receipt,
ref: this.receiptsRef,
});
} catch (error) {
this.git.logger?.warn(`Receipt hook failed: ${error.message}`);
}
}
}
/**
* Record execution in git refs
*/
async recordExecution(result) {
const encodedJobId = this.encodeJobId(result.id);
const executionRef = `${this.executionsRef}/${encodedJobId}/${result.fingerprint}`;
const executionData = JSON.stringify(result.toJSON(), null, 2);
try {
await this.git.runVoid([
"update-ref",
executionRef,
await this.git.currentHead(),
]);
await this.git.noteAdd(
executionRef,
executionData,
await this.git.currentHead()
);
} catch (error) {
this.git.logger?.warn(`Failed to record execution: ${error.message}`);
}
}
/**
* Run a job
*/
async runJob(jobDef, options = {}) {
const {
payload = {},
trigger = null,
force = false,
head = null,
} = options;
const startTime = Date.now();
const startedAt = new Date().toISOString();
const currentHead = head || (await this.git.currentHead());
// Generate fingerprint
const fingerprint = this.generateFingerprint(
jobDef,
currentHead,
payload,
trigger
);
// Create execution context
const execContext = new JobExecutionContext(jobDef, {
root: this.git.cwd,
nowISO: startedAt,
env: this.git.env,
logger: this.git.logger || console,
trigger,
payload,
});
// Acquire lock
const lockFingerprint = await this.acquireLock(
jobDef.id,
fingerprint,
force
);
let result;
try {
// Call before hook
await this.hooks.callHook("job:before", {
id: jobDef.id,
payload,
ctx: await execContext.buildContext(),
});
// Build execution context
const ctx = await execContext.buildContext();
// Execute job
const jobResult = await jobDef.run({ payload, ctx });
// Create result
const finishedAt = new Date().toISOString();
result = new JobResult({
id: jobDef.id,
fingerprint: lockFingerprint,
startedAt,
finishedAt,
head: currentHead,
ok: true,
artifacts: jobResult?.artifacts || [],
duration: finishedAt - startedAt,
});
// Call after hook
await this.hooks.callHook("job:after", {
id: jobDef.id,
result,
ctx,
});
} catch (error) {
// Create error result
const finishedAt = new Date().toISOString();
result = new JobResult({
id: jobDef.id,
fingerprint: lockFingerprint,
startedAt,
finishedAt,
head: currentHead,
ok: false,
error,
duration: finishedAt - startedAt,
});
// Call error hook
await this.hooks.callHook("job:error", {
id: jobDef.id,
error,
ctx: await execContext.buildContext(),
});
throw error;
} finally {
// Always release lock and write receipt
await this.releaseLock(jobDef.id);
await this.writeReceipt(result);
await this.recordExecution(result);
}
return result;
}
/**
* Check if job is currently running
*/
async isJobRunning(jobId) {
const encodedJobId = this.encodeJobId(jobId);
const lockRef = `${this.locksRef}/${encodedJobId}`;
try {
await this.git.run(["show-ref", "--verify", "--quiet", lockRef]);
return true;
} catch {
return false;
}
}
/**
* Get job lock info
*/
async getJobLockInfo(jobId) {
const encodedJobId = this.encodeJobId(jobId);
const lockRef = `${this.locksRef}/${encodedJobId}`;
try {
await this.git.run(["show-ref", "--verify", "--quiet", lockRef]);
const fingerprint = await this.git.noteShow(lockRef);
return { locked: true, fingerprint: fingerprint.trim() };
} catch {
return { locked: false };
}
}
/**
* Clear job lock (force unlock)
*/
async clearJobLock(jobId) {
await this.releaseLock(jobId);
}
/**
* List all job locks
*/
async listJobLocks() {
try {
const locks = await this.git.run([
"for-each-ref",
"--format=%(refname)",
this.locksRef,
]);
return locks
.split("\n")
.filter(Boolean)
.map((ref) => {
const parts = ref.split("/");
const encodedId = parts[parts.length - 1];
// Decode job ID (reverse the encoding)
const jobId = encodedId.replace(/-/g, ":").replace(/_/g, "-");
return {
id: jobId,
ref,
};
});
} catch {
return [];
}
}
}