mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-01 18:31:55 +00:00
375 lines
18 KiB
JavaScript
375 lines
18 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { TorchError } from '../kernel/errors.mjs';
|
|
import { readInstallManifest } from '../kernel/install.mjs';
|
|
import { loadFleetDefinition } from '../kernel/worktrees.mjs';
|
|
import { BacklogService } from '../backlog/service.mjs';
|
|
|
|
function git(root, args, { allowFailure = false } = {}) {
|
|
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
if (result.status !== 0 && !allowFailure) {
|
|
throw new TorchError(`Git command failed: git ${args.join(' ')}`, {
|
|
code: 'GIT_COMMAND_FAILED', details: result.stderr?.trim() || null,
|
|
});
|
|
}
|
|
return { status: result.status, stdout: result.stdout?.trim() ?? '', stderr: result.stderr?.trim() ?? '' };
|
|
}
|
|
|
|
function integrationRow(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id, projectId: row.project_id, sourceArea: row.source_area,
|
|
sourceBranch: row.source_branch, sourceCommit: row.source_commit,
|
|
targetBranch: row.target_branch, baseTargetCommit: row.base_target_commit,
|
|
state: row.state, reason: row.reason, requiredChecks: JSON.parse(row.required_checks),
|
|
authorizedBy: row.authorized_by, authorizedAt: row.authorized_at,
|
|
createdAt: row.created_at, updatedAt: row.updated_at, landedAt: row.landed_at,
|
|
};
|
|
}
|
|
|
|
export class IntegrationService {
|
|
constructor({ repositoryRoot, controlPlane, checkService, clock = () => new Date(), idFactory = randomUUID } = {}) {
|
|
this.repositoryRoot = repositoryRoot;
|
|
this.controlPlane = controlPlane;
|
|
this.checkService = checkService;
|
|
this.clock = clock;
|
|
this.idFactory = idFactory;
|
|
const { config } = loadFleetDefinition(repositoryRoot);
|
|
this.policy = config.integration ?? {
|
|
target: config.project.main_branch, required_checks: [], landing_authority: ['session-manager'],
|
|
};
|
|
const manifest = readInstallManifest(repositoryRoot);
|
|
this.worktrees = new Map((manifest.external ?? [])
|
|
.filter((entry) => entry.type === 'worktree')
|
|
.map((entry) => [entry.area, entry]));
|
|
this.controlPlane.database.exec(`
|
|
CREATE TABLE IF NOT EXISTS integration_requests (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL,
|
|
source_area TEXT NOT NULL,
|
|
source_branch TEXT NOT NULL,
|
|
source_commit TEXT NOT NULL,
|
|
target_branch TEXT NOT NULL,
|
|
base_target_commit TEXT NOT NULL,
|
|
state TEXT NOT NULL,
|
|
reason TEXT,
|
|
required_checks TEXT NOT NULL,
|
|
authorized_by TEXT,
|
|
authorized_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
landed_at TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS integration_state_created
|
|
ON integration_requests(state, created_at, id);
|
|
`);
|
|
}
|
|
|
|
request({ areaId, commit } = {}) {
|
|
const area = this.controlPlane.assertIdentity(areaId);
|
|
if (area === 'session-manager') {
|
|
throw new TorchError('Session Manager does not own a feature worktree by default', {
|
|
code: 'INTEGRATION_SOURCE_INVALID',
|
|
});
|
|
}
|
|
const worktree = this.worktrees.get(area);
|
|
if (!worktree) throw new TorchError(`No managed worktree for ${area}`, { code: 'WORKTREE_MISSING' });
|
|
const branchTip = git(this.repositoryRoot, ['rev-parse', worktree.branch]).stdout;
|
|
const sourceCommit = commit ?? branchTip;
|
|
if (git(this.repositoryRoot, ['cat-file', '-e', `${sourceCommit}^{commit}`], { allowFailure: true }).status !== 0) {
|
|
throw new TorchError(`Source commit does not exist: ${sourceCommit}`, { code: 'INTEGRATION_COMMIT_MISSING' });
|
|
}
|
|
if (branchTip !== sourceCommit) {
|
|
throw new TorchError('Integration requests must name the current source branch tip', {
|
|
code: 'INTEGRATION_SOURCE_MOVED', details: { branchTip, sourceCommit },
|
|
});
|
|
}
|
|
const targetBranch = this.policy.target;
|
|
const baseTargetCommit = git(this.repositoryRoot, ['rev-parse', targetBranch]).stdout;
|
|
const now = this.clock().toISOString();
|
|
const record = {
|
|
id: this.idFactory(), projectId: this.controlPlane.projectId, sourceArea: area,
|
|
sourceBranch: worktree.branch, sourceCommit, targetBranch, baseTargetCommit,
|
|
state: 'testing', reason: null, requiredChecks: this.policy.required_checks ?? [],
|
|
authorizedBy: null, authorizedAt: null, createdAt: now, updatedAt: now, landedAt: null,
|
|
};
|
|
this.controlPlane.database.prepare(`
|
|
INSERT INTO integration_requests (
|
|
id, project_id, source_area, source_branch, source_commit, target_branch,
|
|
base_target_commit, state, reason, required_checks, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
record.id, record.projectId, record.sourceArea, record.sourceBranch, record.sourceCommit,
|
|
record.targetBranch, record.baseTargetCommit, record.state, record.reason,
|
|
JSON.stringify(record.requiredChecks), record.createdAt, record.updatedAt,
|
|
);
|
|
this.controlPlane.audit({
|
|
actorId: area, operation: 'integration.request', entityType: 'integration-request', entityId: record.id,
|
|
details: { sourceCommit, targetBranch },
|
|
});
|
|
return this.evaluate(record.id);
|
|
}
|
|
|
|
get(requestId) {
|
|
const row = this.controlPlane.database.prepare('SELECT * FROM integration_requests WHERE id = ?').get(requestId);
|
|
if (!row) throw new TorchError(`Unknown integration request: ${requestId}`, { code: 'INTEGRATION_NOT_FOUND' });
|
|
return integrationRow(row);
|
|
}
|
|
|
|
list({ state } = {}) {
|
|
const rows = state
|
|
? this.controlPlane.database.prepare(`
|
|
SELECT * FROM integration_requests WHERE state = ? ORDER BY rowid
|
|
`).all(state)
|
|
: this.controlPlane.database.prepare('SELECT * FROM integration_requests ORDER BY rowid').all();
|
|
return rows.map(integrationRow);
|
|
}
|
|
|
|
evaluate(requestId) {
|
|
const request = this.get(requestId);
|
|
if (request.state === 'landed' || request.state === 'superseded') return request;
|
|
const targetCommit = git(this.repositoryRoot, ['rev-parse', request.targetBranch]).stdout;
|
|
if (request.authorizedBy
|
|
&& (this.policy.landing_authority ?? []).includes(request.authorizedBy)
|
|
&& git(this.repositoryRoot, ['merge-base', '--is-ancestor', request.sourceCommit, targetCommit], { allowFailure: true }).status === 0
|
|
&& this.checkService.exactPasses({ commit: request.sourceCommit, requiredChecks: request.requiredChecks })) {
|
|
const landedAt = this.clock().toISOString();
|
|
this.controlPlane.database.prepare(`
|
|
UPDATE integration_requests
|
|
SET state = 'landed', reason = NULL, landed_at = ?, updated_at = ? WHERE id = ?
|
|
`).run(landedAt, landedAt, request.id);
|
|
this.controlPlane.audit({
|
|
actorId: request.authorizedBy, operation: 'integration.reconcile-landed',
|
|
entityType: 'integration-request', entityId: request.id,
|
|
details: { commit: request.sourceCommit, target: request.targetBranch },
|
|
});
|
|
return this.get(request.id);
|
|
}
|
|
let state = 'ready';
|
|
let reason = null;
|
|
const branchTip = git(this.repositoryRoot, ['rev-parse', request.sourceBranch], { allowFailure: true });
|
|
if (branchTip.status !== 0) {
|
|
state = 'blocked'; reason = 'source-branch-missing';
|
|
} else if (branchTip.stdout !== request.sourceCommit) {
|
|
state = 'superseded'; reason = 'source-branch-moved';
|
|
} else if (git(this.repositoryRoot, [
|
|
'merge-base', '--is-ancestor', request.baseTargetCommit, request.sourceCommit,
|
|
], { allowFailure: true }).status !== 0) {
|
|
state = 'needs_convergence'; reason = 'source-does-not-contain-request-target';
|
|
} else {
|
|
const targetCommit = git(this.repositoryRoot, ['rev-parse', request.targetBranch]).stdout;
|
|
if (targetCommit !== request.baseTargetCommit
|
|
&& git(this.repositoryRoot, [
|
|
'merge-base', '--is-ancestor', targetCommit, request.sourceCommit,
|
|
], { allowFailure: true }).status !== 0) {
|
|
state = 'needs_convergence'; reason = 'target-advanced-since-request';
|
|
}
|
|
}
|
|
if (state === 'ready' && !this.checkService.exactPasses({
|
|
commit: request.sourceCommit, requiredChecks: request.requiredChecks,
|
|
})) {
|
|
state = 'testing'; reason = 'required-exact-sha-checks-missing';
|
|
}
|
|
const updatedAt = this.clock().toISOString();
|
|
this.controlPlane.database.prepare(`
|
|
UPDATE integration_requests SET state = ?, reason = ?, updated_at = ? WHERE id = ?
|
|
`).run(state, reason, updatedAt, request.id);
|
|
return { ...request, state, reason, updatedAt };
|
|
}
|
|
|
|
authorize({ requestId, actorId } = {}) {
|
|
const actor = this.controlPlane.assertIdentity(actorId);
|
|
if (!(this.policy.landing_authority ?? []).includes(actor)) {
|
|
throw new TorchError(`${actor} lacks landing authority`, {
|
|
code: 'INTEGRATION_AUTHORITY_REQUIRED', details: { allowed: this.policy.landing_authority ?? [] },
|
|
});
|
|
}
|
|
const request = this.evaluate(requestId);
|
|
if (request.state !== 'ready') {
|
|
throw new TorchError(`Integration request is ${request.state}, not ready`, {
|
|
code: 'INTEGRATION_NOT_READY', details: { state: request.state, reason: request.reason },
|
|
});
|
|
}
|
|
const authorizedAt = this.clock().toISOString();
|
|
this.controlPlane.database.prepare(`
|
|
UPDATE integration_requests SET authorized_by = ?, authorized_at = ?, updated_at = ? WHERE id = ?
|
|
`).run(actor, authorizedAt, authorizedAt, request.id);
|
|
this.controlPlane.audit({
|
|
actorId: actor, operation: 'integration.authorize', entityType: 'integration-request', entityId: request.id,
|
|
});
|
|
return this.get(request.id);
|
|
}
|
|
|
|
firstReadyRequest(actorId) {
|
|
for (const queued of this.list()) {
|
|
if (queued.authorizedBy !== actorId) continue;
|
|
const current = this.evaluate(queued.id);
|
|
if (current.state === 'ready') return current;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
planLanding({ requestId, actorId } = {}) {
|
|
const actor = this.controlPlane.assertIdentity(actorId);
|
|
const request = this.evaluate(requestId);
|
|
const blockers = [];
|
|
if (!(this.policy.landing_authority ?? []).includes(actor)) blockers.push({ code: 'LANDING_AUTHORITY_REQUIRED' });
|
|
if (request.authorizedBy !== actor) blockers.push({ code: 'REQUEST_NOT_AUTHORIZED_BY_ACTOR' });
|
|
if (request.state !== 'ready') blockers.push({ code: 'INTEGRATION_NOT_READY', state: request.state, reason: request.reason });
|
|
if (request.state === 'ready') {
|
|
const firstReady = this.firstReadyRequest(actor);
|
|
if (firstReady && firstReady.id !== request.id) {
|
|
blockers.push({ code: 'INTEGRATION_QUEUE_ORDER_REQUIRED', nextRequestId: firstReady.id });
|
|
}
|
|
}
|
|
const currentTarget = git(this.repositoryRoot, ['rev-parse', request.targetBranch]).stdout;
|
|
if (this.policy.require_current_main !== false && currentTarget !== request.baseTargetCommit
|
|
&& git(this.repositoryRoot, [
|
|
'merge-base', '--is-ancestor', currentTarget, request.sourceCommit,
|
|
], { allowFailure: true }).status !== 0) {
|
|
blockers.push({ code: 'TARGET_ADVANCED', expected: request.baseTargetCommit, actual: currentTarget });
|
|
}
|
|
const currentBranch = git(this.repositoryRoot, ['branch', '--show-current']).stdout;
|
|
if (currentBranch !== request.targetBranch) blockers.push({ code: 'TARGET_NOT_CHECKED_OUT', currentBranch });
|
|
const dirtyEntries = git(this.repositoryRoot, ['status', '--porcelain']).stdout.split('\n').filter(Boolean);
|
|
if (dirtyEntries.length) blockers.push({ code: 'TARGET_WORKTREE_DIRTY', entries: dirtyEntries });
|
|
if (git(this.repositoryRoot, [
|
|
'merge-base', '--is-ancestor', currentTarget, request.sourceCommit,
|
|
], { allowFailure: true }).status !== 0) blockers.push({ code: 'FAST_FORWARD_NOT_POSSIBLE' });
|
|
return {
|
|
action: 'land-integration', request, actorId: actor, currentTarget,
|
|
blockers, canProceed: blockers.length === 0, mutationPerformed: false,
|
|
};
|
|
}
|
|
|
|
land({ requestId, actorId } = {}) {
|
|
const database = this.controlPlane.database;
|
|
try {
|
|
database.exec('BEGIN IMMEDIATE');
|
|
} catch (error) {
|
|
if (/busy|locked/i.test(error.message ?? '')) {
|
|
throw new TorchError('Another integration or project-state writer holds the landing queue. Retry after it finishes.', {
|
|
code: 'INTEGRATION_LANDING_BUSY', details: { requestId },
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
let transactionFinished = false;
|
|
try {
|
|
// This write transaction is the per-project landing mutex. A second
|
|
// Session Manager process cannot pass the target/ref checks until this
|
|
// one has either landed or failed.
|
|
const plan = this.planLanding({ requestId, actorId });
|
|
if (!plan.canProceed) {
|
|
throw new TorchError('Integration landing refused by main-protection policy', {
|
|
code: 'INTEGRATION_LANDING_BLOCKED', details: plan.blockers,
|
|
});
|
|
}
|
|
const landingAt = this.clock().toISOString();
|
|
database.prepare(`
|
|
UPDATE integration_requests SET state = 'landing', reason = NULL, updated_at = ? WHERE id = ?
|
|
`).run(landingAt, plan.request.id);
|
|
this.controlPlane.audit({
|
|
actorId, operation: 'integration.landing-start', entityType: 'integration-request', entityId: plan.request.id,
|
|
details: { commit: plan.request.sourceCommit, target: plan.request.targetBranch },
|
|
});
|
|
try {
|
|
git(this.repositoryRoot, ['merge', '--ff-only', plan.request.sourceCommit]);
|
|
} catch (error) {
|
|
const failedAt = this.clock().toISOString();
|
|
database.prepare(`
|
|
UPDATE integration_requests SET state = 'blocked', reason = ?, updated_at = ? WHERE id = ?
|
|
`).run('git-fast-forward-failed', failedAt, plan.request.id);
|
|
this.controlPlane.audit({
|
|
actorId, operation: 'integration.landing-failed',
|
|
entityType: 'integration-request', entityId: plan.request.id,
|
|
details: { commit: plan.request.sourceCommit, target: plan.request.targetBranch },
|
|
});
|
|
database.exec('COMMIT');
|
|
transactionFinished = true;
|
|
throw error;
|
|
}
|
|
const landedAt = this.clock().toISOString();
|
|
database.prepare(`
|
|
UPDATE integration_requests
|
|
SET state = 'landed', reason = NULL, landed_at = ?, updated_at = ? WHERE id = ?
|
|
`).run(landedAt, landedAt, plan.request.id);
|
|
this.controlPlane.audit({
|
|
actorId, operation: 'integration.land', entityType: 'integration-request', entityId: plan.request.id,
|
|
details: { commit: plan.request.sourceCommit, target: plan.request.targetBranch },
|
|
});
|
|
database.exec('COMMIT');
|
|
transactionFinished = true;
|
|
const landed = this.get(plan.request.id);
|
|
// Closure is a post-landing reconciliation. Never report a successful
|
|
// Git landing as failed just because its task bookkeeping needs review.
|
|
try {
|
|
const { config } = loadFleetDefinition(this.repositoryRoot);
|
|
if (config.backlog?.activity?.auto_close_after_landing === true) {
|
|
const backlog = new BacklogService({ repositoryRoot: this.repositoryRoot,
|
|
controlPlane: this.controlPlane, checkService: this.checkService,
|
|
integrationLookup: (id) => this.get(id), clock: this.clock });
|
|
landed.backlogClosure = backlog.reconcileLanded({ integrationRequest: landed.id, actorId, automatic: true });
|
|
}
|
|
} catch (error) {
|
|
landed.backlogClosure = { disposition: 'needs-review', reason: error.code ?? error.message };
|
|
try {
|
|
this.controlPlane.audit({ actorId, operation: 'backlog.landed-closure-needs-review',
|
|
entityType: 'integration-request', entityId: landed.id, details: landed.backlogClosure });
|
|
} catch (auditError) { landed.backlogClosure.auditError = auditError.code ?? auditError.message; }
|
|
}
|
|
return landed;
|
|
} catch (error) {
|
|
if (!transactionFinished) database.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
drain({ actorId, limit = 50 } = {}) {
|
|
const actor = this.controlPlane.assertIdentity(actorId);
|
|
if (!(this.policy.landing_authority ?? []).includes(actor)) {
|
|
throw new TorchError(`${actor} lacks landing authority`, {
|
|
code: 'INTEGRATION_AUTHORITY_REQUIRED', details: { allowed: this.policy.landing_authority ?? [] },
|
|
});
|
|
}
|
|
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
throw new TorchError('Integration queue drain limit must be between 1 and 500', {
|
|
code: 'INTEGRATION_DRAIN_LIMIT_INVALID', details: { limit },
|
|
});
|
|
}
|
|
const candidates = this.list().filter((request) => request.authorizedBy === actor
|
|
&& ['ready', 'testing'].includes(request.state)).slice(0, limit);
|
|
const results = [];
|
|
for (const candidate of candidates) {
|
|
const current = this.evaluate(candidate.id);
|
|
if (current.state !== 'ready') {
|
|
results.push({ requestId: candidate.id, state: current.state, reason: current.reason, landed: false });
|
|
continue;
|
|
}
|
|
try {
|
|
const landed = this.land({ requestId: candidate.id, actorId: actor });
|
|
results.push({ requestId: candidate.id, state: landed.state, landed: landed.state === 'landed',
|
|
...(landed.backlogClosure ? { backlogClosure: landed.backlogClosure } : {}) });
|
|
} catch (error) {
|
|
if (error.code === 'INTEGRATION_LANDING_BUSY') {
|
|
results.push({ requestId: candidate.id, state: 'queued', reason: 'another-lander-active', landed: false, retryable: true });
|
|
break;
|
|
}
|
|
if (error.code === 'INTEGRATION_LANDING_BLOCKED') {
|
|
const refreshed = this.evaluate(candidate.id);
|
|
results.push({ requestId: candidate.id, state: refreshed.state, reason: refreshed.reason,
|
|
blockers: error.details, landed: false });
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
return {
|
|
actorId: actor, processed: results.length,
|
|
landed: results.filter((entry) => entry.landed).length,
|
|
deferred: results.filter((entry) => !entry.landed).length,
|
|
results, mutationPerformed: results.some((entry) => entry.landed),
|
|
};
|
|
}
|
|
}
|