mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
A long orchestration creates one case directory per worker and deleting the sessions never removed them, so ~/codeman-cases accumulated scratch folders that were indistinguishable from real projects. They are now labelled and have a cleanup path. - src/agent-case-marker.ts: a case dir quick-start CREATES for an agent-driven spawn gets a .codeman-agent-case.json marker (when, by whom, parent session, mode). Only the create branch writes it, so a linked case, a cloned repo or any pre-existing path is never labelled; reading is total, so a malformed marker means "not agent-created" rather than a half-trusted entry. - The signal is the new X-Codeman-Agent-Origin header the skill preamble sets on its shared curl (preamble bumped to 1.22.0), or an agentOrigin body field, falling back to a resolved parentSessionId so a worker spawned by a stale skill copy is still labelled. - GET /api/cases publishes it as agentCreated; GET /api/cases/agent-created is a read-only cleanup listing adding inUse and modifiedAt; Add Case -> Manage badges each case and offers a review-then-delete sweep that names every directory in its confirm and skips any case a live session is working in. Removal stays on the existing DELETE /api/cases/:name. - Agent preamble caches are collected too: ~/.cache/codeman-agent-<id>.sh was written per claude session and never removed (236 leftovers measured on a working machine). Now deleted with the session and swept at boot, guarded by a live-session keep set plus a 7-day age floor. Verified end to end on an isolated instance: marker written for header, body and lineage-only spawns, absent with no agent signal and for a pre-existing directory; inUse flipping on session end; badge, sticky bar, confirm and sweep driven in a browser; preamble seeded on create, removed on delete, boot sweep taking only the aged orphans. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
7.7 KiB
TypeScript
184 lines
7.7 KiB
TypeScript
/**
|
|
* @fileoverview The marker file that records a case directory as one Codeman scaffolded
|
|
* FOR an agent-spawned session, so scratch worker workspaces can be told apart from the
|
|
* user's real projects long after the sessions that created them are gone.
|
|
*
|
|
* Why a file in the case directory rather than a central registry in `~/.codeman`:
|
|
* the thing being labelled is a directory on the user's disk, and the label has to
|
|
* survive everything that can happen to Codeman's own state (a wiped data dir, a
|
|
* different instance, a hand-moved case). A registry would also need stale-entry
|
|
* pruning and owner scoping of its own, while a marker is deleted by the same `rm -rf`
|
|
* that deletes the case, and is discoverable by a user who just runs `ls -a`.
|
|
*
|
|
* ⚠️ Written ONLY on the path that CREATES the directory (`POST /api/quick-start`'s
|
|
* `!existsSync` branch). A linked case, a cloned repo, a git worktree or any other
|
|
* pre-existing directory must never be labelled agent-created: the label drives a
|
|
* cleanup affordance, and mislabelling someone's repo there is the one failure mode
|
|
* that costs real work. `POST /api/sessions` takes an existing `workingDir` and so
|
|
* writes no marker at all, by construction.
|
|
*
|
|
* ⚠️ Reading is strict and total: anything that does not parse as a version-1 marker
|
|
* (truncated write, hand-edited junk, a user's unrelated file of the same name) reads
|
|
* as "not agent-created" rather than as a partially-trusted entry. A marker is
|
|
* metadata; deleting the file is the supported way to adopt a scratch case as a real
|
|
* one, which is what the `note` field written into it tells the user.
|
|
*/
|
|
|
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
|
|
/** Marker filename inside the case directory. Dot-prefixed so it stays out of the way. */
|
|
export const AGENT_CASE_MARKER_FILE = '.codeman-agent-case.json';
|
|
|
|
/** Current marker schema version. A marker of any other version reads as absent. */
|
|
export const AGENT_CASE_MARKER_VERSION = 1;
|
|
|
|
/**
|
|
* Origin recorded when a create request carried a resolvable spawning session but no
|
|
* explicit origin of its own (an agent driving the API by hand, or an older copy of
|
|
* the skill). Nothing in the browser UI sets lineage, so this really does mean "another
|
|
* session spawned this", not "a human clicked Run".
|
|
*/
|
|
export const AGENT_ORIGIN_SPAWNED_BY_SESSION = 'agent-session';
|
|
|
|
/** Origin the packaged agent skill sends on its shared curl invocation. */
|
|
export const AGENT_ORIGIN_CODEMAN_SKILL = 'codeman-skill';
|
|
|
|
/** Longest accepted origin token (the value is echoed into the UI and the marker). */
|
|
const MAX_ORIGIN_LENGTH = 32;
|
|
|
|
/** Longest accepted free-text field read back out of a marker. */
|
|
const MAX_MARKER_FIELD_LENGTH = 200;
|
|
|
|
/** Lowercase token: what an origin may look like on the wire and on disk. */
|
|
const AGENT_ORIGIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
|
|
/** Explains the file to whoever finds it in their case directory. */
|
|
const MARKER_NOTE =
|
|
'Created by a Codeman agent worker (see the Manage tab in Add Case). ' +
|
|
'Delete this file to keep the case out of the agent-case cleanup list; ' +
|
|
'deleting the whole directory removes the case.';
|
|
|
|
/**
|
|
* What a case directory records about the agent spawn that created it.
|
|
* Every field beyond `version`/`createdAt`/`createdBy` is decoration for the cleanup UI.
|
|
*/
|
|
export interface AgentCaseMarker {
|
|
version: typeof AGENT_CASE_MARKER_VERSION;
|
|
/** ISO timestamp of the spawn that created the directory. */
|
|
createdAt: string;
|
|
/** Who asked: `codeman-skill`, `agent-session`, or another caller's own token. */
|
|
createdBy: string;
|
|
/** Full id of the session that spawned the worker, when one resolved. */
|
|
parentSessionId?: string;
|
|
/** That session's display name at spawn time, so the user recognises it later. */
|
|
parentSessionName?: string;
|
|
/** Run mode the worker was started in (`claude`, `deepseek`, …). */
|
|
mode?: string;
|
|
/** Owner the case was created for, in multi-user mode. */
|
|
owner?: string;
|
|
}
|
|
|
|
/**
|
|
* Validate an origin token coming off the wire (`agentOrigin` body field or the
|
|
* `X-Codeman-Agent-Origin` header). Returns `undefined` for anything that is not a
|
|
* short lowercase token — the value reaches the UI and a JSON file, so it is
|
|
* allowlisted rather than escaped at each use.
|
|
*/
|
|
export function normalizeAgentOrigin(raw: unknown): string | undefined {
|
|
if (typeof raw !== 'string') return undefined;
|
|
const value = raw.trim().toLowerCase();
|
|
if (!value || value.length > MAX_ORIGIN_LENGTH) return undefined;
|
|
return AGENT_ORIGIN_PATTERN.test(value) ? value : undefined;
|
|
}
|
|
|
|
/** Trim an optional free-text marker field to something safe to store and render. */
|
|
function normalizeField(raw: unknown): string | undefined {
|
|
if (typeof raw !== 'string') return undefined;
|
|
const value = raw.trim();
|
|
return value ? value.slice(0, MAX_MARKER_FIELD_LENGTH) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Build a marker from a spawn's details. Pure, so the route can hand it straight to
|
|
* the writer and the tests can assert on the shape without touching a disk.
|
|
*/
|
|
export function buildAgentCaseMarker(input: {
|
|
createdBy: string;
|
|
createdAt?: Date;
|
|
parentSessionId?: string;
|
|
parentSessionName?: string;
|
|
mode?: string;
|
|
owner?: string;
|
|
}): AgentCaseMarker {
|
|
const marker: AgentCaseMarker = {
|
|
version: AGENT_CASE_MARKER_VERSION,
|
|
createdAt: (input.createdAt ?? new Date()).toISOString(),
|
|
createdBy: normalizeAgentOrigin(input.createdBy) ?? AGENT_ORIGIN_SPAWNED_BY_SESSION,
|
|
};
|
|
const parentSessionId = normalizeField(input.parentSessionId);
|
|
const parentSessionName = normalizeField(input.parentSessionName);
|
|
const mode = normalizeField(input.mode);
|
|
const owner = normalizeField(input.owner);
|
|
if (parentSessionId) marker.parentSessionId = parentSessionId;
|
|
if (parentSessionName) marker.parentSessionName = parentSessionName;
|
|
if (mode) marker.mode = mode;
|
|
if (owner) marker.owner = owner;
|
|
return marker;
|
|
}
|
|
|
|
/**
|
|
* Parse marker JSON. Returns `null` for anything that is not a well-formed version-1
|
|
* marker, including a valid-JSON object of the wrong shape — see the strictness note
|
|
* in the file header.
|
|
*/
|
|
export function parseAgentCaseMarker(raw: string): AgentCaseMarker | null {
|
|
let value: unknown;
|
|
try {
|
|
value = JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
|
|
const record = value as Record<string, unknown>;
|
|
if (record.version !== AGENT_CASE_MARKER_VERSION) return null;
|
|
|
|
const createdAt = normalizeField(record.createdAt);
|
|
const createdBy = normalizeAgentOrigin(record.createdBy);
|
|
if (!createdAt || !createdBy || Number.isNaN(Date.parse(createdAt))) return null;
|
|
|
|
return buildAgentCaseMarker({
|
|
createdBy,
|
|
createdAt: new Date(createdAt),
|
|
parentSessionId: normalizeField(record.parentSessionId),
|
|
parentSessionName: normalizeField(record.parentSessionName),
|
|
mode: normalizeField(record.mode),
|
|
owner: normalizeField(record.owner),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Write the marker into `casePath`. Best-effort by design: the marker is metadata for
|
|
* a later cleanup, and a failed write must never fail the worker spawn that is the
|
|
* point of the request. Returns whether it landed.
|
|
*/
|
|
export async function writeAgentCaseMarker(casePath: string, marker: AgentCaseMarker): Promise<boolean> {
|
|
try {
|
|
const body = JSON.stringify({ ...marker, note: MARKER_NOTE }, null, 2);
|
|
await writeFile(join(casePath, AGENT_CASE_MARKER_FILE), `${body}\n`, 'utf-8');
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Read the marker out of `casePath`, or `null` if there isn't a valid one. */
|
|
export async function readAgentCaseMarker(casePath: string): Promise<AgentCaseMarker | null> {
|
|
try {
|
|
return parseAgentCaseMarker(await readFile(join(casePath, AGENT_CASE_MARKER_FILE), 'utf-8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|