mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
feat(git): classify managed-file changes before apply (#1832)
* feat(git): classify managed-file changes before apply Pull now builds a fingerprint-bound plan of adds, modifies, deletes, and local conflicts. Apply refuses stale or blocked plans instead of overwriting live files, and promotion stays the only filesystem mutator. * fix(git): contain stack-dir probes before filesystem access The missing-stack and root-.env existence checks now resolve against the compose base and refuse paths that escape it before lstat or existsSync. * fix(git): address managed-file change plan audit blockers Wire build-context live inventory into the planner, reject special file nodes without readFile, fingerprint configured project env files, enrich plan metadata, and compute the create plan before promotion. Redact drift ledger service keys for managed-path conflicts and clear pending plan columns on revision reset. * fix(git): unblock change-plan CI sinks and fifo test Hash stack files through a contained open plus fstat on the same handle so CodeQL no longer flags the lstat/read race, and create fifo fixtures with mkfifo instead of mkfifoSync. * fix(git): preserve unowned context files and align candidate validation Inspect prior and candidate build contexts together, delete only owned paths, reject context-root symlinks before walking, and validate with the env-file model deploy will use after promotion. * fix(git): contain live context and candidate env path sinks Inline resolve and startsWith at the lstat and access calls so containment is checked at the filesystem sink. * fix(git): resolve live context walks from the compose root Rebuild readdir, lstat, and access paths from the compose directory at each sink so containment is checked against a known-safe base. * fix(git): validate synced env removal against post-promotion files A managed .env that the next revision omits must not be used for candidate validation or invocation, because promotion deletes it. Context walks now bound directory entries and skip descendants under nested symlinks. Plan fingerprints bind review metadata and secret-path matching covers .env.* names. * docs(git): capture classified change-plan review screenshots Replace the old Monaco pull-preview images with the classified operation list used by Apply. * fix(git): treat invocation drift as reviewable, not a file conflict A live Compose command-line change is not a managed-file conflict. Reviewed apply records the incoming invocation; webhook auto-apply still refuses.
This commit is contained in:
@@ -460,6 +460,11 @@ export interface StackGitSource {
|
||||
manifest_version: number | null; // cache of the managed-project manifest's manifestVersion (file is the source of truth)
|
||||
manifest_state: GitSourceManifestState | null; // DB-only enum, wider than the file state; see types/gitProjectManifest.ts
|
||||
manifest_generation: string | null; // stack-relative path of the applied generation dir
|
||||
pending_plan_fingerprint: string | null;
|
||||
pending_plan_blocked: boolean | null;
|
||||
pending_plan_summary: string | null;
|
||||
last_plan_fingerprint: string | null;
|
||||
last_plan_outcome: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -1155,6 +1160,7 @@ export class DatabaseService {
|
||||
this.migrateStackDossierHashes();
|
||||
this.migrateGitSourceMultiFile();
|
||||
this.migrateGitSourceManifest();
|
||||
this.migrateGitSourceChangePlan();
|
||||
this.migrateNodeUpdateSkips();
|
||||
this.migrateStackAlertServiceScope();
|
||||
|
||||
@@ -2536,6 +2542,14 @@ export class DatabaseService {
|
||||
this.tryAddColumn('stack_git_sources', 'manifest_generation', 'TEXT');
|
||||
}
|
||||
|
||||
private migrateGitSourceChangePlan(): void {
|
||||
this.tryAddColumn('stack_git_sources', 'pending_plan_fingerprint', 'TEXT');
|
||||
this.tryAddColumn('stack_git_sources', 'pending_plan_blocked', 'INTEGER');
|
||||
this.tryAddColumn('stack_git_sources', 'pending_plan_summary', 'TEXT');
|
||||
this.tryAddColumn('stack_git_sources', 'last_plan_fingerprint', 'TEXT');
|
||||
this.tryAddColumn('stack_git_sources', 'last_plan_outcome', 'TEXT');
|
||||
}
|
||||
|
||||
private migrateGitSourceMultiFile(): void {
|
||||
this.tryAddColumn('stack_git_sources', 'compose_paths', 'TEXT');
|
||||
this.tryAddColumn('stack_git_sources', 'context_dir', 'TEXT');
|
||||
@@ -3854,6 +3868,10 @@ export class DatabaseService {
|
||||
this.db.prepare('UPDATE stack_drift_findings SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL').run(resolvedAt, id);
|
||||
}
|
||||
|
||||
public updateDriftFindingMessage(id: number, message: string): void {
|
||||
this.db.prepare('UPDATE stack_drift_findings SET message = ? WHERE id = ? AND resolved_at IS NULL').run(message, id);
|
||||
}
|
||||
|
||||
/** Open (unresolved) findings for a stack, oldest first. */
|
||||
public getOpenDriftFindings(nodeId: number, stackName: string): StackDriftFindingRow[] {
|
||||
return this.db.prepare(
|
||||
@@ -6204,6 +6222,13 @@ export class DatabaseService {
|
||||
pending_env_content: (row.pending_env_content as string | null) ?? null,
|
||||
pending_fetched_at: (row.pending_fetched_at as number | null) ?? null,
|
||||
last_debounce_at: (row.last_debounce_at as number | null) ?? null,
|
||||
pending_plan_fingerprint: (row.pending_plan_fingerprint as string | null) ?? null,
|
||||
pending_plan_blocked: row.pending_plan_blocked === undefined || row.pending_plan_blocked === null
|
||||
? null
|
||||
: Number(row.pending_plan_blocked) === 1,
|
||||
pending_plan_summary: (row.pending_plan_summary as string | null) ?? null,
|
||||
last_plan_fingerprint: (row.last_plan_fingerprint as string | null) ?? null,
|
||||
last_plan_outcome: (row.last_plan_outcome as string | null) ?? null,
|
||||
created_at: row.created_at as number,
|
||||
updated_at: row.updated_at as number,
|
||||
};
|
||||
@@ -6219,7 +6244,7 @@ export class DatabaseService {
|
||||
return rows.map(r => this.parseGitSource(r)!);
|
||||
}
|
||||
|
||||
public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at' | 'applied_deploy_spec' | 'manifest_version' | 'manifest_state' | 'manifest_generation'>): number {
|
||||
public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at' | 'applied_deploy_spec' | 'manifest_version' | 'manifest_state' | 'manifest_generation' | 'pending_plan_fingerprint' | 'pending_plan_blocked' | 'pending_plan_summary' | 'last_plan_fingerprint' | 'last_plan_outcome'>): number {
|
||||
const now = Date.now();
|
||||
const existing = this.getGitSource(source.stack_name);
|
||||
const composePathsJson = JSON.stringify(source.compose_paths ?? [source.compose_path]);
|
||||
@@ -6285,16 +6310,57 @@ export class DatabaseService {
|
||||
).run(version, state, generation, Date.now(), stackName);
|
||||
}
|
||||
|
||||
public setGitSourcePending(stackName: string, commitSha: string, composeContent: string, envContent: string | null): void {
|
||||
public setGitSourcePending(
|
||||
stackName: string,
|
||||
commitSha: string,
|
||||
composeContent: string,
|
||||
envContent: string | null,
|
||||
plan?: { fingerprint: string; blocked: boolean; summary: string },
|
||||
): void {
|
||||
this.db.prepare(
|
||||
`UPDATE stack_git_sources SET
|
||||
pending_commit_sha = ?,
|
||||
pending_compose_content = ?,
|
||||
pending_env_content = ?,
|
||||
pending_fetched_at = ?,
|
||||
pending_plan_fingerprint = ?,
|
||||
pending_plan_blocked = ?,
|
||||
pending_plan_summary = ?,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(commitSha, composeContent, envContent, Date.now(), Date.now(), stackName);
|
||||
).run(
|
||||
commitSha,
|
||||
composeContent,
|
||||
envContent,
|
||||
Date.now(),
|
||||
plan?.fingerprint ?? null,
|
||||
plan ? (plan.blocked ? 1 : 0) : null,
|
||||
plan?.summary ?? null,
|
||||
Date.now(),
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
|
||||
public updateGitSourcePendingPlan(
|
||||
stackName: string,
|
||||
composeContent: string,
|
||||
plan: { fingerprint: string; blocked: boolean; summary: string },
|
||||
): void {
|
||||
this.db.prepare(
|
||||
`UPDATE stack_git_sources SET
|
||||
pending_compose_content = ?,
|
||||
pending_plan_fingerprint = ?,
|
||||
pending_plan_blocked = ?,
|
||||
pending_plan_summary = ?,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(composeContent, plan.fingerprint, plan.blocked ? 1 : 0, plan.summary, Date.now(), stackName);
|
||||
}
|
||||
|
||||
public setGitSourceLastPlan(stackName: string, fingerprint: string | null, outcome: string | null): void {
|
||||
this.db.prepare(
|
||||
`UPDATE stack_git_sources SET last_plan_fingerprint = ?, last_plan_outcome = ?, updated_at = ? WHERE stack_name = ?`
|
||||
).run(fingerprint, outcome, Date.now(), stackName);
|
||||
}
|
||||
|
||||
public clearGitSourcePending(stackName: string): void {
|
||||
@@ -6304,6 +6370,9 @@ export class DatabaseService {
|
||||
pending_compose_content = NULL,
|
||||
pending_env_content = NULL,
|
||||
pending_fetched_at = NULL,
|
||||
pending_plan_fingerprint = NULL,
|
||||
pending_plan_blocked = NULL,
|
||||
pending_plan_summary = NULL,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(Date.now(), stackName);
|
||||
@@ -6318,6 +6387,9 @@ export class DatabaseService {
|
||||
pending_compose_content = NULL,
|
||||
pending_env_content = NULL,
|
||||
pending_fetched_at = NULL,
|
||||
pending_plan_fingerprint = NULL,
|
||||
pending_plan_blocked = NULL,
|
||||
pending_plan_summary = NULL,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(commitSha, contentHash, Date.now(), stackName);
|
||||
@@ -6337,6 +6409,9 @@ export class DatabaseService {
|
||||
pending_compose_content = NULL,
|
||||
pending_env_content = NULL,
|
||||
pending_fetched_at = NULL,
|
||||
pending_plan_fingerprint = NULL,
|
||||
pending_plan_blocked = NULL,
|
||||
pending_plan_summary = NULL,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(Date.now(), stackName);
|
||||
|
||||
@@ -38,6 +38,17 @@ function findingKey(service: string, kind: string): string {
|
||||
return JSON.stringify([service, kind]);
|
||||
}
|
||||
|
||||
const SPATIAL_FINDING_KINDS = new Set([
|
||||
'service-missing',
|
||||
'service-undeclared',
|
||||
'image-mismatch',
|
||||
'ports-mismatch',
|
||||
'network-undeclared',
|
||||
'network-missing',
|
||||
]);
|
||||
|
||||
const GIT_MANAGED_PATH_KIND = 'managed-path-conflict';
|
||||
|
||||
/**
|
||||
* Order-independent serialization of the parsed model so two compose files that
|
||||
* differ only in comments, whitespace, or key order hash equal, while a real
|
||||
@@ -141,6 +152,7 @@ export class DriftLedgerService {
|
||||
}
|
||||
const toResolve: StackDriftFindingRow[] = [];
|
||||
for (const [key, row] of openByKey) {
|
||||
if (!SPATIAL_FINDING_KINDS.has(row.finding_type)) continue;
|
||||
if (!currentByKey.has(key)) toResolve.push(row);
|
||||
}
|
||||
// Stamp the check time and apply any transitions in one transaction, so the
|
||||
@@ -205,6 +217,7 @@ export class DriftLedgerService {
|
||||
}
|
||||
const toResolve: StackDriftFindingRow[] = [];
|
||||
for (const [key, row] of openByKey) {
|
||||
if (!SPATIAL_FINDING_KINDS.has(row.finding_type)) continue;
|
||||
if (!currentByKey.has(key)) toResolve.push(row);
|
||||
}
|
||||
db.getDb().transaction(() => {
|
||||
@@ -271,6 +284,64 @@ export class DriftLedgerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or refresh Git managed-path findings. Never resolves. An existing
|
||||
* open row keeps its original detected_at; only the redacted message updates.
|
||||
*/
|
||||
upsertManagedPathConflicts(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
conflicts: Array<{ path: string; op: string; role: string; sensitivity: 'high' | 'medium' | 'low' }>,
|
||||
): void {
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
const open = db.getOpenDriftFindings(nodeId, stackName)
|
||||
.filter((r) => r.finding_type === GIT_MANAGED_PATH_KIND);
|
||||
const openByKey = new Map(open.map((r) => [r.service, r]));
|
||||
db.getDb().transaction(() => {
|
||||
for (const conflict of conflicts) {
|
||||
const key = sha256Hex(`${stackName}\0${conflict.path}`);
|
||||
const message = conflict.sensitivity === 'high'
|
||||
? `secret-bearing managed path (${conflict.op})`
|
||||
: `${conflict.role} ${conflict.op}`;
|
||||
const existing = openByKey.get(key);
|
||||
if (existing) {
|
||||
db.updateDriftFindingMessage(existing.id, message);
|
||||
openByKey.delete(key);
|
||||
continue;
|
||||
}
|
||||
db.insertDriftFinding({
|
||||
node_id: nodeId,
|
||||
stack_name: stackName,
|
||||
service: key,
|
||||
finding_type: GIT_MANAGED_PATH_KIND,
|
||||
severity: 'warning',
|
||||
message,
|
||||
expected_json: null,
|
||||
actual_json: null,
|
||||
detected_at: now,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every open managed-path-conflict for this stack. Call only after
|
||||
* a clean promotion; a clean pull must not close an existing Git finding.
|
||||
*/
|
||||
resolveManagedPathConflicts(nodeId: number, stackName: string): void {
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
const open = db.getOpenDriftFindings(nodeId, stackName)
|
||||
.filter((r) => r.finding_type === GIT_MANAGED_PATH_KIND);
|
||||
if (open.length === 0) return;
|
||||
db.getDb().transaction(() => {
|
||||
for (const row of open) {
|
||||
db.resolveDriftFinding(row.id, now);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a drift transition to the stack activity timeline. History-only (no
|
||||
* external channel dispatch): a drift signal belongs in the activity feed, not
|
||||
|
||||
@@ -1867,6 +1867,40 @@ export class FileSystemService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like pathKind, but distinguishes a symlink leaf from a regular file.
|
||||
* Used by the Git change planner so a swapped symlink is type-changed,
|
||||
* not hashed as if it were the target's content.
|
||||
*/
|
||||
async observeStackPath(
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
scope?: FileRootScope,
|
||||
): Promise<'file' | 'directory' | 'symlink' | 'special' | null> {
|
||||
try {
|
||||
if (scope?.rootAbsDir === undefined) {
|
||||
// Canonical js/path-injection barrier inline with the lstat sink. A missing
|
||||
// stack dir must return null (leaf resolve would throw path-escape). CodeQL
|
||||
// only credits containment when it sits at the sink.
|
||||
const baseResolved = path.resolve(this.baseDir);
|
||||
const stackDir = path.resolve(baseResolved, stackName);
|
||||
if (stackDir.startsWith(baseResolved + path.sep)) {
|
||||
await fsPromises.lstat(stackDir);
|
||||
}
|
||||
}
|
||||
const safePath = await this.resolveScopedLeafPath(stackName, relPath, scope);
|
||||
const stat = await fsPromises.lstat(safePath);
|
||||
if (stat.isSymbolicLink()) return 'symlink';
|
||||
if (stat.isDirectory()) return 'directory';
|
||||
if (stat.isFile()) return 'file';
|
||||
return 'special';
|
||||
} catch (err: unknown) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic-concurrency write for arbitrary stack files (file-explorer
|
||||
* editor save path). If `expectedMtimeMs` is provided, opens the target,
|
||||
@@ -1972,24 +2006,19 @@ export class FileSystemService {
|
||||
await fsPromises.rm(leafPath, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fsPromises.unlink(leafPath);
|
||||
} catch (err: unknown) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === 'EISDIR') {
|
||||
try {
|
||||
await fsPromises.rmdir(leafPath);
|
||||
} catch (inner: unknown) {
|
||||
const ie = inner as NodeJS.ErrnoException;
|
||||
if (ie.code === 'ENOTEMPTY' || ie.code === 'EEXIST') {
|
||||
throw Object.assign(new Error('Directory is not empty'), { code: 'NOT_EMPTY' });
|
||||
}
|
||||
throw inner;
|
||||
if (leafStat.isDirectory()) {
|
||||
try {
|
||||
await fsPromises.rmdir(leafPath);
|
||||
} catch (inner: unknown) {
|
||||
const ie = inner as NodeJS.ErrnoException;
|
||||
if (ie.code === 'ENOTEMPTY' || ie.code === 'EEXIST') {
|
||||
throw Object.assign(new Error('Directory is not empty'), { code: 'NOT_EMPTY' });
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
throw inner;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await fsPromises.unlink(leafPath);
|
||||
}
|
||||
|
||||
async mkdirStackPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* Classified compare of prior-manifest managed paths, the candidate Git
|
||||
* inventory, and live disk. Pure policy: it never writes the stack directory.
|
||||
* Promotion stays in GitProjectManifestService.
|
||||
*/
|
||||
import { createHash } from 'crypto';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { GitProjectManifestService } from './GitProjectManifestService';
|
||||
import { collectManifestFilePaths } from '../helpers/manifestFilePaths';
|
||||
import { isEnvLikeFileName } from '../helpers/envFileResolution';
|
||||
import { sha256Hex } from '../utils/hashing';
|
||||
import type {
|
||||
BuildContextPlan,
|
||||
ComposeInputEntry,
|
||||
DeletionAuthority,
|
||||
GitProjectManifest,
|
||||
InputOwnership,
|
||||
InputRole,
|
||||
InputSensitivity,
|
||||
ManifestProvenance,
|
||||
} from '../types/gitProjectManifest';
|
||||
import {
|
||||
BLOCKING_CHANGE_PLAN_OPS,
|
||||
GIT_CHANGE_PLAN_SCHEMA_VERSION,
|
||||
type GitChangePlan,
|
||||
type GitChangePlanCounts,
|
||||
type GitChangePlanMode,
|
||||
type GitChangePlanOp,
|
||||
type GitChangePlanOperation,
|
||||
type PublicGitChangePlan,
|
||||
type PublicGitChangePlanOperation,
|
||||
type PublicPendingPlan,
|
||||
} from '../types/gitChangePlan';
|
||||
|
||||
const INVOCATION_PATH_KEY = '__invocation__';
|
||||
|
||||
interface PathMeta {
|
||||
hash: string | null;
|
||||
role: InputRole | 'build-context-file';
|
||||
deletionAuthority: DeletionAuthority | null;
|
||||
sensitivity: InputSensitivity;
|
||||
ownership: InputOwnership;
|
||||
provenance: ManifestProvenance;
|
||||
}
|
||||
|
||||
function isSecretBearingRelPath(rel: string): boolean {
|
||||
const base = rel.split('/').pop()?.toLowerCase() ?? '';
|
||||
return isEnvLikeFileName(rel)
|
||||
|| base.includes('secret')
|
||||
|| base.includes('credential')
|
||||
|| base.endsWith('.pem')
|
||||
|| base === 'id_rsa';
|
||||
}
|
||||
|
||||
type LiveKind = Awaited<ReturnType<FileSystemService['observeStackPath']>>;
|
||||
|
||||
function isSymlinkEscape(err: unknown): boolean {
|
||||
return (err as NodeJS.ErrnoException).code === 'SYMLINK_ESCAPE';
|
||||
}
|
||||
|
||||
async function observeKind(
|
||||
fsSvc: FileSystemService,
|
||||
stackName: string,
|
||||
pathKey: string,
|
||||
): Promise<LiveKind | 'escape'> {
|
||||
try {
|
||||
return await fsSvc.observeStackPath(stackName, pathKey);
|
||||
} catch (err) {
|
||||
if (isSymlinkEscape(err)) return 'escape';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BuildGitChangePlanInput {
|
||||
stackName: string;
|
||||
commitSha: string;
|
||||
mode: GitChangePlanMode;
|
||||
priorManifest: GitProjectManifest | null;
|
||||
candidateInputs: ComposeInputEntry[];
|
||||
candidateBuildContexts: BuildContextPlan[];
|
||||
candidateInvocation: string[];
|
||||
liveInvocation: string[];
|
||||
/** Pre-manifest stacks: compose files + synced .env that Sencho already wrote. */
|
||||
legacyOwnedPaths?: string[];
|
||||
/** Live hashes captured when the pending plan was reviewed. A later mismatch is local-modified. */
|
||||
reviewedLiveHashes?: ReadonlyMap<string, string | null>;
|
||||
/** Stack-root project env files configured for deploy (live disk, not Git inventory). */
|
||||
projectEnvFiles?: string[];
|
||||
}
|
||||
|
||||
export class GitChangePlanService {
|
||||
private static instance: GitChangePlanService;
|
||||
|
||||
static getInstance(): GitChangePlanService {
|
||||
if (!GitChangePlanService.instance) {
|
||||
GitChangePlanService.instance = new GitChangePlanService();
|
||||
}
|
||||
return GitChangePlanService.instance;
|
||||
}
|
||||
|
||||
async build(input: BuildGitChangePlanInput): Promise<GitChangePlan> {
|
||||
const priorIndex = input.priorManifest
|
||||
? this.indexPaths(input.priorManifest.inputs, input.priorManifest.buildContexts)
|
||||
: new Map<string, PathMeta>();
|
||||
const candidateIndex = this.indexPaths(input.candidateInputs, input.candidateBuildContexts);
|
||||
const priorPaths = input.priorManifest
|
||||
? collectManifestFilePaths(input.priorManifest)
|
||||
: [];
|
||||
const candidatePaths = collectManifestFilePaths({
|
||||
inputs: input.candidateInputs,
|
||||
buildContexts: input.candidateBuildContexts,
|
||||
});
|
||||
const contextExtras = await this.collectContextUniverseExtras({
|
||||
stackName: input.stackName,
|
||||
candidateInputs: input.candidateInputs,
|
||||
candidateBuildContexts: input.candidateBuildContexts,
|
||||
priorBuildContexts: input.priorManifest?.buildContexts ?? [],
|
||||
priorInputs: input.priorManifest?.inputs ?? [],
|
||||
manifestSvc: GitProjectManifestService.getInstance(),
|
||||
});
|
||||
const projectEnvFiles = input.projectEnvFiles ?? [];
|
||||
const universe = this.mergePaths(
|
||||
this.mergePaths(priorPaths, candidatePaths),
|
||||
this.mergePaths(contextExtras, projectEnvFiles),
|
||||
);
|
||||
const contextExtraSet = new Set(contextExtras.map((p) => p.toLowerCase()));
|
||||
const projectEnvSet = new Set(projectEnvFiles.map((p) => p.toLowerCase()));
|
||||
const legacyOwned = new Set(input.legacyOwnedPaths ?? []);
|
||||
const fsSvc = FileSystemService.getInstance();
|
||||
const manifestSvc = GitProjectManifestService.getInstance();
|
||||
|
||||
const classified: GitChangePlanOperation[] = [];
|
||||
for (const pathKey of universe) {
|
||||
const pathFold = pathKey.toLowerCase();
|
||||
const prior = priorIndex.get(pathFold);
|
||||
const candidate = candidateIndex.get(pathFold);
|
||||
classified.push(await this.classifyPath({
|
||||
stackName: input.stackName,
|
||||
pathKey,
|
||||
prior,
|
||||
candidate,
|
||||
mode: input.mode,
|
||||
legacyOwned,
|
||||
reviewedLiveHash: input.reviewedLiveHashes?.get(pathFold),
|
||||
hasReviewedLive: input.reviewedLiveHashes?.has(pathFold) === true,
|
||||
isContextExtra: contextExtraSet.has(pathFold)
|
||||
&& prior === undefined
|
||||
&& candidate === undefined,
|
||||
isProjectEnv: projectEnvSet.has(pathFold),
|
||||
sourceRevision: input.commitSha,
|
||||
fsSvc,
|
||||
manifestSvc,
|
||||
}));
|
||||
}
|
||||
|
||||
const operations = this.pairRenames(classified);
|
||||
const { op: invocationOp, liveDiverged: invocationBlocked } = this.classifyInvocation(
|
||||
input.priorManifest,
|
||||
input.candidateInvocation,
|
||||
input.liveInvocation,
|
||||
input.commitSha,
|
||||
);
|
||||
if (invocationOp) operations.push(invocationOp);
|
||||
|
||||
const counts = this.countOps(operations);
|
||||
const blocked = operations.some((op) => BLOCKING_CHANGE_PLAN_OPS.has(op.op));
|
||||
const fingerprint = this.fingerprint({
|
||||
commitSha: input.commitSha,
|
||||
priorManifestVersion: input.priorManifest?.manifestVersion ?? null,
|
||||
priorAppliedDir: input.priorManifest?.generation.appliedDir ?? null,
|
||||
operations,
|
||||
});
|
||||
|
||||
return {
|
||||
schemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION,
|
||||
fingerprint,
|
||||
blocked,
|
||||
invocationBlocked,
|
||||
candidateInvocation: input.candidateInvocation,
|
||||
liveInvocation: input.liveInvocation,
|
||||
priorInvocation: input.priorManifest?.project.invocation ?? [],
|
||||
operations,
|
||||
counts,
|
||||
};
|
||||
}
|
||||
|
||||
toPublic(plan: GitChangePlan): PublicGitChangePlan {
|
||||
return {
|
||||
blocked: plan.blocked,
|
||||
counts: plan.counts,
|
||||
operations: plan.operations
|
||||
.filter((op) => op.op !== 'unchanged')
|
||||
.map((op) => this.toPublicOp(op)),
|
||||
invocation: {
|
||||
candidateChanged: this.invocationsDiffer(plan.candidateInvocation, plan.priorInvocation),
|
||||
liveDiverged: plan.invocationBlocked,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
toPendingSummary(plan: GitChangePlan): PublicPendingPlan {
|
||||
const publicPlan = this.toPublic(plan);
|
||||
return {
|
||||
fingerprint: plan.fingerprint,
|
||||
blocked: publicPlan.blocked,
|
||||
counts: publicPlan.counts,
|
||||
operations: publicPlan.operations,
|
||||
};
|
||||
}
|
||||
|
||||
private toPublicOp(op: GitChangePlanOperation): PublicGitChangePlanOperation {
|
||||
const redact = op.sensitivity === 'high';
|
||||
const publicOp: PublicGitChangePlanOperation = {
|
||||
path: redact || op.op === 'invocation' ? null : op.pathKey,
|
||||
op: op.op,
|
||||
role: op.role,
|
||||
};
|
||||
if (op.fromPath !== undefined) {
|
||||
publicOp.fromPath = redact ? null : op.fromPath;
|
||||
}
|
||||
return publicOp;
|
||||
}
|
||||
|
||||
private fingerprint(input: {
|
||||
commitSha: string;
|
||||
priorManifestVersion: number | null;
|
||||
priorAppliedDir: string | null;
|
||||
operations: GitChangePlanOperation[];
|
||||
}): string {
|
||||
const ops = [...input.operations]
|
||||
.sort((a, b) => a.pathKey.localeCompare(b.pathKey))
|
||||
.map((op) => ({
|
||||
pathKey: op.pathKey,
|
||||
op: op.op,
|
||||
priorHash: op.priorHash,
|
||||
candidateHash: op.candidateHash,
|
||||
liveHash: op.liveHash,
|
||||
role: op.role,
|
||||
deletionAuthority: op.deletionAuthority,
|
||||
fromPath: op.fromPath ?? null,
|
||||
ownership: op.ownership,
|
||||
provenance: op.provenance,
|
||||
sensitivity: op.sensitivity,
|
||||
reason: op.reason,
|
||||
}));
|
||||
const canonical = {
|
||||
schemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION,
|
||||
commitSha: input.commitSha,
|
||||
priorManifestVersion: input.priorManifestVersion,
|
||||
priorAppliedDir: input.priorAppliedDir,
|
||||
operations: ops,
|
||||
};
|
||||
return createHash('sha256').update(JSON.stringify(canonical), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
private indexPaths(inputs: ComposeInputEntry[], buildContexts: BuildContextPlan[]): Map<string, PathMeta> {
|
||||
const index = new Map<string, PathMeta>();
|
||||
const contextSensitivity = new Map<string, InputSensitivity>();
|
||||
for (const entry of inputs) {
|
||||
if (entry.materializedPath === null) continue;
|
||||
if (entry.dependencyKind === 'build-context' || entry.dependencyKind === 'build-additional-context') {
|
||||
contextSensitivity.set(entry.materializedPath.toLowerCase(), entry.sensitivity);
|
||||
}
|
||||
if (entry.ownership !== 'managed' || entry.state !== 'present' || entry.materializedPath === null) continue;
|
||||
if (entry.dependencyKind === 'build-context' || entry.dependencyKind === 'build-additional-context') continue;
|
||||
index.set(entry.materializedPath.toLowerCase(), {
|
||||
hash: entry.contentSha256,
|
||||
role: entry.role,
|
||||
deletionAuthority: entry.deletionAuthority,
|
||||
sensitivity: entry.sensitivity,
|
||||
ownership: entry.ownership,
|
||||
provenance: entry.provenance,
|
||||
});
|
||||
}
|
||||
for (const context of buildContexts) {
|
||||
const parentSensitivity = contextSensitivity.get(context.repoPath.toLowerCase()) ?? 'medium';
|
||||
for (const file of context.files) {
|
||||
const rel = context.repoPath ? `${context.repoPath}/${file.path}` : file.path;
|
||||
index.set(rel.toLowerCase(), {
|
||||
hash: file.sha256,
|
||||
role: 'build-context-file',
|
||||
deletionAuthority: 'sencho',
|
||||
sensitivity: parentSensitivity,
|
||||
ownership: 'managed',
|
||||
provenance: 'fetch',
|
||||
});
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
private mergePaths(prior: string[], candidate: string[]): string[] {
|
||||
const byFold = new Map<string, string>();
|
||||
for (const rel of [...prior, ...candidate]) {
|
||||
const key = rel.toLowerCase();
|
||||
if (!byFold.has(key)) byFold.set(key, rel);
|
||||
}
|
||||
return [...byFold.values()].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
private async classifyPath(args: {
|
||||
stackName: string;
|
||||
pathKey: string;
|
||||
prior: PathMeta | undefined;
|
||||
candidate: PathMeta | undefined;
|
||||
mode: GitChangePlanMode;
|
||||
legacyOwned: Set<string>;
|
||||
reviewedLiveHash?: string | null;
|
||||
hasReviewedLive: boolean;
|
||||
isContextExtra: boolean;
|
||||
isProjectEnv: boolean;
|
||||
sourceRevision: string;
|
||||
fsSvc: FileSystemService;
|
||||
manifestSvc: GitProjectManifestService;
|
||||
}): Promise<GitChangePlanOperation> {
|
||||
const { pathKey, prior, candidate, mode, legacyOwned, sourceRevision } = args;
|
||||
const role = candidate?.role ?? prior?.role ?? (args.isProjectEnv ? 'env' : 'other');
|
||||
const deletionAuthority = candidate?.deletionAuthority ?? prior?.deletionAuthority ?? null;
|
||||
const secretExtra = args.isContextExtra && isSecretBearingRelPath(pathKey);
|
||||
const sensitivity = secretExtra
|
||||
? 'high'
|
||||
: (candidate?.sensitivity ?? prior?.sensitivity ?? (args.isProjectEnv ? 'high' : 'medium'));
|
||||
const ownership = candidate?.ownership
|
||||
?? prior?.ownership
|
||||
?? (args.isProjectEnv || args.isContextExtra ? 'unmanaged' : 'managed');
|
||||
const provenance = candidate?.provenance
|
||||
?? prior?.provenance
|
||||
?? (args.isProjectEnv || args.isContextExtra ? 'adopted' : 'fetch');
|
||||
const meta = { ownership, provenance, sourceRevision };
|
||||
const priorHash = prior?.hash ?? null;
|
||||
const candidateHash = candidate?.hash ?? null;
|
||||
const typeChanged = (reason: string): GitChangePlanOperation => this.op(
|
||||
pathKey, 'type-changed', role, deletionAuthority, priorHash, candidateHash, null, sensitivity, { ...meta, reason },
|
||||
);
|
||||
|
||||
const liveKind = await observeKind(args.fsSvc, args.stackName, pathKey);
|
||||
if (liveKind === 'escape') {
|
||||
return typeChanged('live path escapes the stack through a symlink');
|
||||
}
|
||||
|
||||
let liveHash: string | null = null;
|
||||
if (liveKind === 'file') {
|
||||
try {
|
||||
liveHash = await args.manifestSvc.hashStackFile(args.stackName, pathKey);
|
||||
} catch (err) {
|
||||
if (isSymlinkEscape(err)) return typeChanged('live path is not a regular file');
|
||||
const kindAfter = await observeKind(args.fsSvc, args.stackName, pathKey);
|
||||
if (kindAfter !== 'file' && kindAfter !== null) {
|
||||
return typeChanged('live path is not a regular file');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const priorPresent = prior !== undefined && priorHash !== null;
|
||||
const candidatePresent = candidate !== undefined && candidateHash !== null;
|
||||
|
||||
if (liveKind !== 'file' && liveKind !== null) {
|
||||
return typeChanged('live path is not a regular file');
|
||||
}
|
||||
|
||||
if (args.hasReviewedLive && args.reviewedLiveHash !== liveHash) {
|
||||
return this.op(pathKey, 'local-modified', role, deletionAuthority, priorHash, candidateHash, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'live hash changed since review',
|
||||
});
|
||||
}
|
||||
|
||||
if (priorPresent && candidatePresent) {
|
||||
if (liveKind === null) {
|
||||
return this.op(pathKey, 'local-missing', role, deletionAuthority, priorHash, candidateHash, null, sensitivity, {
|
||||
...meta,
|
||||
reason: 'managed path absent on disk',
|
||||
});
|
||||
}
|
||||
if (liveHash !== priorHash) {
|
||||
// Live vs last-applied, not vs candidate. Matching incoming
|
||||
// bytes by coincidence is still a local edit.
|
||||
return this.op(pathKey, 'local-modified', role, deletionAuthority, priorHash, candidateHash, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'live hash differs from prior managed hash',
|
||||
});
|
||||
}
|
||||
if (candidateHash === priorHash) {
|
||||
return this.op(pathKey, 'unchanged', role, deletionAuthority, priorHash, candidateHash, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'matches prior managed hash',
|
||||
});
|
||||
}
|
||||
return this.op(pathKey, 'modify', role, deletionAuthority, priorHash, candidateHash, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'candidate content differs from prior',
|
||||
});
|
||||
}
|
||||
|
||||
if (priorPresent && !candidatePresent) {
|
||||
if (liveKind === null) {
|
||||
return this.op(pathKey, 'local-missing', role, deletionAuthority, priorHash, null, null, sensitivity, {
|
||||
...meta,
|
||||
reason: 'managed path absent on disk',
|
||||
});
|
||||
}
|
||||
if (prior?.deletionAuthority !== 'sencho') {
|
||||
return this.op(pathKey, 'local-modified', role, deletionAuthority, priorHash, null, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'live path is not sencho-deletable',
|
||||
});
|
||||
}
|
||||
if (liveKind === 'file' && liveHash !== priorHash) {
|
||||
return this.op(pathKey, 'local-modified', role, deletionAuthority, priorHash, null, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'live hash differs from prior managed hash',
|
||||
});
|
||||
}
|
||||
return this.op(pathKey, 'delete', role, deletionAuthority, priorHash, null, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'removed from candidate',
|
||||
});
|
||||
}
|
||||
|
||||
if (!priorPresent && !candidatePresent) {
|
||||
if (args.isProjectEnv) {
|
||||
if (liveKind === null) {
|
||||
return this.op(pathKey, 'local-missing', role, deletionAuthority, null, null, null, sensitivity, {
|
||||
...meta,
|
||||
reason: 'configured project env file missing on disk',
|
||||
});
|
||||
}
|
||||
return this.op(pathKey, 'unchanged', role, deletionAuthority, null, null, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'configured project env file',
|
||||
});
|
||||
}
|
||||
if (args.isContextExtra) {
|
||||
return this.op(pathKey, 'unmanaged-collision', role, deletionAuthority, null, null, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'locally added in build context',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate-only path (add or collision).
|
||||
if (mode === 'create' || liveKind === null || legacyOwned.has(pathKey)) {
|
||||
return this.op(pathKey, 'add', role, deletionAuthority, null, candidateHash, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'new managed path',
|
||||
});
|
||||
}
|
||||
return this.op(pathKey, 'unmanaged-collision', role, deletionAuthority, null, candidateHash, liveHash, sensitivity, {
|
||||
...meta,
|
||||
reason: 'unmanaged live file at a candidate path',
|
||||
});
|
||||
}
|
||||
|
||||
private pairRenames(ops: GitChangePlanOperation[]): GitChangePlanOperation[] {
|
||||
const deletes = ops.filter((o) => o.op === 'delete' && o.priorHash);
|
||||
const adds = ops.filter((o) => o.op === 'add' && o.candidateHash);
|
||||
const usedDeletes = new Set<string>();
|
||||
const usedAdds = new Set<string>();
|
||||
const renames: GitChangePlanOperation[] = [];
|
||||
|
||||
const deletesByHash = new Map<string, GitChangePlanOperation[]>();
|
||||
for (const d of deletes) {
|
||||
const list = deletesByHash.get(d.priorHash!) ?? [];
|
||||
list.push(d);
|
||||
deletesByHash.set(d.priorHash!, list);
|
||||
}
|
||||
const addsByHash = new Map<string, GitChangePlanOperation[]>();
|
||||
for (const a of adds) {
|
||||
const list = addsByHash.get(a.candidateHash!) ?? [];
|
||||
list.push(a);
|
||||
addsByHash.set(a.candidateHash!, list);
|
||||
}
|
||||
|
||||
for (const [hash, delList] of deletesByHash) {
|
||||
const addList = addsByHash.get(hash);
|
||||
if (!addList) continue;
|
||||
const leftoverDel = delList
|
||||
.filter((d) => !usedDeletes.has(d.pathKey))
|
||||
.sort((a, b) => a.pathKey.localeCompare(b.pathKey));
|
||||
const leftoverAdd = addList
|
||||
.filter((a) => !usedAdds.has(a.pathKey))
|
||||
.sort((a, b) => a.pathKey.localeCompare(b.pathKey));
|
||||
const pairs = Math.min(leftoverDel.length, leftoverAdd.length);
|
||||
for (let i = 0; i < pairs; i++) {
|
||||
const del = leftoverDel[i];
|
||||
const add = leftoverAdd[i];
|
||||
usedDeletes.add(del.pathKey);
|
||||
usedAdds.add(add.pathKey);
|
||||
const sensitivity = add.sensitivity === 'high' || del.sensitivity === 'high' ? 'high' : add.sensitivity;
|
||||
renames.push(this.op(
|
||||
add.pathKey,
|
||||
'rename',
|
||||
add.role,
|
||||
del.deletionAuthority,
|
||||
del.priorHash,
|
||||
add.candidateHash,
|
||||
add.liveHash,
|
||||
sensitivity,
|
||||
{
|
||||
fromPath: del.pathKey,
|
||||
ownership: add.ownership,
|
||||
provenance: add.provenance,
|
||||
sourceRevision: add.sourceRevision,
|
||||
reason: 'same content, new path',
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const kept = ops.filter((o) =>
|
||||
!(o.op === 'delete' && usedDeletes.has(o.pathKey))
|
||||
&& !(o.op === 'add' && usedAdds.has(o.pathKey)),
|
||||
);
|
||||
return [...kept, ...renames].sort((a, b) => a.pathKey.localeCompare(b.pathKey));
|
||||
}
|
||||
|
||||
private classifyInvocation(
|
||||
prior: GitProjectManifest | null,
|
||||
candidateInvocation: string[],
|
||||
liveInvocation: string[],
|
||||
sourceRevision: string,
|
||||
): { op: GitChangePlanOperation | null; liveDiverged: boolean } {
|
||||
if (prior === null) return { op: null, liveDiverged: false };
|
||||
const priorInv = prior.project.invocation;
|
||||
const liveDiverged = this.invocationsDiffer(liveInvocation, priorInv);
|
||||
const candidateChanged = this.invocationsDiffer(candidateInvocation, priorInv);
|
||||
if (!liveDiverged && !candidateChanged) return { op: null, liveDiverged: false };
|
||||
return {
|
||||
liveDiverged,
|
||||
op: this.op(
|
||||
INVOCATION_PATH_KEY,
|
||||
'invocation',
|
||||
'invocation',
|
||||
null,
|
||||
sha256Hex(JSON.stringify(priorInv)),
|
||||
sha256Hex(JSON.stringify(candidateInvocation)),
|
||||
sha256Hex(JSON.stringify(liveInvocation)),
|
||||
'low',
|
||||
{
|
||||
ownership: 'managed',
|
||||
provenance: 'fetch',
|
||||
sourceRevision,
|
||||
reason: liveDiverged
|
||||
? 'live compose invocation diverged from prior'
|
||||
: 'candidate compose invocation changed',
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private invocationsEqual(a: string[], b: string[]): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
private invocationsDiffer(a: string[], b: string[]): boolean {
|
||||
return !this.invocationsEqual(a, b);
|
||||
}
|
||||
|
||||
private countOps(operations: GitChangePlanOperation[]): GitChangePlanCounts {
|
||||
const counts: GitChangePlanCounts = {
|
||||
add: 0,
|
||||
modify: 0,
|
||||
delete: 0,
|
||||
rename: 0,
|
||||
unchanged: 0,
|
||||
localModified: 0,
|
||||
localMissing: 0,
|
||||
typeChanged: 0,
|
||||
unmanagedCollision: 0,
|
||||
invocation: 0,
|
||||
};
|
||||
for (const op of operations) {
|
||||
switch (op.op) {
|
||||
case 'add': counts.add += 1; break;
|
||||
case 'modify': counts.modify += 1; break;
|
||||
case 'delete': counts.delete += 1; break;
|
||||
case 'rename': counts.rename += 1; break;
|
||||
case 'unchanged': counts.unchanged += 1; break;
|
||||
case 'local-modified': counts.localModified += 1; break;
|
||||
case 'local-missing': counts.localMissing += 1; break;
|
||||
case 'type-changed': counts.typeChanged += 1; break;
|
||||
case 'unmanaged-collision': counts.unmanagedCollision += 1; break;
|
||||
case 'invocation': counts.invocation += 1; break;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private async collectContextUniverseExtras(args: {
|
||||
stackName: string;
|
||||
candidateInputs: ComposeInputEntry[];
|
||||
candidateBuildContexts: BuildContextPlan[];
|
||||
priorBuildContexts: BuildContextPlan[];
|
||||
priorInputs: ComposeInputEntry[];
|
||||
manifestSvc: GitProjectManifestService;
|
||||
}): Promise<string[]> {
|
||||
const managedInputPaths = new Set(
|
||||
[...args.priorInputs, ...args.candidateInputs]
|
||||
.filter((i) => i.ownership === 'managed' && i.state === 'present' && i.materializedPath !== null)
|
||||
.map((i) => i.materializedPath!),
|
||||
);
|
||||
const contextsByFold = new Map<string, BuildContextPlan>();
|
||||
for (const context of [...args.priorBuildContexts, ...args.candidateBuildContexts]) {
|
||||
contextsByFold.set(context.repoPath.toLowerCase(), context);
|
||||
}
|
||||
const extras: string[] = [];
|
||||
for (const context of contextsByFold.values()) {
|
||||
const diverged = await args.manifestSvc.verifyContextOnDisk(
|
||||
args.stackName,
|
||||
context,
|
||||
managedInputPaths,
|
||||
);
|
||||
for (const entry of diverged) {
|
||||
const stackRel = this.stackPathFromContextDivergence(context.repoPath, entry);
|
||||
if (stackRel) extras.push(stackRel);
|
||||
}
|
||||
}
|
||||
return extras;
|
||||
}
|
||||
|
||||
private stackPathFromContextDivergence(contextRepoPath: string, diverged: string): string | null {
|
||||
if (
|
||||
diverged === '. (symbolic link)'
|
||||
|| diverged === '. (special file node)'
|
||||
|| diverged === '. (scan limit exceeded)'
|
||||
) {
|
||||
return contextRepoPath || '.';
|
||||
}
|
||||
const join = (rel: string): string => (contextRepoPath ? `${contextRepoPath}/${rel}` : rel);
|
||||
const annotated = diverged.match(
|
||||
/^(.+) \((?:locally added, not in the managed context|symbolic link|special file node|missing)\)$/,
|
||||
);
|
||||
if (annotated) return join(annotated[1]);
|
||||
if (!diverged.includes('(')) return join(diverged);
|
||||
return null;
|
||||
}
|
||||
|
||||
private op(
|
||||
pathKey: string,
|
||||
op: GitChangePlanOp,
|
||||
role: GitChangePlanOperation['role'],
|
||||
deletionAuthority: DeletionAuthority | null,
|
||||
priorHash: string | null,
|
||||
candidateHash: string | null,
|
||||
liveHash: string | null,
|
||||
sensitivity: InputSensitivity,
|
||||
meta: {
|
||||
fromPath?: string;
|
||||
ownership: InputOwnership;
|
||||
provenance: ManifestProvenance;
|
||||
sourceRevision: string;
|
||||
reason: string;
|
||||
},
|
||||
): GitChangePlanOperation {
|
||||
return {
|
||||
pathKey,
|
||||
op,
|
||||
role,
|
||||
deletionAuthority,
|
||||
priorHash,
|
||||
candidateHash,
|
||||
liveHash,
|
||||
sensitivity,
|
||||
ownership: meta.ownership,
|
||||
provenance: meta.provenance,
|
||||
sourceRevision: meta.sourceRevision,
|
||||
reason: meta.reason,
|
||||
...(meta.fromPath !== undefined ? { fromPath: meta.fromPath } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,22 @@ type RecoveryIncoming =
|
||||
| { inputs: ComposeInputEntry[]; buildContexts: BuildContextPlan[] }
|
||||
| { introducedPaths: string[] };
|
||||
|
||||
export type PromoteFailurePhase = 'pre_mutation' | 'restored' | 'recovery_required';
|
||||
|
||||
/** Typed promotion failure so apply can record restore vs pre-mutation vs recovery-required. */
|
||||
export class PromoteGenerationError extends Error {
|
||||
readonly phase: PromoteFailurePhase;
|
||||
readonly cause: unknown;
|
||||
|
||||
constructor(phase: PromoteFailurePhase, cause: unknown) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
super(message);
|
||||
this.name = 'PromoteGenerationError';
|
||||
this.phase = phase;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
const MANIFEST_STATES: readonly ManifestState[] = ['none', 'migrated', 'active', 'partial', 'unsupported'];
|
||||
const DEPENDENCY_KINDS: readonly InputDependencyKind[] = [
|
||||
'explicit', 'implicit-override', 'include', 'include-env', 'extends', 'env_file',
|
||||
@@ -649,72 +665,191 @@ export class GitProjectManifestService {
|
||||
|
||||
/** Hash of the stack-dir file at a materialized path, or null when absent. */
|
||||
async hashStackFile(stackName: string, relPath: string): Promise<string | null> {
|
||||
const abs = await this.stackFileAbs(stackName, relPath);
|
||||
const composeDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId());
|
||||
const baseResolved = path.resolve(composeDir);
|
||||
if (!isValidStackName(stackName) || !isSafeRelPath(relPath)) throw new Error('Invalid stack file path');
|
||||
const stackRoot = path.resolve(baseResolved, stackName);
|
||||
const abs = path.resolve(stackRoot, relPath);
|
||||
// Canonical js/path-injection barrier inline with the open sink. CodeQL
|
||||
// only credits containment when it sits at the sink; helpers are ignored.
|
||||
if (!stackRoot.startsWith(baseResolved + path.sep)) throw new Error('Invalid stack file path');
|
||||
if (abs !== stackRoot && !abs.startsWith(stackRoot + path.sep)) {
|
||||
throw new Error('Stack file path escapes the stack root');
|
||||
}
|
||||
if (!abs.startsWith(baseResolved + path.sep)) {
|
||||
throw new Error('Stack file path escapes the compose directory');
|
||||
}
|
||||
let flags = fs.constants.O_RDONLY;
|
||||
if (typeof fs.constants.O_NOFOLLOW === 'number') flags |= fs.constants.O_NOFOLLOW;
|
||||
if (typeof fs.constants.O_NONBLOCK === 'number') flags |= fs.constants.O_NONBLOCK;
|
||||
try {
|
||||
return sha256Of(await fs.promises.readFile(abs));
|
||||
const handle = await fs.promises.open(abs, flags);
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) return null;
|
||||
return sha256Of(await handle.readFile());
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === 'ENOENT' || err.code === 'ELOOP' || err.code === 'ENXIO' || err.code === 'EAGAIN') {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a build-context subtree on disk against the manifest's file-level
|
||||
* inventory. Returns the context-relative paths that diverge: files whose
|
||||
* hash differs, files missing from the stack, and files present in the
|
||||
* stack that the manifest does not own (locally added). This gives contexts
|
||||
* the same local-modification protection as plain managed files.
|
||||
* Compare a build-context subtree on disk to the manifest inventory.
|
||||
* Observes the context root with no-follow semantics before walking.
|
||||
* A symlink, special node, or file at the root returns a sentinel and
|
||||
* does not enumerate the target. Nested symlinks are classified without
|
||||
* following, and owned descendants beneath them are not inspected.
|
||||
* Scan limits count every visited entry (files and directories), plus
|
||||
* depth and on-disk bytes, and fail closed with `. (scan limit exceeded)`.
|
||||
* `boundsOverride` is for tests.
|
||||
*/
|
||||
async verifyContextOnDisk(stackName: string, context: BuildContextPlan, managedInputPaths?: Set<string>): Promise<string[]> {
|
||||
const abs = await this.stackFileAbs(stackName, context.repoPath);
|
||||
async verifyContextOnDisk(
|
||||
stackName: string,
|
||||
context: BuildContextPlan,
|
||||
managedInputPaths?: Set<string>,
|
||||
boundsOverride?: ManifestBounds,
|
||||
): Promise<string[]> {
|
||||
const bounds = boundsOverride ?? this.boundsConfig();
|
||||
const fsSvc = FileSystemService.getInstance();
|
||||
let rootKind: Awaited<ReturnType<FileSystemService['observeStackPath']>>;
|
||||
try {
|
||||
rootKind = await fsSvc.observeStackPath(stackName, context.repoPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'SYMLINK_ESCAPE') throw err;
|
||||
rootKind = 'symlink';
|
||||
}
|
||||
if (rootKind === 'symlink') return ['. (symbolic link)'];
|
||||
if (rootKind === 'special' || rootKind === 'file') return ['. (special file node)'];
|
||||
if (rootKind !== 'directory') return [];
|
||||
|
||||
if (!isValidStackName(stackName) || !isSafeRelPath(context.repoPath)) {
|
||||
throw new Error('Invalid stack file path');
|
||||
}
|
||||
const composeDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId());
|
||||
const diverged: string[] = [];
|
||||
const owned = new Set(context.files.map((f) => f.path));
|
||||
const walk = async (dir: string, rel: string): Promise<void> => {
|
||||
const expectedByPath = new Map(context.files.map((f) => [f.path, f.sha256]));
|
||||
const symlinkPrefixes: string[] = [];
|
||||
let filesSeen = 0;
|
||||
let bytesSeen = 0;
|
||||
let limitExceeded = false;
|
||||
const exceedLimit = (): void => {
|
||||
diverged.push('. (scan limit exceeded)');
|
||||
limitExceeded = true;
|
||||
};
|
||||
const walk = async (rel: string): Promise<void> => {
|
||||
if (limitExceeded) return;
|
||||
if (!isSafeRelPath(rel)) return;
|
||||
const depth = rel === '' ? 0 : rel.split('/').filter(Boolean).length;
|
||||
if (depth > bounds.maxPathDepth) {
|
||||
exceedLimit();
|
||||
return;
|
||||
}
|
||||
let entriesList: fs.Dirent[];
|
||||
try {
|
||||
entriesList = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
const baseResolved = path.resolve(composeDir);
|
||||
const dirParts = [stackName, context.repoPath, rel].filter((p) => p !== '');
|
||||
const dirAbs = path.resolve(baseResolved, ...dirParts);
|
||||
if (!dirAbs.startsWith(baseResolved + path.sep)) return;
|
||||
entriesList = await fs.promises.readdir(dirAbs, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // missing context dir reported by the owned-file loop below
|
||||
return;
|
||||
}
|
||||
for (const entry of entriesList) {
|
||||
if (limitExceeded) return;
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
continue;
|
||||
if (!isSafeRelPath(childRel)) continue;
|
||||
filesSeen += 1;
|
||||
if (filesSeen > bounds.maxFiles) {
|
||||
exceedLimit();
|
||||
return;
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
diverged.push(`${childRel} (symbolic link)`);
|
||||
symlinkPrefixes.push(childRel);
|
||||
continue;
|
||||
}
|
||||
// Files not in the context inventory: if they have a
|
||||
// managed-input owner (stack-relative path), they are owned
|
||||
// by another manifest entry. The managed set uses stack-
|
||||
// relative paths; the walk uses context-relative paths.
|
||||
if (!owned.has(childRel)) {
|
||||
const stackRel = context.repoPath ? `${context.repoPath}/${childRel}` : childRel;
|
||||
if (managedInputPaths && managedInputPaths.has(stackRel)) continue;
|
||||
if (entry.isDirectory()) {
|
||||
await walk(childRel);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFIFO() || entry.isSocket() || entry.isBlockDevice() || entry.isCharacterDevice()) {
|
||||
diverged.push(`${childRel} (special file node)`);
|
||||
continue;
|
||||
}
|
||||
const stackRel = context.repoPath ? `${context.repoPath}/${childRel}` : childRel;
|
||||
if (!expectedByPath.has(childRel)) {
|
||||
if (managedInputPaths?.has(stackRel)) continue;
|
||||
diverged.push(`${childRel} (locally added, not in the managed context)`);
|
||||
continue;
|
||||
}
|
||||
const expected = context.files.find((f) => f.path === childRel)?.sha256;
|
||||
const actual = await this.hashStackFile(stackName, context.repoPath ? `${context.repoPath}/${childRel}` : childRel);
|
||||
let onDiskBytes = 0;
|
||||
try {
|
||||
const baseResolved = path.resolve(composeDir);
|
||||
const childAbs = path.resolve(baseResolved, stackName, stackRel);
|
||||
if (!childAbs.startsWith(baseResolved + path.sep)) continue;
|
||||
onDiskBytes = (await fs.promises.lstat(childAbs)).size;
|
||||
} catch {
|
||||
diverged.push(`${childRel} (missing)`);
|
||||
continue;
|
||||
}
|
||||
if (onDiskBytes > bounds.maxFileBytes || bytesSeen + onDiskBytes > bounds.maxContextBytes) {
|
||||
exceedLimit();
|
||||
return;
|
||||
}
|
||||
bytesSeen += onDiskBytes;
|
||||
const expected = expectedByPath.get(childRel);
|
||||
const actual = await this.hashStackFile(stackName, stackRel);
|
||||
if (expected === undefined || actual !== expected) {
|
||||
diverged.push(childRel);
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(abs, '');
|
||||
await walk('');
|
||||
for (const ownedFile of context.files) {
|
||||
if (!owned.has(ownedFile.path)) continue;
|
||||
const present = await fs.promises
|
||||
.access(path.join(abs, ownedFile.path))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!present) diverged.push(`${ownedFile.path} (missing)`);
|
||||
if (!isSafeRelPath(ownedFile.path)) continue;
|
||||
if (symlinkPrefixes.some((p) => ownedFile.path === p || ownedFile.path.startsWith(`${p}/`))) {
|
||||
continue;
|
||||
}
|
||||
const stackRel = context.repoPath ? `${context.repoPath}/${ownedFile.path}` : ownedFile.path;
|
||||
let kind: Awaited<ReturnType<FileSystemService['observeStackPath']>>;
|
||||
try {
|
||||
kind = await fsSvc.observeStackPath(stackName, stackRel);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'SYMLINK_ESCAPE') throw err;
|
||||
kind = 'symlink';
|
||||
}
|
||||
if (kind === null) diverged.push(`${ownedFile.path} (missing)`);
|
||||
else if (kind === 'symlink') diverged.push(`${ownedFile.path} (symbolic link)`);
|
||||
else if (kind !== 'file') diverged.push(`${ownedFile.path} (special file node)`);
|
||||
}
|
||||
return diverged;
|
||||
}
|
||||
|
||||
private async tryRemoveEmptyDir(stackName: string, relPath: string, fsSvc: FileSystemService): Promise<void> {
|
||||
if (!relPath) return;
|
||||
try {
|
||||
await fsSvc.deleteStackPath(stackName, relPath, false, { protectedEnabled: false });
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTEMPTY' || code === 'EEXIST' || code === 'NOT_EMPTY') return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async tryRemoveEmptyParents(stackName: string, fileRel: string, fsSvc: FileSystemService): Promise<void> {
|
||||
const parts = fileRel.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
for (let i = parts.length - 1; i >= 1; i--) {
|
||||
await this.tryRemoveEmptyDir(stackName, parts.slice(0, i).join('/'), fsSvc);
|
||||
}
|
||||
}
|
||||
|
||||
private async stackFileAbs(stackName: string, relPath: string): Promise<string> {
|
||||
// Same resolution chain as FileSystemService: node.compose_dir ->
|
||||
// COMPOSE_DIR -> /app/compose. The stack name was validated upstream
|
||||
@@ -811,7 +946,10 @@ export class GitProjectManifestService {
|
||||
return priorRel !== undefined && priorRel !== rel;
|
||||
});
|
||||
if (caseOnlyChange !== undefined) {
|
||||
throw new Error(`Case-only managed path changes are not supported: ${priorByCaseFold.get(caseOnlyChange.toLowerCase())} -> ${caseOnlyChange}`);
|
||||
throw new PromoteGenerationError(
|
||||
'pre_mutation',
|
||||
new Error(`Case-only managed path changes are not supported: ${priorByCaseFold.get(caseOnlyChange.toLowerCase())} -> ${caseOnlyChange}`),
|
||||
);
|
||||
}
|
||||
const introduced = incomingFiles.filter((rel) => !priorKeys.has(rel.toLowerCase()));
|
||||
const affected = [...new Map([...priorFiles, ...incomingFiles].map((rel) => [rel.toLowerCase(), rel])).values()]
|
||||
@@ -882,18 +1020,16 @@ export class GitProjectManifestService {
|
||||
}
|
||||
|
||||
// 2. Stale cleanup: prior-manifest paths Sencho owns (deletionAuthority
|
||||
// sencho), absent from the new set. Only sencho-authority paths are
|
||||
// ever unlinked; user/none authority stays untouched. A failed
|
||||
// unlink FAILS the promotion (the transaction restores the prior
|
||||
// generation) rather than recording a tombstone for a file that
|
||||
// still exists and can silently change the deployed model.
|
||||
// sencho), absent from the new set. Only sencho-authority files are
|
||||
// unlinked. Build-context directory inventory entries are tombstoned
|
||||
// without a recursive directory delete; their owned files are
|
||||
// removed one path at a time. A failed unlink fails the promotion.
|
||||
const newPaths = new Set(managed.map((i) => i.materializedPath!));
|
||||
const removed: ComposeInputEntry[] = [];
|
||||
const fsSvc = FileSystemService.getInstance();
|
||||
// Context files are reconciled FILE-LEVEL: a file removed from the
|
||||
// repository inside a retained context must disappear from the
|
||||
// stack context too, or the deployed/build context would keep
|
||||
// deleted (possibly secret-bearing) content.
|
||||
// Context files are reconciled file-level for both retained and
|
||||
// removed contexts. After owned files are gone, an empty non-root
|
||||
// context directory is removed; unowned leftovers keep the directory.
|
||||
const newContextFiles = new Map<string, Set<string>>();
|
||||
for (const ctx of manifest.buildContexts) {
|
||||
newContextFiles.set(ctx.repoPath, new Set(ctx.files.map((f) => f.path)));
|
||||
@@ -901,26 +1037,25 @@ export class GitProjectManifestService {
|
||||
if (priorManifest) {
|
||||
for (const entry of priorManifest.inputs) {
|
||||
if (entry.ownership !== 'managed' || entry.state !== 'present' || entry.materializedPath === null) continue;
|
||||
if (entry.deletionAuthority !== 'sencho') continue; // never touch user/none authority
|
||||
if (entry.deletionAuthority !== 'sencho') continue;
|
||||
if (newPaths.has(entry.materializedPath)) continue;
|
||||
// Directories (build contexts) need a recursive unlink; a
|
||||
// non-recursive attempt would throw and fail the promotion
|
||||
// even though the directory is legitimately removable.
|
||||
const isDir = await fsSvc
|
||||
.pathKind(stackName, entry.materializedPath)
|
||||
.then((kind) => kind === 'directory')
|
||||
.catch(() => false);
|
||||
await fsSvc.deleteStackPath(stackName, entry.materializedPath, isDir, { protectedEnabled: false });
|
||||
if (entry.dependencyKind === 'build-context' || entry.dependencyKind === 'build-additional-context') {
|
||||
removed.push({ ...entry, state: 'tombstoned', contentSha256: null, sizeBytes: null });
|
||||
continue;
|
||||
}
|
||||
await fsSvc.deleteStackPath(stackName, entry.materializedPath, false, { protectedEnabled: false });
|
||||
removed.push({ ...entry, state: 'tombstoned', contentSha256: null, sizeBytes: null });
|
||||
}
|
||||
// Context-file reconciliation for contexts retained in both sets.
|
||||
for (const priorCtx of priorManifest.buildContexts) {
|
||||
const newFiles = newContextFiles.get(priorCtx.repoPath);
|
||||
if (!newFiles) continue; // context removed entirely; handled above
|
||||
const newFiles = newContextFiles.get(priorCtx.repoPath) ?? new Set<string>();
|
||||
for (const priorFile of priorCtx.files) {
|
||||
if (newFiles.has(priorFile.path)) continue;
|
||||
const rel = priorCtx.repoPath ? `${priorCtx.repoPath}/${priorFile.path}` : priorFile.path;
|
||||
await fsSvc.deleteStackPath(stackName, rel, false, { protectedEnabled: false });
|
||||
await this.tryRemoveEmptyParents(stackName, rel, fsSvc);
|
||||
}
|
||||
if (!newContextFiles.has(priorCtx.repoPath) && priorCtx.repoPath) {
|
||||
await this.tryRemoveEmptyDir(stackName, priorCtx.repoPath, fsSvc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -959,18 +1094,22 @@ export class GitProjectManifestService {
|
||||
console.warn('[GitManifest] committed promotion marker cleanup failed:', (e as Error).message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!liveMutationStarted) throw error;
|
||||
if (!liveMutationStarted) {
|
||||
throw new PromoteGenerationError('pre_mutation', error);
|
||||
}
|
||||
// Mid-write failure: restore the previous applied generation and
|
||||
// rethrow so the caller reports the failure honestly.
|
||||
// rethrow a typed outcome so the caller records restore vs recovery-required.
|
||||
let restored = false;
|
||||
try {
|
||||
await this.restorePreviousGeneration(stackName, {
|
||||
restored = await this.restorePreviousGeneration(stackName, {
|
||||
priorManifest: opts.priorManifest,
|
||||
incoming: { inputs: opts.manifest.inputs, buildContexts: opts.manifest.buildContexts },
|
||||
});
|
||||
} catch (restoreError) {
|
||||
console.error('[GitManifest] promotion failed and recovery restore also failed:', (restoreError as Error).message);
|
||||
restored = false;
|
||||
}
|
||||
throw error;
|
||||
throw new PromoteGenerationError(restored ? 'restored' : 'recovery_required', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,14 @@ export type NotificationCategory =
|
||||
| 'rollback_generation_released'
|
||||
// Automatic external-network creation during deploy. History-only.
|
||||
| 'network_auto_created'
|
||||
// Git source change-plan attempts. History-only (Activity timeline).
|
||||
| 'git_pull_ready'
|
||||
| 'git_plan_blocked'
|
||||
| 'git_pull_failed'
|
||||
| 'git_apply'
|
||||
| 'git_apply_failed'
|
||||
| 'git_apply_rolled_back'
|
||||
| 'git_create'
|
||||
| 'node_update_available'
|
||||
| 'system';
|
||||
|
||||
@@ -72,6 +80,8 @@ export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [
|
||||
'drift_detected', 'drift_resolved',
|
||||
'update_started', 'health_gate_passed', 'health_gate_failed',
|
||||
'network_auto_created', 'rollback_generation_released',
|
||||
'git_pull_ready', 'git_plan_blocked', 'git_pull_failed',
|
||||
'git_apply', 'git_apply_failed', 'git_apply_rolled_back', 'git_create',
|
||||
];
|
||||
|
||||
/** Webhook timeout: 10 seconds per external dispatch call. */
|
||||
|
||||
Reference in New Issue
Block a user