mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 06:31:41 +00:00
* feat(gateway): session worktree targeting, branch listing, and group catalog
- sessions.create accepts worktreeBaseRef/worktreeName (write scope) and
execNode (admin); worktree binding persists on the session entry as
worktree { id, branch, repoRoot } and projects onto session rows with
execNode so UIs can show checkout/branch/node state
- new worktrees.branches RPC lists local/remote branches (no fetch) for
base-ref pickers; worktrees.remove now reports snapshotError
- sessions.delete reports preserved dirty checkouts as worktreePreserved
- gateway-owned session group catalog in the shared state DB with
sessions.groups.list/put/rename/delete; rename/delete update member
categories server-side without bumping recency; sessions.patch absorbs
ad-hoc categories into the catalog
- group session display names prefer the human chat title (subject or
space #channel) over stored compact tokens
Part of #103431
* fix(gateway): route group category updates through the session accessor and regen protocol models
- bulk member-category rename/clear uses applySessionEntryReplacements
instead of legacy updateSessionStore call sites (session accessor
boundary guard)
- regenerate Swift GatewayModels for the worktrees.branches schemas,
snapshotError, and new sessions.create params
Part of #103431
* fix(gateway): resolvable remote branch refs and workspace-scoped branch listing
- worktrees.branches returns remote-only branches remote-qualified
(origin/feature-a) so every advertised name works as a worktree base ref
- write-scoped worktrees.branches callers are limited to configured agent
workspaces; other host paths require operator.admin, matching the
sessions.create cwd bar
Part of #103431
* fix(gateway): guard worktree name reuse by owner and env-scope group transactions
- managedWorktrees.create rejects a caller-chosen name whose live or
restorable record belongs to a different owner, so write-scoped
sessions.create cannot bind a session into another session's or a
manual checkout
- session group catalog writes run their SQLite transaction on the same
env-scoped handle as their statements, keeping OPENCLAW_STATE_DIR
overrides atomic and away from the default state DB
Part of #103431
236 lines
7.6 KiB
TypeScript
236 lines
7.6 KiB
TypeScript
// Gateway-owned custom session group catalog.
|
|
// Membership stays on each session entry's category field; this module owns
|
|
// which groups exist, their display order, and bulk member category updates.
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
|
import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions.js";
|
|
import { applySessionEntryReplacements } from "../config/sessions/session-accessor.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
|
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
|
import {
|
|
openOpenClawStateDatabase,
|
|
runOpenClawStateWriteTransaction,
|
|
} from "../state/openclaw-state-db.js";
|
|
|
|
// Write transactions must run on the same env-scoped handle as their
|
|
// statements; a bare transaction would open the default state DB while the
|
|
// SQL hits the override, losing atomicity under OPENCLAW_STATE_DIR overrides.
|
|
|
|
export type SessionGroupRecord = { name: string; position: number };
|
|
|
|
type SessionGroupsDatabase = Pick<OpenClawStateKyselyDatabase, "session_groups">;
|
|
|
|
function dbFor(env: NodeJS.ProcessEnv): DatabaseSync {
|
|
return openOpenClawStateDatabase({ env }).db;
|
|
}
|
|
|
|
function kyselyFor(db: DatabaseSync) {
|
|
return getNodeSqliteKysely<SessionGroupsDatabase>(db);
|
|
}
|
|
|
|
function normalizeGroupNames(names: readonly string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const normalized: string[] = [];
|
|
for (const raw of names) {
|
|
const name = normalizeOptionalString(raw);
|
|
if (!name || seen.has(name)) {
|
|
continue;
|
|
}
|
|
seen.add(name);
|
|
normalized.push(name);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function listSessionGroups(env: NodeJS.ProcessEnv = process.env): SessionGroupRecord[] {
|
|
const db = dbFor(env);
|
|
const query = kyselyFor(db)
|
|
.selectFrom("session_groups")
|
|
.select(["name", "position"])
|
|
.orderBy("position", "asc")
|
|
.orderBy("name", "asc");
|
|
return executeSqliteQuerySync(db, query).rows.map((row) => ({
|
|
name: row.name,
|
|
position: row.position,
|
|
}));
|
|
}
|
|
|
|
/** Replaces the ordered catalog. Sessions keep their category even when a name is dropped. */
|
|
export function putSessionGroups(
|
|
names: readonly string[],
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): SessionGroupRecord[] {
|
|
const normalized = normalizeGroupNames(names);
|
|
const now = Date.now();
|
|
runOpenClawStateWriteTransaction(
|
|
({ db }) => {
|
|
const kysely = kyselyFor(db);
|
|
const existing = new Map(
|
|
executeSqliteQuerySync(
|
|
db,
|
|
kysely.selectFrom("session_groups").select(["name", "created_at"]),
|
|
).rows.map((row) => [row.name, row.created_at]),
|
|
);
|
|
executeSqliteQuerySync(db, kysely.deleteFrom("session_groups"));
|
|
normalized.forEach((name, position) => {
|
|
executeSqliteQuerySync(
|
|
db,
|
|
kysely.insertInto("session_groups").values({
|
|
name,
|
|
position,
|
|
created_at: existing.get(name) ?? now,
|
|
}),
|
|
);
|
|
});
|
|
},
|
|
{ env },
|
|
);
|
|
return normalized.map((name, position) => ({ name, position }));
|
|
}
|
|
|
|
/**
|
|
* Absorbs a category assigned through sessions.patch so the catalog keeps
|
|
* covering every group an operator UI can observe, appended at the end.
|
|
*/
|
|
export function ensureSessionGroupRegistered(
|
|
name: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): void {
|
|
const normalized = normalizeOptionalString(name);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
runOpenClawStateWriteTransaction(
|
|
({ db }) => {
|
|
const kysely = kyselyFor(db);
|
|
const existing = executeSqliteQuerySync(
|
|
db,
|
|
kysely.selectFrom("session_groups").select("name").where("name", "=", normalized).limit(1),
|
|
).rows[0];
|
|
if (existing) {
|
|
return;
|
|
}
|
|
const maxRow = executeSqliteQuerySync(
|
|
db,
|
|
kysely.selectFrom("session_groups").select("position").orderBy("position", "desc").limit(1),
|
|
).rows[0];
|
|
executeSqliteQuerySync(
|
|
db,
|
|
kysely.insertInto("session_groups").values({
|
|
name: normalized,
|
|
position: (maxRow?.position ?? -1) + 1,
|
|
created_at: Date.now(),
|
|
}),
|
|
);
|
|
},
|
|
{ env },
|
|
);
|
|
}
|
|
|
|
function renameCatalogEntry(from: string, to: string, env: NodeJS.ProcessEnv): void {
|
|
runOpenClawStateWriteTransaction(
|
|
({ db }) => {
|
|
const kysely = kyselyFor(db);
|
|
const source = executeSqliteQuerySync(
|
|
db,
|
|
kysely.selectFrom("session_groups").selectAll().where("name", "=", from).limit(1),
|
|
).rows[0];
|
|
const targetExists = executeSqliteQuerySync(
|
|
db,
|
|
kysely.selectFrom("session_groups").select("name").where("name", "=", to).limit(1),
|
|
).rows[0];
|
|
executeSqliteQuerySync(db, kysely.deleteFrom("session_groups").where("name", "=", from));
|
|
if (targetExists) {
|
|
// Rename into an existing group merges memberships; keep the target row.
|
|
return;
|
|
}
|
|
executeSqliteQuerySync(
|
|
db,
|
|
kysely.insertInto("session_groups").values({
|
|
name: to,
|
|
position: source?.position ?? 0,
|
|
created_at: source?.created_at ?? Date.now(),
|
|
}),
|
|
);
|
|
},
|
|
{ env },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Bulk-updates member session categories across every agent store without
|
|
* bumping updatedAt: group maintenance must not reshuffle recency ordering.
|
|
*/
|
|
async function updateMemberCategories(
|
|
cfg: OpenClawConfig,
|
|
from: string,
|
|
to: string | undefined,
|
|
env: NodeJS.ProcessEnv,
|
|
): Promise<number> {
|
|
let updated = 0;
|
|
for (const target of resolveAllAgentSessionStoreTargetsSync(cfg, { env })) {
|
|
updated += await applySessionEntryReplacements<number>({
|
|
storePath: target.storePath,
|
|
update: (entries) => {
|
|
const replacements = entries.flatMap(({ sessionKey, entry }) => {
|
|
if (entry.category?.trim() !== from) {
|
|
return [];
|
|
}
|
|
const next = { ...entry };
|
|
if (to === undefined) {
|
|
delete next.category;
|
|
} else {
|
|
next.category = to;
|
|
}
|
|
return [{ sessionKey, entry: next }];
|
|
});
|
|
return { replacements, result: replacements.length };
|
|
},
|
|
});
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
export async function renameSessionGroup(params: {
|
|
cfg: OpenClawConfig;
|
|
name: string;
|
|
to: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): Promise<{ groups: SessionGroupRecord[]; updatedSessions: number }> {
|
|
const env = params.env ?? process.env;
|
|
const from = normalizeOptionalString(params.name);
|
|
const to = normalizeOptionalString(params.to);
|
|
if (!from || !to) {
|
|
throw new Error("group rename requires non-empty names");
|
|
}
|
|
if (from !== to) {
|
|
renameCatalogEntry(from, to, env);
|
|
}
|
|
const updatedSessions = from === to ? 0 : await updateMemberCategories(params.cfg, from, to, env);
|
|
return { groups: listSessionGroups(env), updatedSessions };
|
|
}
|
|
|
|
export async function deleteSessionGroup(params: {
|
|
cfg: OpenClawConfig;
|
|
name: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): Promise<{ groups: SessionGroupRecord[]; updatedSessions: number }> {
|
|
const env = params.env ?? process.env;
|
|
const name = normalizeOptionalString(params.name);
|
|
if (!name) {
|
|
throw new Error("group delete requires a non-empty name");
|
|
}
|
|
runOpenClawStateWriteTransaction(
|
|
({ db }) => {
|
|
executeSqliteQuerySync(
|
|
db,
|
|
kyselyFor(db).deleteFrom("session_groups").where("name", "=", name),
|
|
);
|
|
},
|
|
{ env },
|
|
);
|
|
const updatedSessions = await updateMemberCategories(params.cfg, name, undefined, env);
|
|
return { groups: listSessionGroups(env), updatedSessions };
|
|
}
|