Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants
267 lines
9.1 KiB
JavaScript
267 lines
9.1 KiB
JavaScript
const SETTINGS_KEY = 'permissionAutoAccept';
|
|
const RETRY_DELAYS_MS = [0, 250, 1000];
|
|
const REQUEST_TIMEOUT_MS = 5000;
|
|
const SESSION_CACHE_LIMIT = 10000;
|
|
|
|
const normalizePolicy = (value) => {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
const sessions = {};
|
|
const entries = source.sessions && typeof source.sessions === 'object' && !Array.isArray(source.sessions)
|
|
? Object.entries(source.sessions)
|
|
: [];
|
|
for (const [sessionId, enabled] of entries) {
|
|
if (sessionId && typeof enabled === 'boolean') sessions[sessionId] = enabled;
|
|
}
|
|
const revision = Number.isSafeInteger(source.revision) && source.revision >= 0 ? source.revision : 0;
|
|
return { sessions, revision };
|
|
};
|
|
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
export function createPermissionAutoAcceptRuntime({
|
|
globalEventHub,
|
|
buildOpenCodeUrl,
|
|
getOpenCodeAuthHeaders,
|
|
readSettingsFromDiskMigrated,
|
|
persistSettings,
|
|
broadcastGlobalUiEvent,
|
|
fetchImpl = fetch,
|
|
retryDelaysMs = RETRY_DELAYS_MS,
|
|
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
}) {
|
|
let policy = normalizePolicy();
|
|
let loaded = false;
|
|
let loadPromise = null;
|
|
let writePromise = Promise.resolve();
|
|
const sessions = new Map();
|
|
const inFlight = new Map();
|
|
const reconcilePromises = new Map();
|
|
|
|
const snapshot = () => ({
|
|
sessions: { ...policy.sessions },
|
|
revision: policy.revision,
|
|
});
|
|
|
|
const load = async () => {
|
|
if (loaded) return snapshot();
|
|
if (!loadPromise) {
|
|
loadPromise = readSettingsFromDiskMigrated()
|
|
.then((settings) => {
|
|
policy = normalizePolicy(settings?.[SETTINGS_KEY]);
|
|
loaded = true;
|
|
return snapshot();
|
|
})
|
|
.finally(() => { loadPromise = null; });
|
|
}
|
|
return loadPromise;
|
|
};
|
|
|
|
const persistUpdate = (update) => {
|
|
writePromise = writePromise.then(async () => {
|
|
const next = update(policy);
|
|
await persistSettings({ [SETTINGS_KEY]: next });
|
|
policy = next;
|
|
loaded = true;
|
|
broadcastGlobalUiEvent?.({
|
|
type: 'openchamber:permission-auto-accept.updated',
|
|
properties: snapshot(),
|
|
});
|
|
return snapshot();
|
|
});
|
|
return writePromise;
|
|
};
|
|
|
|
const setSessionPolicy = async (sessionId, enabled, directory) => {
|
|
if (typeof sessionId !== 'string' || !sessionId.trim()) throw new TypeError('sessionId is required');
|
|
if (typeof enabled !== 'boolean') throw new TypeError('enabled must be a boolean');
|
|
await load();
|
|
const result = await persistUpdate((current) => ({
|
|
...current,
|
|
sessions: { ...current.sessions, [sessionId.trim()]: enabled },
|
|
revision: current.revision + 1,
|
|
}));
|
|
if (enabled) await reconcilePending({ directories: [directory] });
|
|
return result;
|
|
};
|
|
|
|
const rememberSession = (info, directoryHint) => {
|
|
if (!info || typeof info.id !== 'string' || !info.id) return;
|
|
sessions.set(info.id, {
|
|
parentID: typeof info.parentID === 'string' && info.parentID ? info.parentID : null,
|
|
directory: typeof info.directory === 'string' && info.directory ? info.directory : directoryHint,
|
|
});
|
|
if (sessions.size > SESSION_CACHE_LIMIT) {
|
|
sessions.delete(sessions.keys().next().value);
|
|
}
|
|
};
|
|
|
|
const request = async (path, { directory, method = 'GET', body } = {}) => {
|
|
const url = new URL(buildOpenCodeUrl(path, ''));
|
|
if (directory) url.searchParams.set('directory', directory);
|
|
const response = await fetchImpl(url, {
|
|
method,
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
...getOpenCodeAuthHeaders(),
|
|
},
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
signal: AbortSignal.timeout(requestTimeoutMs),
|
|
});
|
|
if (!response.ok) {
|
|
const error = new Error(`OpenCode request failed (${response.status})`);
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
return response.json().catch(() => null);
|
|
};
|
|
|
|
const getSession = async (sessionId, directory) => {
|
|
const cached = sessions.get(sessionId);
|
|
if (cached) return cached;
|
|
const info = await request(`/session/${encodeURIComponent(sessionId)}`, { directory });
|
|
rememberSession(info?.data ?? info, directory);
|
|
return sessions.get(sessionId) ?? null;
|
|
};
|
|
|
|
const isSessionAutoAccepting = async (sessionId, directory) => {
|
|
await load();
|
|
const seen = new Set();
|
|
let current = sessionId;
|
|
let currentDirectory = directory;
|
|
while (current && !seen.has(current)) {
|
|
if (Object.hasOwn(policy.sessions, current)) return policy.sessions[current] === true;
|
|
seen.add(current);
|
|
let info;
|
|
try {
|
|
info = await getSession(current, currentDirectory);
|
|
} catch {
|
|
return false;
|
|
}
|
|
current = info?.parentID ?? null;
|
|
currentDirectory = info?.directory ?? currentDirectory;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const replyOnce = async (permission, directory) => {
|
|
if (!permission?.id || !permission?.sessionID) return false;
|
|
await load();
|
|
if (!(await isSessionAutoAccepting(permission.sessionID, directory))) return false;
|
|
await request(`/permission/${encodeURIComponent(permission.id)}/reply`, {
|
|
directory,
|
|
method: 'POST',
|
|
body: { reply: 'once' },
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const processPermission = (permission, directory) => {
|
|
if (!permission?.id) return Promise.resolve(false);
|
|
const key = permission.id;
|
|
const existing = inFlight.get(key);
|
|
if (existing) return existing;
|
|
const task = (async () => {
|
|
for (const delay of retryDelaysMs) {
|
|
if (delay > 0) await wait(delay);
|
|
try {
|
|
return await replyOnce(permission, directory);
|
|
} catch (error) {
|
|
if (error?.status === 404) return true;
|
|
}
|
|
}
|
|
return false;
|
|
})().finally(() => inFlight.delete(key));
|
|
inFlight.set(key, task);
|
|
return task;
|
|
};
|
|
|
|
async function reconcilePending({ directories = [] } = {}) {
|
|
const normalizedDirectories = Array.from(new Set(
|
|
directories.filter((directory) => typeof directory === 'string' && directory.trim()).map((directory) => directory.trim()),
|
|
));
|
|
const key = normalizedDirectories.length > 0 ? normalizedDirectories.join('\n') : 'all';
|
|
const existing = reconcilePromises.get(key);
|
|
if (existing) return existing;
|
|
const task = (async () => {
|
|
await load();
|
|
const scopes = [undefined, ...normalizedDirectories];
|
|
const pendingById = new Map();
|
|
for (const directory of scopes) {
|
|
let payload;
|
|
try {
|
|
payload = await request('/permission', { directory });
|
|
} catch {
|
|
continue;
|
|
}
|
|
const pending = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : null;
|
|
if (!pending) continue;
|
|
for (const permission of pending) {
|
|
if (!permission?.id) continue;
|
|
pendingById.set(permission.id, { permission, directory: permission.directory ?? directory });
|
|
}
|
|
}
|
|
await Promise.all(Array.from(pendingById.values()).map(({ permission, directory }) =>
|
|
processPermission(permission, directory)));
|
|
})().finally(() => { reconcilePromises.delete(key); });
|
|
reconcilePromises.set(key, task);
|
|
return task;
|
|
}
|
|
|
|
const processEvent = (event) => {
|
|
const raw = event?.payload;
|
|
const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw;
|
|
const directory = typeof event?.directory === 'string' && event.directory !== 'global' ? event.directory : undefined;
|
|
if (payload?.type === 'session.created' || payload?.type === 'session.updated') {
|
|
rememberSession(payload.properties?.info, directory);
|
|
return;
|
|
}
|
|
if (payload?.type === 'permission.asked') {
|
|
void processPermission(payload.properties, directory);
|
|
}
|
|
};
|
|
|
|
const start = () => {
|
|
const unsubscribeEvent = globalEventHub.subscribeEvent(processEvent);
|
|
const unsubscribeStatus = globalEventHub.subscribeStatus((status) => {
|
|
if (status?.type === 'connect') void reconcilePending();
|
|
});
|
|
void load().then(() => reconcilePending()).catch((error) => {
|
|
console.warn('[permission-auto-accept] failed to load policy:', error?.message ?? error);
|
|
});
|
|
return () => {
|
|
unsubscribeEvent();
|
|
unsubscribeStatus();
|
|
};
|
|
};
|
|
|
|
return {
|
|
snapshot,
|
|
load,
|
|
setSessionPolicy,
|
|
isSessionAutoAccepting,
|
|
processPermission,
|
|
reconcilePending,
|
|
start,
|
|
};
|
|
}
|
|
|
|
export function registerPermissionAutoAcceptRoutes(app, runtime) {
|
|
app.get('/api/permission-auto-accept', async (_req, res) => {
|
|
try {
|
|
res.json(await runtime.load());
|
|
} catch (error) {
|
|
res.status(500).json({ error: error?.message ?? 'Failed to load permission auto-accept policy' });
|
|
}
|
|
});
|
|
|
|
app.put('/api/permission-auto-accept/sessions/:sessionId', async (req, res) => {
|
|
try {
|
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory : undefined;
|
|
res.json(await runtime.setSessionPolicy(req.params.sessionId, req.body?.enabled, directory));
|
|
} catch (error) {
|
|
res.status(error instanceof TypeError ? 400 : 500).json({ error: error?.message });
|
|
}
|
|
});
|
|
}
|