Skip to content
Open
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
113 changes: 113 additions & 0 deletions src/lifecycle/disposable/ResourceLifecycleRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { AsyncSeriesWaterfallHook } from 'tapable';

export interface Disposable {
dispose(): Promise<void>;
disposed: boolean;
}

export interface RegisteredResource {
id: string;
/** Higher priority = disposed first (browser contexts before browsers, etc.) */
priority: number;
resource: Disposable;
registeredAt: number;
}

export class ResourceLifecycleRegistry {
private readonly resources = new Map<string, RegisteredResource>();
private disposing = false;
private disposedAt?: number;

/** True once disposeAll() has been called (even if some resources failed to dispose) */
get disposed(): boolean {
return this.disposing || this.disposedAt !== undefined;
}

/**
* Register a resource for lifecycle-managed disposal.
* If a resource with the same id is already registered, the old one is disposed
* (fire-and-forget) and replaced.
*/
register(id: string, resource: Disposable, priority = 0): void {
if (this.disposed) {
throw new Error(`[lifecycle] Cannot register ${id}: registry is already disposed`);
}
const existing = this.resources.get(id);
if (existing) {
console.warn(`[lifecycle] Duplicate registration of "${id}", disposing old resource first`);
existing.resource.dispose().catch(() => {});
}
this.resources.set(id, { id, priority, resource, registeredAt: Date.now() });
console.debug(`[lifecycle] Registered "${id}" (priority=${priority}, total=${this.resources.size})`);
}

/** Unregister a resource without disposing it (e.g. ownership transferred elsewhere). */
unregister(id: string): void {
this.resources.delete(id);
console.debug(`[lifecycle] Unregistered "${id}" (remaining=${this.resources.size})`);
}

get(id: string): Disposable | undefined {
return this.resources.get(id)?.resource;
}

/** IDs of resources that are registered but not yet disposed — for smoke tests. */
getOpenHandles(): string[] {
return [...this.resources.values()]
.filter((r) => !r.resource.disposed)
.map((r) => r.id);
}

/**
* Dispose all registered resources in priority order.
* Each individual dispose() has a 3-second timeout.
*/
async disposeAll(timeoutMs = 10_000): Promise<'ok' | 'timeout'> {
if (this.disposed) return 'ok';
this.disposing = true;

const sorted = [...this.resources.values()].sort((a, b) => b.priority - a.priority);
const start = Date.now();

await Promise.allSettled(
sorted.map(async (r) => {
if (r.resource.disposed) return;
try {
console.debug(`[lifecycle] Disposing "${r.id}" …`);
await Promise.race([
r.resource.dispose(),
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 3_000)),
]);
} catch (err) {
console.error(`[lifecycle] Error disposing "${r.id}":`, err);
}
}),
);

this.disposedAt = Date.now();
const elapsed = Date.now() - start;
console.info(`[lifecycle] disposeAll completed in ${elapsed}ms (handles=${sorted.length})`);
return elapsed > timeoutMs ? 'timeout' : 'ok';
}

/**
* Dispose all resources and wait up to `timeoutMs` for completion.
* Logs a warning with the remaining open handles if the timeout is hit.
*/
async disposeAllAndWait(timeoutMs = 30_000): Promise<'ok' | 'timeout'> {
const result = await this.disposeAll(timeoutMs);
if (result === 'timeout') {
const handles = this.getOpenHandles();
console.error(`[lifecycle] disposeAllAndWait TIMEOUT — open handles: ${handles.join(', ')}`);
}
return result;
}

/** Number of currently registered resources. */
get size(): number {
return this.resources.size;
}
}

/** Singleton registry for agent-scoped resources. */
export const agentRegistry = new ResourceLifecycleRegistry();
2 changes: 2 additions & 0 deletions src/lifecycle/disposable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ResourceLifecycleRegistry, type Disposable, type RegisteredResource } from './ResourceLifecycleRegistry';
export { agentRegistry } from './ResourceLifecycleRegistry';
172 changes: 172 additions & 0 deletions src/lifecycle/retention/RetentionPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* RetentionPolicy — cleanup policy for runtime-generated directories.
*
* Applies TTL and count-based retention to:
* - .pilotdeck-always-on/ (TTL: 7 days, max 50 dirs per project)
* - .agi-output/ (TTL: 30 days)
* - .cci-output/ (TTL: 30 days)
* - TaskOutputStore entries (TTL: 30 days)
*/

export interface RetentionPolicyOptions {
/** Max age in ms before a directory is considered for deletion. Default: 7 days. */
alwaysOnTtlMs?: number;
/** Max age in ms before output dirs are cleaned. Default: 30 days. */
outputTtlMs?: number;
/** Max directories per project for always-on. Default: 50. */
alwaysOnMaxCount?: number;
}

interface RetentionEntry {
path: string;
mtimeMs: number;
sizeBytes: number;
}

