Download src/claws/lifecycle-delete-support.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 19.2 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/claws/lifecycle-delete-support.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/claws/lifecycle-delete-support.ts
-
curl -L -o lifecycle-delete-support.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/claws/lifecycle-delete-support.ts
19.2 kB
| import { createHash, randomUUID } from "node:crypto"; | |
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; | |
| import { err, ok, type Result } from "@openclaw/normalization-core/result"; | |
| import { | |
| isPathOwnedBySurvivingAgent, | |
| readAgentDeleteDatabaseRegistry, | |
| resolveSurvivingDatabaseFilePaths, | |
| } from "../agents/agent-delete-databases.js"; | |
| import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; | |
| import { listAgentEntries, resolveAgentDir } from "../agents/agent-scope.js"; | |
| import { MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES } from "../agents/workspace-bootstrap-read.js"; | |
| import { | |
| prepareLegacyWorkspaceStateReset, | |
| removeLegacyWorkspaceStateForReset, | |
| } from "../agents/workspace-legacy-state.js"; | |
| import { | |
| deleteWorkspaceState, | |
| prepareWorkspaceStateDeletion, | |
| readWorkspaceStateSnapshot, | |
| } from "../agents/workspace-state-store.js"; | |
| import { | |
| DEFAULT_BOOTSTRAP_FILENAME, | |
| resolveWorkspaceBootstrapStatus, | |
| } from "../agents/workspace.js"; | |
| import { pruneAgentConfig } from "../commands/agents.config.js"; | |
| import { moveToTrash } from "../commands/cleanup-utils.js"; | |
| import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; | |
| import type { OpenClawConfig } from "../config/types.openclaw.js"; | |
| import { resolveCronJobConfigRevision } from "../cron/config-revision.js"; | |
| import { loadedCronStoreFromRows } from "../cron/store/row-codec.js"; | |
| import type { CronJobRow } from "../cron/store/schema.js"; | |
| import { isSystemMonitorDeclaration } from "../cron/system-owned-declaration.js"; | |
| import { root as fsSafeRoot, FsSafeError } from "../infra/fs-safe.js"; | |
| import { | |
| compileSqliteQueryBindings, | |
| executeSqliteQuerySync, | |
| getNodeSqliteKysely, | |
| } from "../infra/kysely-sync.js"; | |
| import type { RuntimeEnv } from "../runtime.js"; | |
| import { unregisterOpenClawAgentDatabases } from "../state/openclaw-agent-db-registry.js"; | |
| import type { OpenClawStateDatabase } from "../state/openclaw-state-db-contract.js"; | |
| import { tableExists } from "../state/openclaw-state-db-schema-helpers.js"; | |
| import type { DB } from "../state/openclaw-state-db.generated.js"; | |
| import { | |
| openOpenClawStateDatabase, | |
| runOpenClawStateWriteTransaction, | |
| type OpenClawStateDatabaseOptions, | |
| } from "../state/openclaw-state-db.js"; | |
| import type { ClawMonitorCleanupGateway, ClawMonitorSnapshot } from "./monitor-cleanup-contract.js"; | |
| import { deleteCachedClawInstallSchemaVersion } from "./provenance-runtime-read.js"; | |
| import type { PersistedClawInstall } from "./provenance.js"; | |
| import type { PersistedClawWorkspaceFile } from "./workspace.js"; | |
| type ClawRemovalDatabase = Pick< | |
| DB, | |
| "claw_workspace_files" | "claw_package_refs" | "claw_installs" | "cron_jobs" | |
| >; | |
| export class ClawRemoveError extends Error { | |
| constructor( | |
| readonly code: string, | |
| message: string, | |
| ) { | |
| super(message); | |
| this.name = "ClawRemoveError"; | |
| } | |
| } | |
| export function synthesizeOrphanInstall(params: { | |
| agentId: string; | |
| clawName?: string; | |
| workspace?: string; | |
| updatedAtMs?: number; | |
| }): PersistedClawInstall { | |
| const updatedAtMs = params.updatedAtMs ?? 0; | |
| return { | |
| schemaVersion: "openclaw.clawInstallRecord.v1" as PersistedClawInstall["schemaVersion"], | |
| claw: { | |
| kind: "development", | |
| name: params.clawName ?? `orphan:${params.agentId}`, | |
| version: "0.0.0", | |
| packageRoot: "", | |
| manifestPath: "", | |
| integrityKind: "development-snapshot", | |
| integrity: "sha256:orphan", | |
| byteLength: 0, | |
| }, | |
| manifestSchemaVersion: 1, | |
| planIntegrity: "sha256:orphan", | |
| agentId: params.agentId, | |
| workspace: params.workspace ?? "", | |
| agentConfigDigest: "sha256:missing", | |
| agentOwnedPaths: [], | |
| status: "partial", | |
| addedAtMs: updatedAtMs, | |
| updatedAtMs, | |
| }; | |
| } | |
| export function deletionEffects( | |
| config: OpenClawConfig, | |
| agentId: string, | |
| fallbackWorkspace = "", | |
| env?: NodeJS.ProcessEnv, | |
| ) { | |
| const agent = listAgentEntries(config).find((candidate) => candidate.id === agentId); | |
| const pruned = pruneAgentConfig(config, agentId); | |
| const workspace = agent?.workspace ?? fallbackWorkspace; | |
| const agentDir = resolveAgentDir(config, agentId, env); | |
| const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env); | |
| const workspaceSharedWith = workspace | |
| ? findOverlappingWorkspaceAgentIds(config, agentId, workspace, env) | |
| : []; | |
| return { | |
| pruned, | |
| workspace, | |
| agentDir, | |
| sessionsDir, | |
| workspaceSharedWith, | |
| }; | |
| } | |
| export type AttachedCronJob = { | |
| id: string; | |
| name: string; | |
| enabled: boolean; | |
| agentId: string | null; | |
| ownerAgentId: string | null; | |
| storeKey: string; | |
| declarationKey: string | null; | |
| revision?: string; | |
| }; | |
| /** Inventories cron jobs that would retain a reference to a removed agent. */ | |
| export function readAttachedCronJobs( | |
| agentId: string, | |
| options: OpenClawStateDatabaseOptions, | |
| ): AttachedCronJob[] { | |
| const { db } = openOpenClawStateDatabase(options); | |
| if (!tableExists(db, "cron_jobs")) { | |
| return []; | |
| } | |
| const { compiled, bind } = compileSqliteQueryBindings<string>((parameter) => { | |
| const boundAgentId = parameter((value) => value); | |
| return getNodeSqliteKysely<ClawRemovalDatabase>(db) | |
| .selectFrom("cron_jobs") | |
| .selectAll() | |
| .where((eb) => | |
| eb.or([eb("agent_id", "=", boundAgentId), eb("owner_agent_id", "=", boundAgentId)]), | |
| ) | |
| .orderBy("job_id") | |
| .orderBy("store_key"); | |
| }); | |
| const rows = | |
| db /* sqlite-allow-raw: preserve native inventory errors outside the write-transaction owner. */ | |
| .prepare(compiled.sql) | |
| .all(...bind(agentId)) as CronJobRow[]; | |
| return rows.map((row) => { | |
| const job = loadedCronStoreFromRows([row]).store.jobs[0]; | |
| return { | |
| id: row.job_id, | |
| name: row.name, | |
| enabled: row.enabled === 1, | |
| agentId: row.agent_id, | |
| ownerAgentId: row.owner_agent_id, | |
| storeKey: row.store_key, | |
| declarationKey: row.declaration_key, | |
| revision: job ? resolveCronJobConfigRevision(job) : undefined, | |
| }; | |
| }); | |
| } | |
| /** Offline preview keeps local blockers; only a serving owner can make a monitor removable. */ | |
| export async function readClawRemoveCronInventory( | |
| agentId: string, | |
| options: OpenClawStateDatabaseOptions & { monitorGateway?: ClawMonitorCleanupGateway }, | |
| ) { | |
| let attachedJobs = readAttachedCronJobs(agentId, options); | |
| let monitors: ClawMonitorSnapshot[] = []; | |
| let inspectionUnavailable = false; | |
| if (attachedJobs.some((job) => isSystemMonitorDeclaration(job.declarationKey ?? undefined))) { | |
| inspectionUnavailable = true; | |
| if (options.monitorGateway) { | |
| try { | |
| monitors = await options.monitorGateway.inspect(agentId); | |
| inspectionUnavailable = false; | |
| } catch { | |
| // An unavailable/uncorroborated owner grants no removal scope. | |
| } | |
| attachedJobs = readAttachedCronJobs(agentId, options); | |
| } | |
| } | |
| return { attachedJobs, monitors, inspectionUnavailable }; | |
| } | |
| export type ClawCleanupTargets = { | |
| workspaceDir: string; | |
| agentDir: string; | |
| sessionsDir: string; | |
| }; | |
| export type ClawTrashPath = typeof moveToTrash; | |
| /** Returns true when removing a workspace would discard anything outside Claw provenance. */ | |
| export async function workspaceContainsUntrackedEntries( | |
| workspaceRoot: string, | |
| trackedPaths: string[], | |
| ): Promise<boolean> { | |
| const tracked = new Set(trackedPaths.map((entry) => path.normalize(entry))); | |
| const trackedDirectories = new Set<string>(); | |
| for (const trackedPath of tracked) { | |
| let parent = path.dirname(trackedPath); | |
| while (parent && parent !== ".") { | |
| trackedDirectories.add(parent); | |
| const next = path.dirname(parent); | |
| if (next === parent) { | |
| break; | |
| } | |
| parent = next; | |
| } | |
| } | |
| const walk = async (absoluteDir: string, relativeDir = ""): Promise<boolean> => { | |
| const entries = await fs.readdir(absoluteDir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const relativeEntry = path.join(relativeDir, entry.name); | |
| if (entry.isDirectory() && !entry.isSymbolicLink()) { | |
| if (!trackedDirectories.has(path.normalize(relativeEntry))) { | |
| return true; | |
| } | |
| if (await walk(path.join(absoluteDir, entry.name), relativeEntry)) { | |
| return true; | |
| } | |
| continue; | |
| } | |
| if (!tracked.has(path.normalize(relativeEntry))) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| }; | |
| try { | |
| return await walk(workspaceRoot); | |
| } catch (error) { | |
| return (error as NodeJS.ErrnoException).code !== "ENOENT"; | |
| } | |
| } | |
| /** Applies canonical post-config filesystem cleanup and reports every failed effect. */ | |
| export async function cleanupClawAgentFilesystem(params: { | |
| agentId: string; | |
| nextConfig: OpenClawConfig; | |
| targets: ClawCleanupTargets; | |
| runtime: RuntimeEnv; | |
| trashPath?: ClawTrashPath; | |
| retainWorkspace?: boolean; | |
| stateDatabase?: OpenClawStateDatabaseOptions; | |
| assertCurrent: () => void; | |
| }): Promise<string[]> { | |
| const errors: string[] = []; | |
| const trashPath: ClawTrashPath = (pathname, runtime) => { | |
| params.assertCurrent(); | |
| return params.trashPath | |
| ? params.trashPath(pathname, runtime) | |
| : moveToTrash(pathname, runtime, params.assertCurrent); | |
| }; | |
| const survivingDatabaseFilePaths = resolveSurvivingDatabaseFilePaths( | |
| readAgentDeleteDatabaseRegistry(params.stateDatabase), | |
| params.agentId, | |
| params.stateDatabase?.env, | |
| ); | |
| const sharedWithSurvivor = (pathname: string) => | |
| isPathOwnedBySurvivingAgent( | |
| params.nextConfig, | |
| params.agentId, | |
| pathname, | |
| survivingDatabaseFilePaths, | |
| params.stateDatabase?.env, | |
| ); | |
| if ( | |
| params.targets.workspaceDir && | |
| !params.retainWorkspace && | |
| !sharedWithSurvivor(params.targets.workspaceDir) | |
| ) { | |
| const legacyPlan = prepareLegacyWorkspaceStateReset(params.targets.workspaceDir); | |
| const statePlan = prepareWorkspaceStateDeletion(params.targets.workspaceDir); | |
| const workspaceRemoved = await trashPath(params.targets.workspaceDir, params.runtime); | |
| if (workspaceRemoved) { | |
| try { | |
| const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan, { | |
| assertCurrent: params.assertCurrent, | |
| }); | |
| for (const warning of legacyCleanup.warnings) { | |
| params.runtime.log(warning); | |
| } | |
| params.assertCurrent(); | |
| await deleteWorkspaceState(statePlan, { assertCurrent: params.assertCurrent }); | |
| } catch (error) { | |
| errors.push(coerceErrorMessage(error)); | |
| } | |
| } else { | |
| errors.push(`Could not trash workspace ${params.targets.workspaceDir}.`); | |
| } | |
| } | |
| if ( | |
| !sharedWithSurvivor(params.targets.agentDir) && | |
| !(await trashPath(params.targets.agentDir, params.runtime)) | |
| ) { | |
| errors.push(`Could not trash agent state ${params.targets.agentDir}.`); | |
| } | |
| if ( | |
| !sharedWithSurvivor(params.targets.sessionsDir) && | |
| !(await trashPath(params.targets.sessionsDir, params.runtime)) | |
| ) { | |
| errors.push(`Could not trash session transcripts ${params.targets.sessionsDir}.`); | |
| } | |
| return errors; | |
| } | |
| export const clawRemoveQuietRuntime: RuntimeEnv = { | |
| log: (..._args: unknown[]) => undefined, | |
| error: (..._args: unknown[]) => undefined, | |
| exit: (code?: number): never => { | |
| throw new Error(`Unexpected exit during Claw removal cleanup: ${code ?? 1}`); | |
| }, | |
| }; | |
| type DigestOwnedWorkspaceFile = Pick< | |
| PersistedClawWorkspaceFile, | |
| "workspace" | "path" | "contentDigest" | |
| >; | |
| type DigestOwnedWorkspaceFileStatus = { | |
| state: "unchanged" | "modified" | "missing" | "unsafe"; | |
| message?: string; | |
| }; | |
| type ClawRemovableWorkspaceFile = DigestOwnedWorkspaceFile & DigestOwnedWorkspaceFileStatus; | |
| export type RemovedWorkspaceFile = { | |
| path: string; | |
| action: "deleted" | "missing" | "retainedModified" | "error"; | |
| message?: string; | |
| }; | |
| export type ClawManagedFileStatus = PersistedClawWorkspaceFile & { | |
| state: "unchanged" | "modified" | "missing" | "unsafe"; | |
| message?: string; | |
| }; | |
| export type ClawBootstrapStatus = { | |
| state: "pending" | "complete" | "modified" | "missing" | "unsafe" | "unknown"; | |
| workspace: string; | |
| path: string; | |
| sourcePath?: string; | |
| contentDigest?: string; | |
| message?: string; | |
| }; | |
| async function inspectDigestOwnedWorkspaceFile( | |
| record: DigestOwnedWorkspaceFile, | |
| maxBytes = 1024 * 1024, | |
| ): Promise<DigestOwnedWorkspaceFileStatus> { | |
| try { | |
| const workspace = await fsSafeRoot(record.workspace, { | |
| hardlinks: "reject", | |
| maxBytes, | |
| symlinks: "reject", | |
| }); | |
| if (!(await workspace.exists(record.path))) { | |
| return { state: "missing" }; | |
| } | |
| const content = await workspace.readBytes(record.path, { maxBytes }); | |
| const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`; | |
| return { | |
| state: digest === record.contentDigest ? "unchanged" : "modified", | |
| }; | |
| } catch (error) { | |
| if ((error as NodeJS.ErrnoException).code === "ENOENT") { | |
| return { state: "missing" }; | |
| } | |
| return { | |
| state: "unsafe", | |
| message: coerceErrorMessage(error), | |
| }; | |
| } | |
| } | |
| export async function inspectClawWorkspaceFile( | |
| record: PersistedClawWorkspaceFile, | |
| ): Promise<ClawManagedFileStatus> { | |
| return { ...record, ...(await inspectDigestOwnedWorkspaceFile(record)) }; | |
| } | |
| export async function inspectClawBootstrap( | |
| install: PersistedClawInstall, | |
| options: OpenClawStateDatabaseOptions, | |
| ): Promise<ClawBootstrapStatus> { | |
| const nativeState = await resolveWorkspaceBootstrapStatus(install.workspace, options); | |
| const setupState = (await readWorkspaceStateSnapshot(install.workspace, options)).setup; | |
| const base = { | |
| workspace: install.workspace, | |
| path: DEFAULT_BOOTSTRAP_FILENAME, | |
| ...install.bootstrap, | |
| }; | |
| const nativeBootstrapConsumed = | |
| typeof setupState.setupCompletedAt === "string" || | |
| typeof setupState.bootstrapSeededAt === "string"; | |
| if (nativeState === "complete" && (!install.bootstrap || nativeBootstrapConsumed)) { | |
| return { ...base, state: "complete" }; | |
| } | |
| if (!install.bootstrap) { | |
| return { ...base, state: nativeState }; | |
| } | |
| const bootstrapSeedingPending = | |
| install.status === "pending" || | |
| install.status === "partial" || | |
| install.status === "workspace_ready"; | |
| if (bootstrapSeedingPending) { | |
| try { | |
| await fs.lstat(install.workspace); | |
| } catch (error) { | |
| if ((error as NodeJS.ErrnoException).code === "ENOENT") { | |
| return { ...base, state: "missing" }; | |
| } | |
| } | |
| } | |
| const inspected = await inspectDigestOwnedWorkspaceFile( | |
| { | |
| workspace: install.workspace, | |
| path: DEFAULT_BOOTSTRAP_FILENAME, | |
| contentDigest: install.bootstrap.contentDigest, | |
| }, | |
| MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES, | |
| ); | |
| if (inspected.state === "unchanged") { | |
| return { ...base, state: "pending" }; | |
| } | |
| if (inspected.state === "modified" || inspected.state === "unsafe") { | |
| return { | |
| ...base, | |
| state: inspected.state, | |
| ...(inspected.message ? { message: inspected.message } : {}), | |
| }; | |
| } | |
| if (bootstrapSeedingPending) { | |
| return { ...base, state: "missing" }; | |
| } | |
| return { ...base, state: "unknown", message: "BOOTSTRAP.md disappeared during inspection." }; | |
| } | |
| export async function removeClawWorkspaceFile( | |
| record: ClawRemovableWorkspaceFile, | |
| assertCurrent: () => void, | |
| maxBytes = 1024 * 1024, | |
| ): Promise<RemovedWorkspaceFile> { | |
| if (record.state === "missing") { | |
| return { path: record.path, action: "missing" }; | |
| } | |
| if (record.state === "modified") { | |
| return { path: record.path, action: "retainedModified" }; | |
| } | |
| try { | |
| const workspace = await fsSafeRoot(record.workspace, { | |
| hardlinks: "reject", | |
| maxBytes, | |
| symlinks: "reject", | |
| }); | |
| if (!(await workspace.exists(record.path))) { | |
| return { path: record.path, action: "missing" }; | |
| } | |
| const stagedPath = `${record.path}.openclaw-claw-remove-${randomUUID()}`; | |
| assertCurrent(); | |
| await workspace.move(record.path, stagedPath, { overwrite: false }); | |
| let outcome: Result<void, unknown>; | |
| try { | |
| const content = await workspace.readBytes(stagedPath, { maxBytes }); | |
| assertCurrent(); | |
| const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`; | |
| if (digest === record.contentDigest) { | |
| await workspace.remove(stagedPath); | |
| return { path: record.path, action: "deleted" }; | |
| } | |
| outcome = ok(undefined); | |
| } catch (error) { | |
| outcome = err(error); | |
| } | |
| // Undo this attempt's staging even after ownership loss; never replace new content. | |
| try { | |
| await workspace.move(stagedPath, record.path, { overwrite: false }); | |
| } catch (error) { | |
| throw new AggregateError( | |
| [...(outcome.ok ? [] : [outcome.error]), error], | |
| `Could not restore ${record.path} from ${stagedPath}: ${String(error)}`, | |
| { cause: error }, | |
| ); | |
| } | |
| if (!outcome.ok) { | |
| throw outcome.error; | |
| } | |
| return { path: record.path, action: "retainedModified" }; | |
| } catch (error) { | |
| return { | |
| path: record.path, | |
| action: "error", | |
| message: error instanceof FsSafeError ? `${error.code}: ${error.message}` : String(error), | |
| }; | |
| } | |
| } | |
| export function releaseClawRemoveRows( | |
| agentId: string, | |
| files: RemovedWorkspaceFile[], | |
| cleanupErrors: string[], | |
| assertCurrent: (database: OpenClawStateDatabase) => void, | |
| completeDeletion: (database: OpenClawStateDatabase) => void, | |
| options: OpenClawStateDatabaseOptions, | |
| ): boolean { | |
| const complete = cleanupErrors.length === 0; | |
| try { | |
| runOpenClawStateWriteTransaction((database) => { | |
| assertCurrent(database); | |
| if (complete) { | |
| // Discovery and owned rows must retire under the same current-operation transaction. | |
| unregisterOpenClawAgentDatabases({ agentId, env: options.env, database }); | |
| } | |
| const { db } = database; | |
| const query = getNodeSqliteKysely<ClawRemovalDatabase>(db); | |
| if (tableExists(db, "claw_workspace_files")) { | |
| for (const file of files.filter((candidate) => candidate.action !== "error")) { | |
| executeSqliteQuerySync( | |
| db, | |
| query | |
| .deleteFrom("claw_workspace_files") | |
| .where("agent_id", "=", agentId) | |
| .where("target_path", "=", file.path), | |
| ); | |
| } | |
| } | |
| // Partial removals keep both the journal fence and install retry owner intact. | |
| if (!complete) { | |
| return; | |
| } | |
| if (tableExists(db, "claw_package_refs")) { | |
| executeSqliteQuerySync( | |
| db, | |
| query.deleteFrom("claw_package_refs").where("agent_id", "=", agentId), | |
| ); | |
| } | |
| if (tableExists(db, "claw_installs")) { | |
| executeSqliteQuerySync( | |
| db, | |
| query.deleteFrom("claw_installs").where("agent_id", "=", agentId), | |
| ); | |
| } | |
| // Complete removals release the fence and retry owner in the same transaction. | |
| completeDeletion(database); | |
| }, options); | |
| if (complete) { | |
| deleteCachedClawInstallSchemaVersion(agentId, options); | |
| } | |
| } catch (error) { | |
| if (complete) { | |
| throw error; | |
| } | |
| cleanupErrors.push(coerceErrorMessage(error)); | |
| } | |
| return complete; | |
| } | |