import type * as plugins from './plugins.js'; import { controllerRuntimeIdKey, type TControllerSessionId } from '../ts_interfaces/index.js'; import type { IControllerResourceAttachmentEntryDocument, IControllerResourceDocument, } from './interfaces.projects.js'; /** * Reading helpers for a resource's attachment set. * * A resource may be attached to several subjects at once and every attached subject may use it. * Concurrent use is serialized by the attachment revision each caller observed and by browser view * ownership, not prevented: a second chat attaching does not take the resource away from the * first. Every gate therefore asks "does the set contain an entry matching my subject", which is * exactly as strong per subject as the single-slot equality it replaces. */ export const resourceAttachmentEntryKey = ( entryArg: IControllerResourceAttachmentEntryDocument, ): string => ( entryArg.kind === 'session' ? `session:${controllerRuntimeIdKey(entryArg.id as TControllerSessionId)}` : `terminal:${entryArg.id as string}` ); export const cloneResourceAttachmentEntry = ( entryArg: IControllerResourceAttachmentEntryDocument, ): IControllerResourceAttachmentEntryDocument => ({ ...entryArg, id: entryArg.kind === 'session' ? { ...(entryArg.id as TControllerSessionId) } : (entryArg.id as string), attachedAt: new Date(entryArg.attachedAt), }); export const isSessionAttachmentEntry = ( entryArg: IControllerResourceAttachmentEntryDocument, ): entryArg is IControllerResourceAttachmentEntryDocument & { id: TControllerSessionId } => entryArg.kind === 'session'; export const resourceSessionEntries = ( resourceArg: Pick, ): Array => resourceArg.attachments.filter(isSessionAttachmentEntry); export const resourceTerminalEntries = ( resourceArg: Pick, ): IControllerResourceAttachmentEntryDocument[] => resourceArg.attachments.filter((entry) => entry.kind === 'terminal'); export const isResourceAttached = ( resourceArg: Pick, ): boolean => resourceArg.attachments.length > 0; /** The entry for one conversation, ignoring its exact identity. */ export const findSessionAttachment = ( resourceArg: Pick, sessionIdArg: TControllerSessionId, ): (IControllerResourceAttachmentEntryDocument & { id: TControllerSessionId }) | undefined => { const key = controllerRuntimeIdKey(sessionIdArg); return resourceSessionEntries(resourceArg) .find((entry) => controllerRuntimeIdKey(entry.id) === key); }; /** The entry for one conversation at its exact managed identity, which is what the gates need. */ export const hasExactSessionAttachment = ( resourceArg: Pick, sessionIdArg: TControllerSessionId, sessionIdentityIdArg: string | undefined, ): boolean => { const entry = findSessionAttachment(resourceArg, sessionIdArg); return entry !== undefined && sessionIdentityIdArg !== undefined && entry.sessionIdentityId === sessionIdentityIdArg; }; /** The entry for one terminal conversation: the terminal *and* the conversation must match. */ export const hasExactTerminalAttachment = ( resourceArg: Pick, resourceIdArg: string, agentSessionIdArg: string | undefined, ): boolean => resourceTerminalEntries(resourceArg).some((entry) => ( entry.id === resourceIdArg && entry.agentSessionId === agentSessionIdArg )); /** * The conversations attached to a resource as BrowserRuntime names them, which is what its * attachment binding is built from and what every acting identity is checked against. * * Every session entry is its own conversation. A terminal entry contributes the conversation that * *owns* the terminal — the coding agent's own chat, a `claude` session — and never the terminal * itself, which is a process rather than a conversation; a plain shell terminal names no * conversation and contributes nothing. The result is de-duplicated, because a conversation * attached both directly and through its own terminal, or through two of its terminals, is one * member and the runtime rejects a repeated member outright. */ export const resourceBrowserSessionIds = ( resourceArg: Pick, ): plugins.browserRuntime.TQualifiedBrowserSessionId[] => { const byKey = new Map(); for (const entry of resourceSessionEntries(resourceArg)) { byKey.set(`${entry.id.harnessId}:${entry.id.nativeId}`, { harnessId: entry.id.harnessId, nativeId: entry.id.nativeId, }); } for (const entry of resourceTerminalEntries(resourceArg)) { if (entry.agentSessionId === undefined) continue; byKey.set(`claude:${entry.agentSessionId}`, { harnessId: 'claude', nativeId: entry.agentSessionId, }); } return [...byKey.values()]; }; /** * Whether an acting conversation is a member of that set, asked against the exact projection the * runtime was given so a gate and the binding can never disagree. A `claude` acting id is a * terminal's own conversation and is therefore matched by the terminal entry carrying it. */ export const hasBrowserSessionMember = ( resourceArg: Pick, sessionIdArg: plugins.browserRuntime.TQualifiedBrowserSessionId, ): boolean => resourceBrowserSessionIds(resourceArg).some((candidate) => ( candidate.harnessId === sessionIdArg.harnessId && candidate.nativeId === sessionIdArg.nativeId )); export const hasSessionAttachmentOfHarness = ( resourceArg: Pick, harnessIdArg: TControllerSessionId['harnessId'], ): boolean => resourceSessionEntries(resourceArg) .some((entry) => entry.id.harnessId === harnessIdArg); export const withoutResourceAttachment = ( resourceArg: Pick, entryKeyArg: string, ): IControllerResourceAttachmentEntryDocument[] => resourceArg.attachments .filter((entry) => resourceAttachmentEntryKey(entry) !== entryKeyArg); /** * Applies a committed attachment intent to a set. `add` is idempotent — re-attaching a subject * that is already present refreshes nothing and changes nothing — and `replace` removes and adds * in one step so a Move never leaves the resource momentarily unattached. */ export const applyResourceAttachmentIntent = ( attachmentsArg: readonly IControllerResourceAttachmentEntryDocument[], pendingArg: { op: 'add' | 'remove' | 'replace'; entry?: IControllerResourceAttachmentEntryDocument; replaces?: IControllerResourceAttachmentEntryDocument; }, ): IControllerResourceAttachmentEntryDocument[] => { const cloned = attachmentsArg.map(cloneResourceAttachmentEntry); if (pendingArg.op === 'remove') { if (pendingArg.entry === undefined) return []; const removedKey = resourceAttachmentEntryKey(pendingArg.entry); return cloned.filter((entry) => resourceAttachmentEntryKey(entry) !== removedKey); } const entry = pendingArg.entry; if (entry === undefined) return cloned; const addedKey = resourceAttachmentEntryKey(entry); const supersededKey = pendingArg.replaces === undefined ? undefined : resourceAttachmentEntryKey(pendingArg.replaces); const retained = cloned.filter((candidate) => { const key = resourceAttachmentEntryKey(candidate); return key !== addedKey && key !== supersededKey; }); return [...retained, cloneResourceAttachmentEntry(entry)]; };