const DEFAULT_ALWAYS_ON_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const DEFAULT_OUTPUT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const DEFAULT_ALWAYS_ON_MAX_COUNT = 50;

export class RetentionPolicy {
private readonly alwaysOnTtlMs: number;
private readonly outputTtlMs: number;
private readonly alwaysOnMaxCount: number;

constructor(options: RetentionPolicyOptions = {}) {
this.alwaysOnTtlMs = options.alwaysOnTtlMs ?? DEFAULT_ALWAYS_ON_TTL_MS;
this.outputTtlMs = options.outputTtlMs ?? DEFAULT_OUTPUT_TTL_MS;
this.alwaysOnMaxCount = options.alwaysOnMaxCount ?? DEFAULT_ALWAYS_ON_MAX_COUNT;
}

/**
* Scan `rootDir` for retention-target directories and return those that are candidates
* for cleanup (expired TTL or over count limit).
*/
async scanRetentionCandidates(rootDir: string): Promise<RetentionEntry[]> {
const { promises: fs } = await import('fs');
const { join } = await import('path');
const now = Date.now();

const candidates: RetentionEntry[] = [];

// .pilotdeck-always-on/
try {
const alwaysOnDir = join(rootDir, '.pilotdeck-always-on');
const stat = await fs.stat(alwaysOnDir);
if (stat.isDirectory()) {
const entries = await fs.readdir(alwaysOnDir);
for (const entry of entries) {
const entryPath = join(alwaysOnDir, entry);
try {
const st = await fs.stat(entryPath);
candidates.push({
path: entryPath,
mtimeMs: st.mtimeMs,
sizeBytes: 0, // skip size for dirs
});
} catch { /* skip */ }
}
}
} catch { /* dir doesn't exist */ }

// .agi-output/, .cci-output/
for (const subdir of ['.agi-output', '.cci-output']) {
try {
const dir = join(rootDir, subdir);
const stat = await fs.stat(dir);
if (stat.isDirectory()) {
const entries = await fs.readdir(dir);
for (const entry of entries) {
const entryPath = join(dir, entry);
try {
const st = await fs.stat(entryPath);
candidates.push({
path: entryPath,
mtimeMs: st.mtimeMs,
sizeBytes: 0,
});
} catch { /* skip */ }
}
}
} catch { /* dir doesn't exist */ }
}

return candidates;
}

/**
* Given candidates from scanRetentionCandidates(), return the subset that should
* actually be deleted (TTL expired OR always-on over count limit).
*/
computeDeletionSet(
candidates: RetentionEntry[],
options: { alwaysOnCount?: number } = {},
): RetentionEntry[] {
const now = Date.now();
const toDelete: RetentionEntry[] = [];

for (const c of candidates) {
const isAlwaysOn = c.path.includes('.pilotdeck-always-on');
const isOutput = c.path.includes('.agi-output') || c.path.includes('.cci-output');
const age = now - c.mtimeMs;

if (isAlwaysOn) {
const overCount = (options.alwaysOnCount ?? this.alwaysOnMaxCount) < candidates.filter(
(x) => x.path.includes('.pilotdeck-always-on'),
).length;
const expired = age > this.alwaysOnTtlMs;
if (expired || overCount) toDelete.push(c);
} else if (isOutput) {
if (age > this.outputTtlMs) toDelete.push(c);
}
}

return toDelete;
}

/**
* Delete a set of entries returned by computeDeletionSet().
* Removes directories recursively.
*/
async applyDeletions(entries: RetentionEntry[]): Promise<{ deleted: number; freedBytes: number }> {
const { promises: fs } = await import('fs');
const { join } = await import('path');

let deleted = 0;
let freedBytes = 0;

for (const entry of entries) {
try {
// Estimate freed bytes by reading dir size
let size = 0;
try {
const { stdout } = await import('child_process').exec(
`du -sb "${entry.path}" 2>/dev/null | cut -f1`,
{ timeout: 5000 },
);
size = parseInt(stdout.trim(), 10) || 0;
} catch { /* ignore */ }

await fs.rm(entry.path, { recursive: true, force: true });
deleted++;
freedBytes += size;
console.debug(`[retention] Deleted: ${entry.path} (~${size} bytes)`);
} catch (err) {
console.warn(`[retention] Failed to delete ${entry.path}:`, err);
}
}

return { deleted, freedBytes };
}

/** Convenience: run full scan → compute → delete cycle on `rootDir`. */
async runRetentionCleanup(rootDir: string): Promise<{ deleted: number; freedBytes: number }> {
const candidates = await this.scanRetentionCandidates(rootDir);
const toDelete = this.computeDeletionSet(candidates);
const result = await this.applyDeletions(toDelete);
console.info(
`[retention] Cleanup complete on ${rootDir}: deleted=${result.deleted}, freed_bytes=${result.freedBytes}`,
);
return result;
}
}
1 change: 1 addition & 0 deletions src/lifecycle/retention/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { RetentionPolicy } from './RetentionPolicy';
Loading