Merge remote-tracking branch 'origin/v0.3' into v0.3

This commit is contained in:
root
2026-07-21 13:11:38 +02:00
8 changed files with 358 additions and 76 deletions
+8 -1
View File
@@ -116,7 +116,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 } : {}) },
};
}
};
}
+95 -31
View File
@@ -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<void> | 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<boolean> {
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");
+1
View File
@@ -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",
+51 -4
View File
@@ -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 () => {
@@ -1125,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: {
@@ -1136,9 +1175,15 @@ 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);
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",
@@ -1157,6 +1202,8 @@ 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.services.every((service) => service.codeMetadataHashDrift === true), true);
assert.equal(plan.imageBuildRequired, false);
assert.equal(plan.buildSkippedCount, 2);
});
+7 -3
View File
@@ -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"));
+36 -12
View File
@@ -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,
@@ -121,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));
@@ -144,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([
@@ -154,17 +161,24 @@ 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
}) : 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 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),
@@ -216,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,
@@ -261,10 +280,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 +292,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,
@@ -329,6 +349,9 @@ export async function createCiPlan(options = {}) {
catalogEnvironmentInputHashAtSource,
environmentSourceUnchanged,
codeChanged,
codeInputChanged,
codeMetadataHashDrift,
catalogCodeInputHashAtSource,
reuseRegistry,
environmentInputHash,
codeInputHash,
@@ -340,7 +363,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 +378,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 +431,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
}
};
@@ -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,
@@ -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;
}