mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(cases): rename a case and its folder, with a lock and live-session handling
This commit is contained in:
1 parent
002de32a08
commit
ab0bc0aecd
14 files changed
+884
No files matched your search
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @fileoverview Renaming a case (its name and its folder) without losing what points at the folder.
|
||||
*
|
||||
* A case folder is held in more places than an open file. A running agent has it as its working
|
||||
* directory and its tmux pane was started `cd`-ed into it; Claude Code keys its conversation history
|
||||
* by the folder's path (`<config>/projects/<path with every non-alphanumeric as "-">`), so a moved
|
||||
* folder silently orphans every conversation; on Windows, SMB and some FUSE mounts a directory with an
|
||||
* open handle simply cannot be renamed (EBUSY / EPERM / EACCES). This module owns the three parts of
|
||||
* that which are not route logic:
|
||||
*
|
||||
* - the in-process lock (`withCaseLock` / `isCasePathLocked`) that keeps a session from starting in a
|
||||
* folder while it is being moved, and two renames from overlapping;
|
||||
* - the move itself, retried a few times for a transient lock and turned into a message that says
|
||||
* what to do when it is a real one;
|
||||
* - carrying Claude Code's history for the folder over to the new path.
|
||||
*
|
||||
* Pure of the server: every function takes the paths it works on.
|
||||
*
|
||||
* @module case-rename
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname, join, resolve, sep } from 'node:path';
|
||||
import { claudeProjectsDir } from './utils/claude-transcript.js';
|
||||
|
||||
export type CaseRenameErrorCode =
|
||||
| 'INVALID' // a name that is not allowed, or the same name
|
||||
| 'EXISTS' // the new name or folder is already taken
|
||||
| 'BUSY' // another rename (or a session being stopped) holds the folder
|
||||
| 'LOCKED' // the OS refused the move: something still has the folder open
|
||||
| 'CROSS_DEVICE' // the move would cross filesystems
|
||||
| 'UNSUPPORTED'; // remote and Docker cases
|
||||
|
||||
export class CaseRenameError extends Error {
|
||||
constructor(
|
||||
readonly code: CaseRenameErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CaseRenameError';
|
||||
}
|
||||
}
|
||||
|
||||
const locked = new Set<string>();
|
||||
|
||||
const within = (child: string, parent: string): boolean => child === parent || child.startsWith(parent + sep);
|
||||
|
||||
/** True when `path` is, or is inside, a folder that is being renamed right now. */
|
||||
export function isCasePathLocked(path: string): boolean {
|
||||
const target = resolve(path);
|
||||
for (const held of locked) {
|
||||
if (within(target, held)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold `from` and `to` for the length of `fn`. A second caller touching either (or anything inside
|
||||
* them) gets `BUSY` instead of racing the move; `Session`'s constructor refuses a working directory
|
||||
* inside a held folder, so nothing starts in a case that is half-moved.
|
||||
*/
|
||||
export async function withCaseLock<T>(from: string, to: string, fn: () => Promise<T>): Promise<T> {
|
||||
const held = [resolve(from), resolve(to)];
|
||||
for (const p of held) {
|
||||
for (const other of locked) {
|
||||
if (within(p, other) || within(other, p)) {
|
||||
throw new CaseRenameError('BUSY', 'Another rename is already working on this case. Try again in a moment.');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const p of held) locked.add(p);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (const p of held) locked.delete(p);
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
||||
|
||||
/**
|
||||
* `rename(from, to)` for a folder that must not already exist at `to`. A lock error is retried (a
|
||||
* handle another process is closing, an antivirus scan) before it is reported as locked.
|
||||
* `fs.rename` onto an EMPTY existing directory replaces it on POSIX, so the target is checked first.
|
||||
*/
|
||||
export async function moveCaseFolder(from: string, to: string, attempts = 4, delayMs = 150): Promise<void> {
|
||||
if (
|
||||
await fs.lstat(to).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
) {
|
||||
throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`);
|
||||
}
|
||||
for (let i = 0; ; i++) {
|
||||
try {
|
||||
await fs.rename(from, to);
|
||||
return;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code ?? '';
|
||||
if (code === 'EXDEV') {
|
||||
throw new CaseRenameError(
|
||||
'CROSS_DEVICE',
|
||||
'The new folder would be on a different drive or mount; it is not moved.'
|
||||
);
|
||||
}
|
||||
if (LOCK_CODES.has(code)) {
|
||||
if (i < attempts - 1) {
|
||||
await sleep(delayMs * (i + 1));
|
||||
continue;
|
||||
}
|
||||
throw new CaseRenameError(
|
||||
'LOCKED',
|
||||
`The folder is in use by another program (${code}), so it could not be renamed. Close anything that has it open ` +
|
||||
'(a terminal, an editor, a file browser or a sync client), or stop the sessions using it, and try again.'
|
||||
);
|
||||
}
|
||||
if (code === 'ENOTEMPTY' || code === 'EEXIST') {
|
||||
throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The directory name Claude Code uses for a project path: every non-alphanumeric becomes `-`. */
|
||||
export function claudeProjectDirName(path: string): string {
|
||||
return resolve(path).replace(/[^a-zA-Z0-9]/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Move Claude Code's history for the old folder to the new one, so `--resume` and the past-sessions list
|
||||
* still find the conversations. Leaves it alone when the new path already has history (nothing is merged
|
||||
* or overwritten) or when there is none. Returns whether anything moved.
|
||||
*/
|
||||
export async function moveClaudeHistory(oldPath: string, newPath: string, configDir?: string): Promise<boolean> {
|
||||
const projects = claudeProjectsDir(configDir);
|
||||
const from = join(projects, claudeProjectDirName(oldPath));
|
||||
const to = join(projects, claudeProjectDirName(newPath));
|
||||
if (from === to) return false;
|
||||
if (
|
||||
!(await fs.lstat(from).then(
|
||||
() => true,
|
||||
() => false
|
||||
))
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
await fs.lstat(to).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
await fs.rename(from, to);
|
||||
return true;
|
||||
} catch {
|
||||
return false; // history is a convenience; the folder move already succeeded
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace `from` by `to` in a list, keeping its position (the case order). */
|
||||
export function renameInOrder(order: unknown, from: string, to: string): string[] | null {
|
||||
if (!Array.isArray(order) || !order.includes(from)) return null;
|
||||
return order.map((n) => (n === from ? to : n)) as string[];
|
||||
}
|
||||
|
||||
/** The folder a linked case moves to: its own parent, the new name. */
|
||||
export const siblingPath = (path: string, newName: string): string => join(dirname(path), newName);
|
||||
@@ -151,6 +151,7 @@ import {
|
||||
} from './session-attachment-history.js';
|
||||
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
|
||||
import { resolveConfigModel } from './model-config-resolvers.js';
|
||||
import { isCasePathLocked } from './case-rename.js';
|
||||
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||
import {
|
||||
launchModelFor,
|
||||
@@ -948,6 +949,11 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
) {
|
||||
super();
|
||||
// A case folder being renamed (src/case-rename.ts) must not gain a session mid-move: its pane would
|
||||
// start in a directory that is about to have a different path.
|
||||
if (config.workingDir && isCasePathLocked(config.workingDir)) {
|
||||
throw new Error(`The case folder ${config.workingDir} is being renamed. Try again in a moment.`);
|
||||
}
|
||||
this.setMaxListeners(25);
|
||||
|
||||
// Default error handler prevents unhandled 'error' events from crashing the process.
|
||||
|
||||
@@ -1914,6 +1914,7 @@ const SSE_EVENTS = {
|
||||
CASE_CREATED: 'case:created',
|
||||
CASE_LINKED: 'case:linked',
|
||||
CASE_DELETED: 'case:deleted',
|
||||
CASE_RENAMED: 'case:renamed',
|
||||
CASE_ORDER_CHANGED: 'case:order-changed',
|
||||
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
|
||||
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
|
||||
|
||||
@@ -4957,6 +4957,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>▼</button>
|
||||
${
|
||||
c.location === 'docker' || c.location === 'remote'
|
||||
? ''
|
||||
: `<button class="case-manage-btn" onclick="app.renameCase(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Rename case (and its folder)">✎</button>`
|
||||
}
|
||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Delete case">✕</button>
|
||||
</div>
|
||||
@@ -4989,6 +4995,67 @@ Object.assign(CodemanApp.prototype, {
|
||||
await this.saveCaseOrder(reordered.map(c => c.name));
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename a case and its folder (POST /api/cases/:name/rename). A linked case asks whether the real folder
|
||||
* should be renamed too (Cancel renames only the alias). When live sessions have the folder open the server
|
||||
* refuses and lists them; they are only stopped after a second, explicit confirmation.
|
||||
*/
|
||||
async renameCase(name) {
|
||||
const info = (this.cases || []).find((c) => c.name === name);
|
||||
const entered = prompt(`Rename case "${name}" to (letters, numbers, - and _):`, name);
|
||||
const newName = (entered || '').trim();
|
||||
if (!newName || newName === name) return;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(newName)) {
|
||||
this.showToast('Use only letters, numbers, hyphens and underscores', 'error');
|
||||
return;
|
||||
}
|
||||
const body = { newName };
|
||||
if (info?.linked) {
|
||||
body.renameFolder = confirm(
|
||||
`Also rename the folder on disk?\n\n${info.path}\n\nOK renames the folder to "${newName}" next to it. ` +
|
||||
`Cancel keeps the folder and only changes the case name.`
|
||||
);
|
||||
}
|
||||
const send = async () => {
|
||||
const res = await fetch(`/api/cases/${encodeURIComponent(name)}/rename`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json().catch(() => ({ success: false, error: 'Unexpected response' }));
|
||||
};
|
||||
try {
|
||||
let data = await send();
|
||||
if (data.requiresStop) {
|
||||
const list = (data.sessions || []).map((s) => ` - ${s.name}`).join('\n');
|
||||
if (!confirm(`These sessions have the folder open and will be stopped (nothing is restarted):\n\n${list}\n\nStop them and rename?`)) {
|
||||
return;
|
||||
}
|
||||
body.stopSessions = true;
|
||||
data = await send();
|
||||
}
|
||||
if (!data.success) {
|
||||
this.showToast(data.error || 'Failed to rename case', 'error');
|
||||
return;
|
||||
}
|
||||
this.showToast(`Case renamed to "${newName}"`, 'success');
|
||||
// The per-case run options are stored under the case name in this browser.
|
||||
try {
|
||||
const stored = localStorage.getItem('caseSettings_' + name);
|
||||
if (stored !== null) {
|
||||
localStorage.setItem('caseSettings_' + newName, stored);
|
||||
localStorage.removeItem('caseSettings_' + name);
|
||||
}
|
||||
} catch { /* storage blocked: the options fall back to their defaults */ }
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const current = select?.value;
|
||||
await this.loadQuickStartCases(current === name ? newName : current);
|
||||
this.renderCaseManageList();
|
||||
} catch (err) {
|
||||
this.showToast('Failed to rename case: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCase(name) {
|
||||
if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) {
|
||||
return;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
LinkCaseSchema,
|
||||
CaseOrderSchema,
|
||||
CaseDefaultGroupSchema,
|
||||
RenameCaseSchema,
|
||||
RemoteCaseLinkSchema,
|
||||
RemoteHostSchema,
|
||||
DockerCaseLinkSchema,
|
||||
@@ -52,6 +53,14 @@ import {
|
||||
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { prepareNewCasePath } from '../case-path.js';
|
||||
import {
|
||||
CaseRenameError,
|
||||
moveCaseFolder,
|
||||
moveClaudeHistory,
|
||||
renameInOrder,
|
||||
siblingPath,
|
||||
withCaseLock,
|
||||
} from '../../case-rename.js';
|
||||
import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js';
|
||||
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
|
||||
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
|
||||
@@ -1747,6 +1756,137 @@ export function registerCaseRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Rename Case ==========
|
||||
|
||||
/**
|
||||
* Rename a case: its name and, with it, its folder.
|
||||
*
|
||||
* - A case under the cases directory IS its folder name, so it always moves.
|
||||
* - A linked case has an alias that is only a key in linked-cases.json; the real folder moves too only
|
||||
* with `renameFolder: true` (it can be shared with other tools).
|
||||
* - Remote and Docker cases are not renamable here: their folder lives on another host or in a container
|
||||
* whose name derives from the case name.
|
||||
*
|
||||
* ⚠️ Moving a folder that sessions have open is the dangerous part (see src/case-rename.ts): live
|
||||
* sessions in it are listed and refused (409 `requiresStop`) unless `stopSessions` is set, in which
|
||||
* case they are ended first. The folder is held by an in-process lock for the whole operation, so a
|
||||
* session cannot start in it half-moved.
|
||||
*/
|
||||
app.post('/api/cases/:name/rename', async (req, reply) => {
|
||||
const { name } = req.params as { name: string };
|
||||
const user = getAuthUser(req);
|
||||
const body = parseBody(RenameCaseSchema, req.body);
|
||||
const newName = body.newName;
|
||||
const fail = (status: number, code: ApiErrorCode, message: string, extra: Record<string, unknown> = {}) => {
|
||||
reply.code(status);
|
||||
return { ...createErrorResponse(code, message), ...extra };
|
||||
};
|
||||
if (!SAFE_CASE_NAME.test(name) || !validatePathWithinBase(name, resolveCasesDir(user))) {
|
||||
return fail(400, ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
if (newName === name) return fail(400, ApiErrorCode.INVALID_INPUT, 'The new name is the same as the old one');
|
||||
|
||||
// Remote and Docker cases first, so a same-named local case cannot be confused for them.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
remoteCases.some((c) => c.name === name && canAccessOwned(user, c.owner)) ||
|
||||
dockerCases.some((c) => c.name === name && canAccessOwned(user, c.owner))
|
||||
) {
|
||||
return fail(400, ApiErrorCode.INVALID_INPUT, 'Remote and Docker cases cannot be renamed here');
|
||||
}
|
||||
|
||||
const casesBase = resolveCasesDir(user);
|
||||
const linkedCases = await readLinkedCases();
|
||||
const isLinked = Boolean(linkedCases[name]) && (!isMultiUserMode() || isAdmin(req));
|
||||
if (isLinked && !isAdmin(req)) return fail(403, ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
const oldPath = isLinked ? linkedCases[name] : join(casesBase, name);
|
||||
|
||||
// The new name must be free in every namespace the list merges.
|
||||
const localTaken = existsSync(join(casesBase, newName));
|
||||
const taken =
|
||||
localTaken ||
|
||||
Boolean(linkedCases[newName]) ||
|
||||
remoteCases.some((c) => c.name === newName) ||
|
||||
dockerCases.some((c) => c.name === newName);
|
||||
if (taken) return fail(409, ApiErrorCode.ALREADY_EXISTS, `A case named "${newName}" already exists`);
|
||||
|
||||
const state = await probePath(oldPath);
|
||||
if (state === 'absent') return fail(404, ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
|
||||
if (state === 'unknown') {
|
||||
return fail(503, ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', oldPath, { pastCap: true }));
|
||||
}
|
||||
|
||||
const moveFolder = !isLinked || body.renameFolder === true;
|
||||
const newPath = isLinked ? (moveFolder ? siblingPath(oldPath, newName) : oldPath) : join(casesBase, newName);
|
||||
if (moveFolder && newPath !== oldPath && (await probePath(newPath, { pastCap: true })) !== 'absent') {
|
||||
return fail(409, ApiErrorCode.ALREADY_EXISTS, `A folder named ${newPath} already exists`);
|
||||
}
|
||||
|
||||
// Sessions with the folder open. Only a move disturbs them; an alias-only rename leaves them running.
|
||||
const open = moveFolder
|
||||
? Array.from(ctx.sessions.values()).filter(
|
||||
(s) => s.workingDir === oldPath || s.workingDir.startsWith(oldPath + '/')
|
||||
)
|
||||
: [];
|
||||
if (open.some((s) => !canAccessOwned(user, s.owner))) {
|
||||
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
|
||||
}
|
||||
if (open.length > 0 && !body.stopSessions) {
|
||||
return fail(
|
||||
409,
|
||||
ApiErrorCode.CONFLICT,
|
||||
`${open.length} live session${open.length === 1 ? ' has' : 's have'} this folder open. Stop them to rename it.`,
|
||||
{ requiresStop: true, sessions: open.map((s) => ({ id: s.id, name: s.name })) }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await withCaseLock(oldPath, newPath, async () => {
|
||||
for (const s of open) await ctx.cleanupSession(s.id, true, 'case_rename');
|
||||
if (moveFolder) await moveCaseFolder(oldPath, newPath);
|
||||
try {
|
||||
if (isLinked) {
|
||||
const links = await readLinkedCases();
|
||||
delete links[name];
|
||||
links[newName] = newPath;
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2));
|
||||
}
|
||||
const existing = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
const order = renameInOrder(existing.caseOrder, name, newName);
|
||||
const last = existing.lastUsedCase === name ? newName : undefined;
|
||||
if (order || last) {
|
||||
await fs.writeFile(
|
||||
SETTINGS_PATH,
|
||||
JSON.stringify(
|
||||
{ ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// The folder moved but the registries did not follow: put it back rather than leave a case
|
||||
// whose name and folder disagree.
|
||||
if (moveFolder) await fs.rename(newPath, oldPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
if (moveFolder) await moveClaudeHistory(oldPath, newPath);
|
||||
});
|
||||
ctx.broadcast(SseEvent.CaseRenamed, { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder });
|
||||
return {
|
||||
success: true,
|
||||
data: { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder, stoppedSessions: open.length },
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof CaseRenameError) {
|
||||
const status = err.code === 'BUSY' ? 409 : err.code === 'EXISTS' ? 409 : err.code === 'LOCKED' ? 423 : 400;
|
||||
return fail(status, ApiErrorCode.OPERATION_FAILED, err.message, { renameCode: err.code });
|
||||
}
|
||||
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Reorder Cases ==========
|
||||
|
||||
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
|
||||
|
||||
@@ -1992,6 +1992,21 @@ export const LinkCaseSchema = z.object({
|
||||
path: safePathSchema,
|
||||
});
|
||||
|
||||
/** POST /api/cases/:name/rename */
|
||||
export const RenameCaseSchema = z
|
||||
.object({
|
||||
newName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
|
||||
/** Linked cases only: also rename the real folder on disk (a case under the cases directory always moves). */
|
||||
renameFolder: z.boolean().optional(),
|
||||
/** Stop the live sessions that have the folder open, instead of refusing with the list of them. */
|
||||
stopSessions: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** PUT /api/cases/order */
|
||||
export const CaseOrderSchema = z.object({
|
||||
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
|
||||
|
||||
@@ -458,6 +458,8 @@ export const CaseCreated = 'case:created' as const;
|
||||
export const CaseLinked = 'case:linked' as const;
|
||||
/** Case deleted or unlinked. */
|
||||
export const CaseDeleted = 'case:deleted' as const;
|
||||
/** Case renamed (name and folder). */
|
||||
export const CaseRenamed = 'case:renamed' as const;
|
||||
/** Case ordering changed. */
|
||||
export const CaseOrderChanged = 'case:order-changed' as const;
|
||||
|
||||
@@ -702,6 +704,7 @@ export const SseEvent = {
|
||||
CaseCreated,
|
||||
CaseLinked,
|
||||
CaseDeleted,
|
||||
CaseRenamed,
|
||||
CaseOrderChanged,
|
||||
|
||||
// Docker cases
|
||||
|
||||
Reference in new issue
Block a user