Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 30 additions & 30 deletions src/cli/commands/data_gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,20 @@

import { Command } from "@cliffy/command";
import {
renderDataGC,
renderDataGCCancelled,
renderDataGCPreview,
} from "../../presentation/output/data_gc_output.ts";
consumeStream,
createDataGcDeps,
createLibSwampContext,
dataGc,
dataGcPreview,
} from "../../libswamp/mod.ts";
import {
createDataGcRenderer,
renderDataGcCancelled,
renderDataGcPreview,
} from "../../presentation/renderers/data_gc.ts";
import { createContext, type GlobalOptions } from "../context.ts";
import { requireInitializedRepo } from "../repo_context.ts";
import { DefaultDataLifecycleService } from "../../domain/data/data_lifecycle_service.ts";

/**
* Prompts user for confirmation in interactive mode.
* Uses basic stdin reading for confirmation prompt.
*/
async function promptConfirmation(message: string): Promise<boolean> {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
Expand All @@ -39,9 +41,7 @@ async function promptConfirmation(message: string): Promise<boolean> {

const buf = new Uint8Array(1024);
const n = await Deno.stdin.read(buf);
if (n === null) {
return false;
}
if (n === null) return false;

const response = decoder.decode(buf.subarray(0, n)).trim().toLowerCase();
return response === "y" || response === "yes";
Expand All @@ -57,38 +57,38 @@ export const dataGcCommand = new Command()
.option("--dry-run", "Show what would be deleted without deleting")
.option("-f, --force", "Skip confirmation prompt")
.action(async function (options: AnyOptions) {
const ctx = createContext(options as GlobalOptions, ["data", "gc"]);
const { repoContext } = await requireInitializedRepo({
const cliCtx = createContext(options as GlobalOptions, ["data", "gc"]);

const { repoDir } = await requireInitializedRepo({
repoDir: options.repoDir ?? ".",
outputMode: ctx.outputMode,
outputMode: cliCtx.outputMode,
});

const service = new DefaultDataLifecycleService(
repoContext.unifiedDataRepo,
repoContext.workflowRunRepo,
);
const ctx = createLibSwampContext({ logger: cliCtx.logger });
const deps = createDataGcDeps(repoDir);

// If interactive and no force, prompt for confirmation
if (ctx.outputMode === "log" && !options.force && !options.dryRun) {
const preview = await service.findExpiredData();
if (preview.length === 0) {
// Phase 1: Preview + Prompt (only in interactive mode without --force and not dry-run)
if (cliCtx.outputMode === "log" && !options.force && !options.dryRun) {
const preview = await dataGcPreview(ctx, deps);
if (preview.items.length === 0) {
console.log("No expired data found. Nothing to clean up.");
return;
}

renderDataGCPreview(preview, ctx.outputMode);
renderDataGcPreview(preview, cliCtx.outputMode);
const confirmed = await promptConfirmation(
"Proceed with garbage collection?",
);
if (!confirmed) {
renderDataGCCancelled(ctx.outputMode);
renderDataGcCancelled(cliCtx.outputMode);
return;
}
}

// Execute GC
const result = await service.deleteExpiredData({
dryRun: options.dryRun,
});
renderDataGC(result, ctx.outputMode);
// Phase 2: Execute GC
const renderer = createDataGcRenderer(cliCtx.outputMode);
await consumeStream(
dataGc(ctx, deps, { dryRun: !!options.dryRun }),
renderer.handlers(),
);
});
195 changes: 54 additions & 141 deletions src/cli/commands/model_delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,23 @@

import { Command } from "@cliffy/command";
import {
type ModelDeleteData,
renderModelDelete,
consumeStream,
createLibSwampContext,
createModelDeleteDeps,
modelDelete,
modelDeletePreview,
} from "../../libswamp/mod.ts";
import {
createModelDeleteRenderer,
renderModelDeleteCancelled,
} from "../../presentation/output/model_delete_output.ts";
} from "../../presentation/renderers/model_delete.ts";
import { createContext, type GlobalOptions } from "../context.ts";
import { requireInitializedRepo } from "../repo_context.ts";
import { findDefinitionByIdOrName } from "../../domain/models/model_lookup.ts";
import { UserError } from "../../domain/errors.ts";
import type { Workflow } from "../../domain/workflows/workflow.ts";

// deno-lint-ignore no-explicit-any
type AnyOptions = any;

/**
* Prompts user for confirmation in interactive mode.
* Uses basic stdin reading for confirmation prompt.
*/
async function promptConfirmation(message: string): Promise<boolean> {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
Expand All @@ -44,49 +44,12 @@ async function promptConfirmation(message: string): Promise<boolean> {

const buf = new Uint8Array(1024);
const n = await Deno.stdin.read(buf);
if (n === null) {
return false;
}
if (n === null) return false;

const response = decoder.decode(buf.subarray(0, n)).trim().toLowerCase();
return response === "y" || response === "yes";
}

/**
* Finds all workflows that reference a model by ID or name.
*/
function findWorkflowsReferencingModel(
workflows: Workflow[],
modelId: string,
modelName: string,
): Workflow[] {
const referencingWorkflows: Workflow[] = [];

for (const workflow of workflows) {
let found = false;
for (const job of workflow.jobs) {
for (const step of job.steps) {
if (step.task.isModelMethod()) {
const taskData = step.task.data;
if (taskData.type === "model_method") {
const ref = taskData.modelIdOrName;
if (ref === modelId || ref === modelName) {
found = true;
break;
}
}
}
}
if (found) break;
}
if (found) {
referencingWorkflows.push(workflow);
}
}

return referencingWorkflows;
}

export const modelDeleteCommand = new Command()
.name("delete")
.description("Delete a model and all related artifacts")
Expand All @@ -98,128 +61,78 @@ export const modelDeleteCommand = new Command()
)
// @ts-expect-error - Cliffy custom type returns unknown instead of string
.action(async function (options: AnyOptions, modelIdOrName: string) {
const ctx = createContext(options as GlobalOptions, ["model", "delete"]);
ctx.logger.debug`Deleting model: ${modelIdOrName}`;
const cliCtx = createContext(options as GlobalOptions, ["model", "delete"]);
cliCtx.logger.debug`Deleting model: ${modelIdOrName}`;

const { repoContext } = await requireInitializedRepo({
const { repoDir } = await requireInitializedRepo({
repoDir: options.repoDir ?? ".",
outputMode: ctx.outputMode,
outputMode: cliCtx.outputMode,
});
const definitionRepo = repoContext.definitionRepo;
const unifiedDataRepo = repoContext.unifiedDataRepo;
const outputRepo = repoContext.outputRepo;
const workflowRepo = repoContext.workflowRepo;

// Look up the model definition
ctx.logger.debug`Looking up model: ${modelIdOrName}`;
const result = await findDefinitionByIdOrName(
definitionRepo,
modelIdOrName,
);
if (!result) {
throw new UserError(`Model not found: ${modelIdOrName}`);
}
const { definition, type: modelType } = result;

ctx.logger
.debug`Found model: id=${definition.id}, type=${modelType.normalized}`;

// Check if model is referenced in any workflow
const allWorkflows = await workflowRepo.findAll();
const referencingWorkflows = findWorkflowsReferencingModel(
allWorkflows,
definition.id,
definition.name,
);
const ctx = createLibSwampContext({ logger: cliCtx.logger });
const deps = createModelDeleteDeps(repoDir);
const force = !!options.force;

// Phase 1: Preview — gather what will be affected
let preview;
try {
preview = await modelDeletePreview(ctx, deps, {
modelIdOrName,
force,
});
} catch (error) {
if ("code" in (error as Record<string, unknown>)) {
throw new UserError((error as { message: string }).message);
}
throw error;
}

if (referencingWorkflows.length > 0) {
const workflowNames = referencingWorkflows.map((w) => w.name).join(", ");
// Block if referenced by workflows
if (preview.referencingWorkflows.length > 0) {
throw new UserError(
`Model '${definition.name}' is referenced by workflow(s): ${workflowNames}. ` +
`Model '${preview.name}' is referenced by workflow(s): ${
preview.referencingWorkflows.join(", ")
}. ` +
`Remove the model from these workflows before deleting.`,
);
}

// Find data artifacts for this model
const dataArtifacts = await unifiedDataRepo.findAllForModel(
modelType,
definition.id,
);
ctx.logger
.debug`Found ${dataArtifacts.length} data artifacts for this model`;

// If data artifacts exist and no --force flag, block deletion
if (dataArtifacts.length > 0 && !options.force) {
// Block if data artifacts exist and no --force
if (preview.dataArtifactCount > 0 && !force) {
throw new UserError(
`Model '${definition.name}' has ${dataArtifacts.length} associated data artifact(s). ` +
`Model '${preview.name}' has ${preview.dataArtifactCount} associated data artifact(s). ` +
`Delete the data first, or use --force to delete all.`,
);
}

// Get paths before deletion
const definitionPath = definitionRepo.getPath(modelType, definition.id);

// Find outputs related to this model
const outputs = await outputRepo.findByDefinition(
modelType,
definition.id,
);
ctx.logger.debug`Found ${outputs.length} outputs to delete`;

// In interactive mode without --force, prompt for confirmation
if (ctx.outputMode === "log" && !options.force) {
// Phase 2: Prompt (CLI concern)
if (cliCtx.outputMode === "log" && !force) {
let deleteDetails = "";
if (outputs.length > 0) {
deleteDetails += ` ${outputs.length} output(s),`;
if (preview.outputCount > 0) {
deleteDetails += ` ${preview.outputCount} output(s),`;
}
if (dataArtifacts.length > 0) {
deleteDetails += ` ${dataArtifacts.length} data artifact(s),`;
if (preview.dataArtifactCount > 0) {
deleteDetails += ` ${preview.dataArtifactCount} data artifact(s),`;
}
if (deleteDetails) {
deleteDetails = ` This will also delete:${deleteDetails.slice(0, -1)}.`;
}

const confirmed = await promptConfirmation(
`Delete model '${definition.name}' (${definition.id})?${deleteDetails}`,
`Delete model '${preview.name}' (${preview.id})?${deleteDetails}`,
);
if (!confirmed) {
renderModelDeleteCancelled(ctx.outputMode);
renderModelDeleteCancelled(cliCtx.outputMode);
return;
}
}

// Delete outputs first
let outputsDeleted = 0;
for (const output of outputs) {
ctx.logger.debug`Deleting output: ${output.id}`;
await outputRepo.delete(modelType, output.methodName, output.id);
outputsDeleted++;
}

// Delete data artifacts
let dataDeleted = false;
for (const data of dataArtifacts) {
ctx.logger.debug`Deleting data artifact: ${data.name}`;
await unifiedDataRepo.delete(modelType, definition.id, data.name);
dataDeleted = true;
}
// Phase 3: Execute mutation
const renderer = createModelDeleteRenderer(cliCtx.outputMode);
await consumeStream(
modelDelete(ctx, deps, { modelIdOrName, force }),
renderer.handlers(),
);

// Delete definition (this emits DefinitionDeleted event which cleans up logical views)
ctx.logger.debug`Deleting definition: ${definition.id}`;
await definitionRepo.delete(modelType, definition.id);

const data: ModelDeleteData = {
id: definition.id,
name: definition.name,
type: modelType.normalized,
inputPath: definitionPath,
resourcePath: undefined,
resourceDeleted: false,
outputsDeleted,
evaluatedInputDeleted: false,
dataDeleted,
};

renderModelDelete(data, ctx.outputMode);
ctx.logger.debug("Model delete command completed");
cliCtx.logger.debug("Model delete command completed");
});
Loading
Loading