From f680984c694d41149183e237488e5d8f6c6e3c97 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 10:18:59 +0200 Subject: [PATCH 1/6] fix: isolate OpenFGA migration from Argo sync --- scripts/gitops-render.test.ts | 10 ++++-- .../src/gitops-render/runtime-manifests.mjs | 34 +++++-------------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index 90bf82fd..9f260be7 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -583,16 +583,20 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots const openfga = JSON.parse(await readFile(path.join(outDir, "runtime-v02", "openfga.yaml"), "utf8")); const openfgaDeployment = openfga.items.find((item) => item.kind === "Deployment" && item.metadata?.name === "hwlab-openfga"); const openfgaService = openfga.items.find((item) => item.kind === "Service" && item.metadata?.name === "hwlab-openfga"); - const openfgaMigrate = openfga.items.find((item) => item.kind === "Job" && item.metadata?.name === "hwlab-openfga-migrate"); assert.ok(openfgaDeployment, "expected v02 OpenFGA deployment"); assert.ok(openfgaService, "expected v02 OpenFGA ClusterIP service"); - assert.ok(openfgaMigrate, "expected v02 OpenFGA migration job"); + assert.equal(openfga.items.some((item) => item.kind === "Job" && item.metadata?.name === "hwlab-openfga-migrate"), false); assert.equal(openfgaService.spec.type, "ClusterIP"); assert.equal(openfga.items.some((item) => item.kind === "Ingress"), false); assert.equal(openfgaDeployment.spec.template.spec.containers[0].image, "127.0.0.1:5000/hwlab/openfga:v1.17.0"); assert.deepEqual(openfgaDeployment.spec.template.spec.containers[0].args, ["run"]); - assert.deepEqual(openfgaMigrate.spec.template.spec.containers[0].args, ["migrate"]); const openfgaEnv = openfgaDeployment.spec.template.spec.containers[0].env; + const openfgaMigrate = openfgaDeployment.spec.template.spec.initContainers[0]; + assert.equal(openfgaMigrate.name, "openfga-migrate"); + assert.equal(openfgaMigrate.image, "127.0.0.1:5000/hwlab/openfga:v1.17.0"); + assert.deepEqual(openfgaMigrate.args, ["migrate"]); + assert.deepEqual(openfgaMigrate.env, openfgaEnv.filter((entry) => entry.name === "OPENFGA_DATASTORE_ENGINE" || entry.name === "OPENFGA_DATASTORE_URI")); + assert.deepEqual(openfgaMigrate.resources, { requests: { cpu: "50m", memory: "64Mi" }, limits: { cpu: "500m", memory: "256Mi" } }); assert.ok(openfgaEnv.some((entry) => entry.name === "OPENFGA_DATASTORE_ENGINE" && entry.value === "postgres")); assert.ok(openfgaEnv.some((entry) => entry.name === "OPENFGA_DATASTORE_URI" && entry.valueFrom?.secretKeyRef?.name === "hwlab-v02-openfga" && entry.valueFrom?.secretKeyRef?.key === "datastore-uri")); assert.ok(openfgaEnv.some((entry) => entry.name === "OPENFGA_AUTHN_METHOD" && entry.value === "preshared")); diff --git a/scripts/src/gitops-render/runtime-manifests.mjs b/scripts/src/gitops-render/runtime-manifests.mjs index 5cd0ff37..6d0e37e8 100644 --- a/scripts/src/gitops-render/runtime-manifests.mjs +++ b/scripts/src/gitops-render/runtime-manifests.mjs @@ -908,37 +908,13 @@ function v02OpenFgaManifest({ profile = "v02", source, deploy = null }) { { name: "OPENFGA_HTTP_ADDR", value: "0.0.0.0:8080" }, { name: "OPENFGA_GRPC_ADDR", value: "0.0.0.0:8081" } ]; + const migrationEnv = env.filter((entry) => entry.name === "OPENFGA_DATASTORE_ENGINE" || entry.name === "OPENFGA_DATASTORE_URI"); const templateLabels = { ...labels, ...selector }; const runtimeAnnotations = { ...annotations, "argocd.argoproj.io/sync-wave": "2" }; return { apiVersion: "v1", kind: "List", items: [ - { - apiVersion: "batch/v1", - kind: "Job", - metadata: { - name: `${name}-migrate`, - namespace, - labels, - annotations: { - ...annotations, - "argocd.argoproj.io/hook": "Sync", - "argocd.argoproj.io/sync-wave": "1", - "argocd.argoproj.io/hook-delete-policy": "BeforeHookCreation,HookSucceeded" - } - }, - spec: { - backoffLimit: 3, - template: { - metadata: { labels: templateLabels, annotations }, - spec: { - restartPolicy: "OnFailure", - containers: [{ name: "openfga-migrate", image, imagePullPolicy: "IfNotPresent", args: ["migrate"], env }] - } - } - } - }, { apiVersion: "apps/v1", kind: "Deployment", @@ -949,6 +925,14 @@ function v02OpenFgaManifest({ profile = "v02", source, deploy = null }) { template: { metadata: { labels: templateLabels, annotations }, spec: { + initContainers: [{ + name: "openfga-migrate", + image, + imagePullPolicy: "IfNotPresent", + args: ["migrate"], + env: migrationEnv, + resources: { requests: { cpu: "50m", memory: "64Mi" }, limits: { cpu: "500m", memory: "256Mi" } } + }], containers: [{ name: "openfga", image, From b9415654cd322dd95fef42d2c7218040a0e52218 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 11:10:20 +0200 Subject: [PATCH 2/6] fix: report GitOps renderer rollout scope --- scripts/ci-plan.test.mjs | 38 +++++++++++++++++++++++++++++++++++-- scripts/src/ci-plan-lib.mjs | 19 ++++++++++++------- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 75c87607..9e4623ca 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -87,6 +87,35 @@ test("v03 planner keeps affected code rollouts out of catalog reuse", async () = assert.equal(plan.artifactCatalog.serviceCount, 3); }); +test("v03 planner reports renderer changes as full rollout without image builds", async () => { + const selectedServices = ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-gateway"]; + const repo = await createFixtureRepo({ serviceIds: selectedServices }); + await mkdir(path.join(repo, "scripts/src/gitops-render"), { recursive: true }); + await writeFile(path.join(repo, "scripts/src/gitops-render/runtime-manifests.mjs"), "export const renderer = 2;\n"); + await git(repo, ["add", "scripts/src/gitops-render/runtime-manifests.mjs"]); + await git(repo, ["commit", "-m", "change runtime manifest renderer"]); + + const plan = await createCiPlan({ + repoRoot: repo, + lane: "v03", + baseRef: "HEAD~1", + targetRef: "HEAD", + artifactCatalogPath: "deploy/artifact-catalog.v03.json", + services: selectedServices + }); + + assert.equal(plan.changedPathSummary.gitopsOnly, true); + assert.deepEqual(plan.affectedServices, selectedServices); + assert.deepEqual(plan.rolloutServices, selectedServices); + assert.deepEqual(plan.buildServices, []); + assert.deepEqual(plan.rolloutWithoutImageBuildServices, selectedServices); + assert.deepEqual(plan.reusedServices, []); + assert.equal(plan.imageBuildRequired, false); + assert.equal(plan.services.every((service) => service.buildRequired === false), true); + assert.equal(plan.services.every((service) => service.reason.includes("gitops-render-input-changed")), true); + assert.equal(plan.ciCdPlan.noImageBuildReason, "gitops-render-only-change"); +}); + test("tracked PaC remote pipelines pass selected services to catalog restore", async () => { const targets = [ { @@ -390,7 +419,7 @@ test("artifact catalog restore rejects a non-bootstrap source catalog when GitOp ); }); -test("node CI planner rolls service runtime config changes without rebuilding image", async () => { +test("node CI planner reports runtime config rendering as full rollout without image builds", async () => { const repo = await createFixtureRepo(); const deployPath = path.join(repo, "deploy/deploy.yaml"); const deploy = await readStructuredFile(repo, deployPath); @@ -400,13 +429,18 @@ test("node CI planner rolls service runtime config changes without rebuilding im await git(repo, ["commit", "-m", "change cloud api runtime env"]); const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" }); - assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]); + assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]); + assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-api", "hwlab-cloud-web"]); assert.deepEqual(plan.buildServices, []); + assert.deepEqual(plan.rolloutWithoutImageBuildServices, ["hwlab-cloud-api", "hwlab-cloud-web"]); assert.equal(plan.imageBuildRequired, false); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); + const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web"); assert.equal(cloudApi.runtimeConfigChanged, true); assert.equal(cloudApi.envChanged, false); assert.deepEqual(cloudApi.reason, ["runtime-config-changed"]); + assert.deepEqual(cloudWeb.reason, ["gitops-render-input-changed"]); + assert.equal(plan.ciCdPlan.noImageBuildReason, "gitops-render-only-change"); }); test("planner rebuilds when catalog digest is missing instead of blocking reuse", async () => { diff --git a/scripts/src/ci-plan-lib.mjs b/scripts/src/ci-plan-lib.mjs index 181e3b1f..37a1a644 100644 --- a/scripts/src/ci-plan-lib.mjs +++ b/scripts/src/ci-plan-lib.mjs @@ -59,6 +59,7 @@ export const DEFAULT_GITOPS_ONLY_PATHS = Object.freeze([ "deploy/gitops/", "deploy/frp/", "scripts/gitops-render.mjs", + "scripts/src/gitops-render/", "scripts/src/runtime-lane.ts", "tsconfig.gitops.json" ]); @@ -69,6 +70,7 @@ export const DEFAULT_GITOPS_RENDER_PATHS = Object.freeze([ "deploy/gitops/", "deploy/frp/", "scripts/gitops-render.mjs", + "scripts/src/gitops-render/", "scripts/src/runtime-lane.ts", "tsconfig.gitops.json" ]); @@ -111,6 +113,7 @@ export async function createCiPlan(options = {}) { const envReuseServices = enabledEnvReuseServices(deployJson, lane, serviceIdResolution.serviceIds); const envArtifactGroupByService = envArtifactGroupsByService(runtimeReuseConfig, serviceIdResolution.serviceIds); const globalChange = classifyGlobalChange(normalizedChangedPaths); + const gitopsRenderChanged = hasGitOpsRenderChange(normalizedChangedPaths); const hasGoService = componentModels.some((model) => model.runtimeKind === "go-service"); const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [ ...envReuseRecipe.additionalEnvPaths, @@ -261,10 +264,10 @@ export async function createCiPlan(options = {}) { const catalogReuse = envReuse ? envReuseCandidate({ catalogRecord, envChanged, environmentImage, environmentDigest, environmentInputHash }) : reuseCandidate(catalogRecordForReuse, imageRelevantChangedPaths.length > 0, componentInputChanged); - const plannedAffected = envReuse + const serviceInputAffected = envReuse ? Boolean(envChanged || codeChanged || runtimeConfigChanged) : imageRelevantChangedPaths.length > 0 || componentInputChanged || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest"; - const plannedBuildRequired = envReuse ? envChanged === true : plannedAffected; + const plannedBuildRequired = envReuse ? envChanged === true : serviceInputAffected; const runtimeReuseDecision = await runtimeReuseDecisionForService({ repoRoot, baseRef, @@ -273,11 +276,12 @@ export async function createCiPlan(options = {}) { config: runtimeReuseByService.get(model.serviceId) ?? null }); const reuseArtifactReady = !envReuse || environmentReady === true; - const affected = runtimeReuseDecision.runtimeReuseHit === true && reuseArtifactReady + const affectedByServiceInputs = runtimeReuseDecision.runtimeReuseHit === true && reuseArtifactReady ? false : runtimeReuseDecision.envReuseHit === true && reuseArtifactReady ? runtimeReuseDecision.sourceIdentityHit === false || runtimeConfigChanged === true - : plannedAffected; + : serviceInputAffected; + const affected = gitopsRenderChanged || affectedByServiceInputs; const buildRequired = runtimeReuseDecision.skipImageBuild === true && reuseArtifactReady ? false : plannedBuildRequired; services.push({ serviceId: model.serviceId, @@ -340,7 +344,9 @@ export async function createCiPlan(options = {}) { envChangedPaths: relevantEnvMatches, codeChangedPaths: relevantCodeMatches, reason: affected - ? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, runtimeConfigChanged, environmentInputChanged: effectiveEnvironmentInputChanged, codeChanged, reuseRegistry }) + ? gitopsRenderChanged && !affectedByServiceInputs + ? ["gitops-render-input-changed"] + : reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, runtimeConfigChanged, environmentInputChanged: effectiveEnvironmentInputChanged, codeChanged, reuseRegistry }) : reasonForUnchanged(globalChange), reuse: catalogReuse }); @@ -353,7 +359,6 @@ export async function createCiPlan(options = {}) { const rolloutWithoutImageBuildServices = services.filter((service) => service.affected && !service.buildRequired).map((service) => service.serviceId); const reusedServices = services.filter((service) => !service.affected && !service.buildRequired).map((service) => service.serviceId); const imageBuildSkippedServices = services.filter((service) => !service.buildRequired).map((service) => service.serviceId); - const gitopsRenderChanged = hasGitOpsRenderChange(normalizedChangedPaths); return { planVersion: CI_PLAN_VERSION, sourceCommitId, @@ -407,7 +412,7 @@ export async function createCiPlan(options = {}) { envArtifactGroups: envArtifactGroupPlans, willRunGitopsPromote: globalChange.gitopsOnly || affectedServices.length > 0 || gitopsRenderChanged, noImageBuildReason: buildServices.length === 0 - ? (affectedServices.length > 0 ? "env-reuse-code-only-rollout" : gitopsRenderChanged ? "gitops-render-only-change" : globalChange.summary) + ? (gitopsRenderChanged ? "gitops-render-only-change" : affectedServices.length > 0 ? "env-reuse-code-only-rollout" : globalChange.summary) : null } }; From 90350d63e7cfa0c9f7f09699c206e50c3090b313 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 12:07:13 +0200 Subject: [PATCH 3/6] fix(tasktree): serialize schema migrations --- internal/tasktree/dispatcher.ts | 9 +- internal/tasktree/store.ts | 126 +++++++++++---- package.json | 1 + scripts/tasktree-schema-concurrency-smoke.mjs | 151 ++++++++++++++++++ 4 files changed, 255 insertions(+), 32 deletions(-) create mode 100644 scripts/tasktree-schema-concurrency-smoke.mjs diff --git a/internal/tasktree/dispatcher.ts b/internal/tasktree/dispatcher.ts index 579a49fe..9da5220f 100644 --- a/internal/tasktree/dispatcher.ts +++ b/internal/tasktree/dispatcher.ts @@ -111,7 +111,14 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) { else throw codedError("unsupported_operation", `unsupported operation: ${String(operation)}`); return { ok: true, operation, data }; } catch (error: any) { - return { ok: false, operation: command.operation, error: { code: error?.code ?? "tasktree_error", message: error?.message ?? String(error) } }; + const migrationDetail = error?.tasktreeMigrationId + ? { migrationId: String(error.tasktreeMigrationId), sqlState: error?.code ? String(error.code) : undefined } + : undefined; + return { + ok: false, + operation: command.operation, + error: { code: error?.code ?? "tasktree_error", message: error?.message ?? String(error), ...(migrationDetail ? { detail: migrationDetail } : {}) }, + }; } }; } diff --git a/internal/tasktree/store.ts b/internal/tasktree/store.ts index b77770dc..88ae8380 100644 --- a/internal/tasktree/store.ts +++ b/internal/tasktree/store.ts @@ -11,58 +11,100 @@ import type { MdtodoImportPlan } from "./mdtodo-import.ts"; const { Pool } = pg; -const schema = [ - `CREATE TABLE IF NOT EXISTS tasktree_schema_migrations (migration_id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`, - `CREATE TABLE IF NOT EXISTS tasktree_groups ( - id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`, - `CREATE TABLE IF NOT EXISTS tasktree_tasks ( - id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE, - parent_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('task','subtask','subsubtask')), - title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending','in_progress','completed','blocked')), - start_at TIMESTAMPTZ, due_at TIMESTAMPTZ, sort_order INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CHECK (due_at IS NULL OR start_at IS NULL OR due_at >= start_at))`, - `CREATE TABLE IF NOT EXISTS tasktree_milestones ( - id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE, - task_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, title TEXT NOT NULL, - occurs_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`, - `CREATE TABLE IF NOT EXISTS tasktree_execution_reports ( - id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasktree_tasks(id) ON DELETE CASCADE, - title TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'succeeded', - created_at TIMESTAMPTZ NOT NULL DEFAULT now())`, - `CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_group_order ON tasktree_tasks(group_id, sort_order, created_at)`, - `CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_parent ON tasktree_tasks(parent_id)`, - `CREATE INDEX IF NOT EXISTS idx_tasktree_reports_task ON tasktree_execution_reports(task_id, created_at DESC)` - ,`CREATE TABLE IF NOT EXISTS tasktree_backup_state ( +type SchemaMigration = { id: string; statements: string[] }; + +const migrationLedgerStatement = `CREATE TABLE IF NOT EXISTS tasktree_schema_migrations ( + migration_id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`; +const schemaMigrations: SchemaMigration[] = [ + { + id: "tasktree-20260716-v1", + statements: [ + `CREATE TABLE IF NOT EXISTS tasktree_groups ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`, + `CREATE TABLE IF NOT EXISTS tasktree_tasks ( + id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE, + parent_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('task','subtask')), + title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','in_progress','completed','blocked')), + start_at TIMESTAMPTZ, due_at TIMESTAMPTZ, sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (due_at IS NULL OR start_at IS NULL OR due_at >= start_at))`, + `CREATE TABLE IF NOT EXISTS tasktree_milestones ( + id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE, + task_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, title TEXT NOT NULL, + occurs_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`, + `CREATE TABLE IF NOT EXISTS tasktree_execution_reports ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasktree_tasks(id) ON DELETE CASCADE, + title TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'succeeded', + created_at TIMESTAMPTZ NOT NULL DEFAULT now())`, + `CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_group_order ON tasktree_tasks(group_id, sort_order, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_parent ON tasktree_tasks(parent_id)`, + `CREATE INDEX IF NOT EXISTS idx_tasktree_reports_task ON tasktree_execution_reports(task_id, created_at DESC)`, + ], + }, + { id: "tasktree-20260717-v2-subsubtask", statements: [ + `ALTER TABLE tasktree_tasks DROP CONSTRAINT IF EXISTS tasktree_tasks_kind_check`, + `ALTER TABLE tasktree_tasks ADD CONSTRAINT tasktree_tasks_kind_check CHECK (kind IN ('task','subtask','subsubtask'))`, + ] }, + { id: "tasktree-20260718-v3-github-backup", statements: [ + `CREATE TABLE IF NOT EXISTS tasktree_backup_state ( target TEXT PRIMARY KEY, pending BOOLEAN NOT NULL DEFAULT false, requested_at TIMESTAMPTZ, request_reason TEXT NOT NULL DEFAULT '', last_attempt_at TIMESTAMPTZ, last_success_at TIMESTAMPTZ, content_sha256 TEXT NOT NULL DEFAULT '', git_commit TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'never' CHECK (status IN ('never','pending','succeeded','failed')), error_code TEXT NOT NULL DEFAULT '', error_message TEXT NOT NULL DEFAULT '', - updated_at TIMESTAMPTZ NOT NULL DEFAULT now())` - ,`ALTER TABLE tasktree_tasks DROP CONSTRAINT IF EXISTS tasktree_tasks_kind_check` - ,`ALTER TABLE tasktree_tasks ADD CONSTRAINT tasktree_tasks_kind_check CHECK (kind IN ('task','subtask','subsubtask'))` + updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`, + ] }, ]; +const schemaMigrationIds = schemaMigrations.map((migration) => migration.id); +const schemaAdvisoryLockKeys = [0x48574c42, 0x54545245] as const; export class TaskTreeStore { private readonly pool: pg.Pool; private ready = false; + private schemaPromise: Promise | null = null; constructor(databaseUrl: string) { if (!databaseUrl) throw new Error("TASKTREE_DATABASE_URL or DATABASE_URL is required"); - this.pool = new Pool({ connectionString: databaseUrl, max: 4, connectionTimeoutMillis: 5000, ssl: { rejectUnauthorized: false } }); + this.pool = new Pool({ connectionString: databaseUrl, max: 4, connectionTimeoutMillis: 5000, ssl: taskTreePostgresSsl(databaseUrl) }); } async ensureSchema() { if (this.ready) return; + this.schemaPromise ??= this.migrateSchema(); + try { + await this.schemaPromise; + } catch (error) { + this.schemaPromise = null; + throw error; + } + } + + private async migrateSchema() { + if (await schemaIsCurrent(this.pool)) { + this.ready = true; + return; + } const client = await this.pool.connect(); try { await client.query("BEGIN"); - for (const statement of schema) await client.query(statement); - await client.query("INSERT INTO tasktree_schema_migrations (migration_id) VALUES ($1),($2),($3) ON CONFLICT DO NOTHING", ["tasktree-20260716-v1", "tasktree-20260717-v2-subsubtask", "tasktree-20260718-v3-github-backup"]); + await client.query("SELECT pg_advisory_xact_lock($1, $2)", [...schemaAdvisoryLockKeys]); + const ledger = await client.query("SELECT to_regclass('tasktree_schema_migrations') AS relation"); + if (ledger.rows[0]?.relation == null) await client.query(migrationLedgerStatement); + const appliedResult = await client.query("SELECT migration_id FROM tasktree_schema_migrations"); + const applied = new Set(appliedResult.rows.map((row) => String(row.migration_id))); + for (const migration of schemaMigrations) { + if (applied.has(migration.id)) continue; + try { + for (const statement of migration.statements) await client.query(statement); + await client.query("INSERT INTO tasktree_schema_migrations (migration_id) VALUES ($1)", [migration.id]); + } catch (error) { + if (error && typeof error === "object") Object.assign(error, { tasktreeMigrationId: migration.id }); + throw error; + } + } await client.query("COMMIT"); this.ready = true; } catch (error) { @@ -581,6 +623,21 @@ export function projectTimelineTasks(tasks: TaskItem[], reports: ExecutionReport }); } +async function schemaIsCurrent(pool: pg.Pool): Promise { + const relation = await pool.query("SELECT to_regclass('tasktree_schema_migrations') AS relation"); + if (relation.rows[0]?.relation == null) return false; + try { + const applied = await pool.query( + "SELECT COUNT(*)::int AS count FROM tasktree_schema_migrations WHERE migration_id = ANY($1::text[])", + [schemaMigrationIds], + ); + return Number(applied.rows[0]?.count ?? 0) === schemaMigrationIds.length; + } catch (error: any) { + if (error?.code === "42P01") return false; + throw error; + } +} + function groupRow(row: any): TaskGroup { return { id: row.id, name: row.name, description: row.description, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; } function overviewRow(row: any): TaskGroupOverview { return { group: groupRow(row), taskCount: Number(row.task_count), subtaskCount: Number(row.subtask_count), subsubtaskCount: Number(row.subsubtask_count), reportCount: Number(row.report_count), startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at) }; } function taskRow(row: any): TaskItem { return { id: row.id, groupId: row.group_id, parentId: row.parent_id, kind: row.kind, title: row.title, description: row.description, status: row.status, startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at), sortOrder: row.sort_order, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; } @@ -605,6 +662,13 @@ function backupStateRow(row: any): TaskTreeBackupState { function iso(value: unknown): string { return new Date(value as any).toISOString(); } function nullableIso(value: unknown): string | null { return value == null ? null : iso(value); } function domainError(code: string, message: string) { return Object.assign(new Error(message), { code }); } +function taskTreePostgresSsl(databaseUrl: string) { + try { + return new URL(databaseUrl).searchParams.get("sslmode") === "disable" ? false : { rejectUnauthorized: false }; + } catch { + return { rejectUnauthorized: false }; + } +} function ensureTimeRange(startAt: string | null, dueAt: string | null) { if (startAt !== null && dueAt !== null && new Date(startAt).valueOf() > new Date(dueAt).valueOf()) { throw domainError("invalid_time_range", "startAt must be before or equal to dueAt"); diff --git a/package.json b/package.json index f1696a95..00f261b8 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "harnessrl:native:l1-smoke": "node scripts/harnessrl-native-l1-smoke.mjs", "tasktree:backup:l1-smoke": "bun scripts/tasktree-backup-native-l1-smoke.mjs", "tasktree:backup:restore:l1-smoke": "bun scripts/tasktree-backup-restore-l1-smoke.mjs", + "tasktree:schema:concurrency-smoke": "bun scripts/tasktree-schema-concurrency-smoke.mjs", "workbench:api:dev": "bun --watch cmd/hwlab-workbench-api/main.ts", "workbench:worker:dev": "bun --watch cmd/hwlab-workbench-worker/main.ts", "workbench:web:dev": "cd web/hwlab-cloud-web && WORKBENCH_VITE_USE_POLLING=1 bun run dev", diff --git a/scripts/tasktree-schema-concurrency-smoke.mjs b/scripts/tasktree-schema-concurrency-smoke.mjs new file mode 100644 index 00000000..aaab4d34 --- /dev/null +++ b/scripts/tasktree-schema-concurrency-smoke.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +import pg from "pg"; + +const { Pool } = pg; +const clients = positiveInteger(process.env.TASKTREE_MIGRATION_SMOKE_CLIENTS, 8); +const rounds = positiveInteger(process.env.TASKTREE_MIGRATION_SMOKE_ROUNDS, 3); +const adminUrl = process.env.TASKTREE_MIGRATION_SMOKE_DATABASE_URL + || "postgresql:///postgres?host=/var/run/postgresql&user=root"; +const databaseName = `tasktree_smoke_${randomUUID().replaceAll("-", "")}`; +const cliPath = fileURLToPath(new URL("../tools/hwlab-cli/bin/hwlab-cli.ts", import.meta.url)); +const scopedUrl = new URL(adminUrl); +scopedUrl.pathname = `/${databaseName}`; +scopedUrl.searchParams.set("sslmode", "disable"); +scopedUrl.searchParams.set("options", "-clock_timeout=1500ms"); +const databaseUrl = scopedUrl.toString(); +const admin = new Pool({ connectionString: adminUrl, max: 2 }); +const scoped = new Pool({ connectionString: databaseUrl, max: 4 }); + +try { + await admin.query(`CREATE DATABASE ${databaseName}`); + + const fresh = await Promise.all(Array.from({ length: clients }, () => runCli(["group", "list"]))); + fresh.forEach((result) => assertEnvelope(result, "group.list")); + await assertMigrations(); + + const createdGroup = await runCli(["group", "create", "--name", `Concurrency smoke ${Date.now()}`]); + assertEnvelope(createdGroup, "group.create"); + const groupId = createdGroup.data.id; + const createdTask = await runCli(["task", "create", "--group", groupId, "--title", "Read envelope seed"]); + assertEnvelope(createdTask, "task.create"); + const taskId = createdTask.data.id; + const report = await runCli(["report", "write", "--task", taskId, "--title", "Smoke", "--body", "passed"]); + assertEnvelope(report, "report.write"); + assert.equal(report.data.mutation, true); + + await scoped.query( + "DELETE FROM tasktree_schema_migrations WHERE migration_id = ANY($1::text[])", + [["tasktree-20260717-v2-subsubtask", "tasktree-20260718-v3-github-backup"]], + ); + await scoped.query("ALTER TABLE tasktree_tasks DROP CONSTRAINT tasktree_tasks_kind_check"); + await scoped.query( + "INSERT INTO tasktree_tasks (id,group_id,kind,title) VALUES ($1,$2,$3,$4)", + ["tt_invalid_migration_smoke", groupId, "invalid", "Migration rollback sentinel"], + ); + const failedMigration = await runCli(["group", "list"], true); + assert.equal(failedMigration.ok, false); + assert.equal(failedMigration.error.code, "23514"); + assert.deepEqual(failedMigration.error.detail, { + migrationId: "tasktree-20260717-v2-subsubtask", + sqlState: "23514", + }); + const afterFailure = await scoped.query("SELECT migration_id FROM tasktree_schema_migrations ORDER BY migration_id"); + assert.deepEqual(afterFailure.rows.map((row) => row.migration_id), ["tasktree-20260716-v1"]); + + await scoped.query("DELETE FROM tasktree_tasks WHERE id=$1", ["tt_invalid_migration_smoke"]); + assertEnvelope(await runCli(["group", "list"]), "group.list"); + await assertMigrations(); + + const blocker = await scoped.connect(); + try { + await blocker.query("BEGIN"); + await blocker.query("SELECT id FROM tasktree_tasks LIMIT 1"); + const commands = [ + ["group", "list"], + ["task", "list", "--group", groupId], + ["report", "list", "--task", taskId], + ]; + for (let round = 0; round < rounds; round += 1) { + const results = await Promise.all(Array.from({ length: clients }, (_, index) => runCli(commands[index % commands.length]))); + results.forEach((result, index) => assertEnvelope(result, ["group.list", "task.list", "report.list"][index % commands.length])); + } + await blocker.query("COMMIT"); + } catch (error) { + await blocker.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + blocker.release(); + } + + console.log(JSON.stringify({ + ok: true, + operation: "tasktree.schema-concurrency-smoke", + data: { + clients, + rounds, + freshCliEnvelopes: fresh.length, + existingCliEnvelopes: clients * rounds, + migrationRollbackVerified: true, + existingReadsWithTableLockVerified: true, + migrationIds: await migrationIds(), + }, + }, null, 2)); +} finally { + await scoped.end().catch(() => {}); + await admin.query(`DROP DATABASE IF EXISTS ${databaseName} WITH (FORCE)`).catch(() => {}); + await admin.end().catch(() => {}); +} + +async function runCli(args, allowFailure = false) { + const child = spawn(process.execPath, [cliPath, "tasktree", ...args], { + cwd: fileURLToPath(new URL("..", import.meta.url)), + env: { ...process.env, TASKTREE_DATABASE_URL: databaseUrl, DATABASE_URL: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; }); + child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; }); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + assert.equal(stderr.trim(), "", `CLI stderr must stay empty: ${stderr}`); + assert.ok(stdout.trim(), "CLI must return one JSON envelope on stdout"); + const result = JSON.parse(stdout); + if (!allowFailure) assert.equal(exitCode, 0, JSON.stringify(result)); + return result; +} + +function assertEnvelope(result, operation) { + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.operation, operation); + assert.ok(Object.hasOwn(result, "data"), "successful CLI envelope must contain data"); +} + +async function assertMigrations() { + assert.deepEqual(await migrationIds(), [ + "tasktree-20260716-v1", + "tasktree-20260717-v2-subsubtask", + "tasktree-20260718-v3-github-backup", + ]); + const constraint = await scoped.query( + "SELECT COUNT(*)::int AS count FROM pg_constraint WHERE conrelid='tasktree_tasks'::regclass AND conname='tasktree_tasks_kind_check'", + ); + assert.equal(constraint.rows[0].count, 1); +} + +async function migrationIds() { + const result = await scoped.query("SELECT migration_id FROM tasktree_schema_migrations ORDER BY migration_id"); + return result.rows.map((row) => row.migration_id); +} + +function positiveInteger(value, fallback) { + const parsed = Number(value ?? fallback); + assert.ok(Number.isInteger(parsed) && parsed > 0, "smoke concurrency parameters must be positive integers"); + return parsed; +} From 34d7b8445e345e75d38d9b289bd6235787596cf0 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 12:10:29 +0200 Subject: [PATCH 4/6] fix(cicd): keep TaskTree smoke out of runtime scope --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 00f261b8..f1696a95 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "harnessrl:native:l1-smoke": "node scripts/harnessrl-native-l1-smoke.mjs", "tasktree:backup:l1-smoke": "bun scripts/tasktree-backup-native-l1-smoke.mjs", "tasktree:backup:restore:l1-smoke": "bun scripts/tasktree-backup-restore-l1-smoke.mjs", - "tasktree:schema:concurrency-smoke": "bun scripts/tasktree-schema-concurrency-smoke.mjs", "workbench:api:dev": "bun --watch cmd/hwlab-workbench-api/main.ts", "workbench:worker:dev": "bun --watch cmd/hwlab-workbench-worker/main.ts", "workbench:web:dev": "cd web/hwlab-cloud-web && WORKBENCH_VITE_USE_POLLING=1 bun run dev", From 7d2c66bebd4f6ec5b85159007254918f134465e0 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 12:41:02 +0200 Subject: [PATCH 5/6] fix(cicd): preserve semantic package scope --- package.json | 1 + scripts/ci-plan.test.mjs | 10 ++++++++-- scripts/src/ci-plan-lib.mjs | 11 ++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f1696a95..00f261b8 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "harnessrl:native:l1-smoke": "node scripts/harnessrl-native-l1-smoke.mjs", "tasktree:backup:l1-smoke": "bun scripts/tasktree-backup-native-l1-smoke.mjs", "tasktree:backup:restore:l1-smoke": "bun scripts/tasktree-backup-restore-l1-smoke.mjs", + "tasktree:schema:concurrency-smoke": "bun scripts/tasktree-schema-concurrency-smoke.mjs", "workbench:api:dev": "bun --watch cmd/hwlab-workbench-api/main.ts", "workbench:worker:dev": "bun --watch cmd/hwlab-workbench-worker/main.ts", "workbench:web:dev": "cd web/hwlab-cloud-web && WORKBENCH_VITE_USE_POLLING=1 bun run dev", diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 9e4623ca..6b1f6956 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -1159,8 +1159,13 @@ test("v02 planner treats shared trace renderer changes as cloud-web code rollout assert.deepEqual(cloudWeb.reason, ["code-input-changed", "component-path-changed"]); }); -test("planner ignores package script cleanup for runtime images", async () => { +test("planner ignores package scripts-only changes even when package is a component path", async () => { const repo = await createFixtureRepo(); + const deployPath = path.join(repo, "deploy/deploy.yaml"); + const deploy = await readStructuredFile(repo, deployPath); + deploy.lanes.v02.serviceDeclarations["hwlab-cloud-api"].componentPaths.push("package.json"); + deploy.lanes.v02.serviceDeclarations["hwlab-cloud-web"].componentPaths.push("package.json"); + await writeStructuredFile(repo, deployPath, deploy); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", scripts: { @@ -1170,7 +1175,7 @@ test("planner ignores package script cleanup for runtime images", async () => { "@openai/codex": "^0.128.0" } }, null, 2)); - await git(repo, ["add", "package.json"]); + await git(repo, ["add", "deploy/deploy.yaml", "package.json"]); await git(repo, ["commit", "-m", "seed package scripts"]); await refreshFixtureCatalogForHead(repo); @@ -1191,6 +1196,7 @@ test("planner ignores package script cleanup for runtime images", async () => { const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, []); assert.deepEqual(plan.buildServices, []); + assert.equal(plan.services.every((service) => !service.changedPaths.includes("package.json")), true); assert.equal(plan.imageBuildRequired, false); assert.equal(plan.buildSkippedCount, 2); }); diff --git a/scripts/src/ci-plan-lib.mjs b/scripts/src/ci-plan-lib.mjs index 37a1a644..3215a98f 100644 --- a/scripts/src/ci-plan-lib.mjs +++ b/scripts/src/ci-plan-lib.mjs @@ -124,7 +124,8 @@ export async function createCiPlan(options = {}) { const services = []; for (const model of componentModels) { - const directMatches = matchingPaths(normalizedChangedPaths, model.componentPaths); + const directMatches = matchingPaths(normalizedChangedPaths, model.componentPaths) + .filter((item) => !model.runtimeDeps.includes(item) || semanticRuntimeDepPaths.has(item)); const sharedMatches = matchingPaths(normalizedChangedPaths, model.sharedPaths); const rawRuntimeDepMatches = matchingPaths(normalizedChangedPaths, model.runtimeDeps); const runtimeDepMatches = rawRuntimeDepMatches.filter((item) => semanticRuntimeDepPaths.has(item)); @@ -147,7 +148,10 @@ export async function createCiPlan(options = {}) { ? matchingPaths(normalizedChangedPaths, envInputPaths) .filter((item) => !model.runtimeDeps.includes(item) || semanticRuntimeDepPaths.has(item)) : []; - const codeMatches = envReuse ? matchingPaths(normalizedChangedPaths, codeInputPaths) : []; + const codeMatches = envReuse + ? matchingPaths(normalizedChangedPaths, codeInputPaths) + .filter((item) => !model.runtimeDeps.includes(item) || semanticRuntimeDepPaths.has(item)) + : []; const relevantEnvMatches = envMatches.filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item)); const relevantCodeMatches = codeMatches.filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item)); const imageRelevantChangedPaths = uniqueSorted([ @@ -162,7 +166,8 @@ export async function createCiPlan(options = {}) { semanticPackageJson: true }) : null; const codeInputHash = envReuse ? await hashGitPaths(repoRoot, targetRef, codeInputPaths, { - skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath) + skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath), + semanticPackageJson: true }) : null; const currentEnvironmentImage = envReuse ? envReuseImageRef(registryPrefix, model.serviceId, environmentInputHash, envArtifactGroup) : null; const catalogEnvironmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null; From 61ffb311c8c433f4d787c2cce74f8b139beaff28 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 12:50:35 +0200 Subject: [PATCH 6/6] fix(cicd): absorb semantic code hash drift --- scripts/ci-plan.test.mjs | 7 +++++++ scripts/src/ci-plan-lib.mjs | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 6b1f6956..17eb7090 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -1178,6 +1178,12 @@ test("planner ignores package scripts-only changes even when package is a compon await git(repo, ["add", "deploy/deploy.yaml", "package.json"]); await git(repo, ["commit", "-m", "seed package scripts"]); await refreshFixtureCatalogForHead(repo); + const catalogPath = path.join(repo, "deploy/artifact-catalog.v02.json"); + const catalog = JSON.parse(await readFile(catalogPath, "utf8")); + for (const service of catalog.services) service.codeInputHash = "legacy-package-script-hash"; + await writeFile(catalogPath, JSON.stringify(catalog, null, 2)); + await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); + await git(repo, ["commit", "-m", "seed legacy code input hashes"]); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", @@ -1197,6 +1203,7 @@ test("planner ignores package scripts-only changes even when package is a compon assert.deepEqual(plan.affectedServices, []); assert.deepEqual(plan.buildServices, []); assert.equal(plan.services.every((service) => !service.changedPaths.includes("package.json")), true); + assert.equal(plan.services.every((service) => service.codeMetadataHashDrift === true), true); assert.equal(plan.imageBuildRequired, false); assert.equal(plan.buildSkippedCount, 2); }); diff --git a/scripts/src/ci-plan-lib.mjs b/scripts/src/ci-plan-lib.mjs index 3215a98f..1adc21a8 100644 --- a/scripts/src/ci-plan-lib.mjs +++ b/scripts/src/ci-plan-lib.mjs @@ -161,6 +161,7 @@ export async function createCiPlan(options = {}) { ...buildSystemMatches ].filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))); const catalogRecord = catalogByServiceId.get(model.serviceId) ?? null; + const catalogEnvironmentSourceCommitId = envReuse ? text(catalogRecord?.sourceCommitId) : ""; const environmentInputHash = envReuse ? await hashEnvReuseInputs(repoRoot, targetRef, envInputPaths, serviceEnvReuseRecipe, { skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath), semanticPackageJson: true @@ -169,10 +170,15 @@ export async function createCiPlan(options = {}) { skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath), semanticPackageJson: true }) : null; + const catalogCodeInputHashAtSource = envReuse && catalogEnvironmentSourceCommitId + ? await hashGitPathsIfCommitExists(repoRoot, catalogEnvironmentSourceCommitId, codeInputPaths, { + skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath), + semanticPackageJson: true + }) + : null; const currentEnvironmentImage = envReuse ? envReuseImageRef(registryPrefix, model.serviceId, environmentInputHash, envArtifactGroup) : null; const catalogEnvironmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null; const catalogEnvironmentDigest = envReuse ? environmentDigestFromCatalog(catalogRecord) : null; - const catalogEnvironmentSourceCommitId = envReuse ? text(catalogRecord?.sourceCommitId) : ""; const catalogEnvironmentInputHashAtSource = envReuse && catalogEnvironmentSourceCommitId ? await hashEnvReuseInputsIfCommitExists(repoRoot, catalogEnvironmentSourceCommitId, envInputPaths, serviceEnvReuseRecipe, { skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath), @@ -224,7 +230,12 @@ export async function createCiPlan(options = {}) { const effectiveEnvironmentInputChanged = environmentInputChanged === true && !environmentMetadataHashDrift; const environmentReady = envReuse && Boolean(environmentImage) && (reuseRegistryProbe ? reuseRegistry?.status === "present" && digestReady : !effectiveEnvironmentInputChanged && digestReady); const envChanged = envReuse ? Boolean(relevantEnvMatches.length > 0 || !environmentReady || reuseRegistryUnavailable) : null; - const codeChanged = envReuse ? relevantCodeMatches.length > 0 || catalogRecord?.codeInputHash !== codeInputHash : null; + const codeInputChanged = envReuse ? catalogRecord?.codeInputHash !== codeInputHash : null; + const codeMetadataHashDrift = envReuse + && codeInputChanged === true + && Boolean(catalogCodeInputHashAtSource) + && catalogCodeInputHashAtSource === codeInputHash; + const codeChanged = envReuse ? relevantCodeMatches.length > 0 || (codeInputChanged === true && !codeMetadataHashDrift) : null; const componentInputPaths = uniqueSorted([ ...model.componentPaths, ...model.sharedPaths, @@ -338,6 +349,9 @@ export async function createCiPlan(options = {}) { catalogEnvironmentInputHashAtSource, environmentSourceUnchanged, codeChanged, + codeInputChanged, + codeMetadataHashDrift, + catalogCodeInputHashAtSource, reuseRegistry, environmentInputHash, codeInputHash,