diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index e7c0367b0..d06d4996a 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -2905,6 +2905,30 @@ text-overflow: ellipsis; white-space: nowrap; } + .environment-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin: 0 0 18px; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--warn) 42%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--warn) 8%, var(--surface)); + } + .environment-notice strong, + .environment-notice p { + display: block; + margin: 0; + } + .environment-notice p { + margin-top: 3px; + color: var(--muted); + font-size: 12px; + } + .environment-notice button { + flex: none; + } .governance-overview { margin: 0 0 22px; padding: 18px 20px; @@ -3987,6 +4011,13 @@

Governance

ScopeOrganization +

Effective state

@@ -5717,6 +5748,7 @@

Confirm governance change

}); let scopeDir = null; + let environmentDir = []; let scopeDirNote = "Loading scopes…"; async function loadScopeDirectory() { const r = await api("GET", "/api/scopes"); @@ -5728,6 +5760,7 @@

Confirm governance change

} viewLoadedAt.history = Date.now(); scopeDir = r.data.scopes || []; + environmentDir = r.data.environments || []; if (SCOPED.has(view) && !urlToState().session) { const memoryEditor = view === "memory" && !(orgWideView() && urlToState().mem !== "edit"); @@ -6244,6 +6277,19 @@

Confirm governance change

); } window.addEventListener("scroll", syncGovernanceSectionNav, { passive: true }); + function renderEnvironmentNotice(data) { + const notice = $("environment-notice"); + const attachment = data?.environmentAttachment; + notice.classList.toggle("hidden", !attachment); + if (!attachment) return; + const name = attachment.environmentName || shortName(attachment.environmentId); + $("environment-notice-title").textContent = "Uses named environment " + name; + $("environment-notice-detail").textContent = + "Computer files and working memory resolve to this environment. Governance and conversation history remain scoped here."; + $("environment-notice-open").textContent = "Open " + name; + $("environment-notice-open").onclick = () => + go({ view: "governance", scope: attachment.environmentId, session: null, page: 1 }); + } let governanceReq = 0; async function loadScope() { const requestedScope = scope; @@ -6259,6 +6305,7 @@

Confirm governance change

); return; } + renderEnvironmentNotice(r.data); renderGovernanceOverview(r.data); syncGovernanceSectionNav(); loadedCommandPolicyPresent = r.data.commandPolicy != null; @@ -11451,6 +11498,21 @@

Confirm governance change

actions: [sortControl], }); const activityTime = (s) => (scopeSort === "human" ? s.lastConversationActivity || 0 : s.lastActivity || 0); + if (environmentDir.length) { + const environments = denseList( + environmentDir, + (environment) => ({ + name: environment.name || shortName(environment.id), + preview: plural(environment.attachedScopes?.length || 0, "attached scope"), + href: stateToUrl({ view: "history", scope: environment.id, historyKind }), + }), + (environment) => selectScope(environment.id), + "No named environments.", + ); + root.appendChild( + dataCard("Named environments", "Named computers and working memory that scopes can share.", environments), + ); + } const t = denseList( activeRows, (s) => { diff --git a/plugins/admin/test/environments.test.ts b/plugins/admin/test/environments.test.ts new file mode 100644 index 000000000..3ec8c9fb8 --- /dev/null +++ b/plugins/admin/test/environments.test.ts @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const html = readFileSync(join(import.meta.dirname, "../public/index.html"), "utf8"); + +test("the admin UI lists named environments and links attachment warnings", () => { + assert.match(html, /Named environments/); + assert.match(html, /id="environment-notice"/); + assert.match(html, /Uses named environment/); + assert.match(html, /scope: attachment\.environmentId/); +}); diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts index de3ec466d..e633b9f65 100644 --- a/plugins/web-ui/src/chat.ts +++ b/plugins/web-ui/src/chat.ts @@ -12,6 +12,7 @@ import { Brain, Check, ChevronRight, + Clock3, Copy, FileImage, FileText, @@ -1538,7 +1539,7 @@ export function createChatSurface( async function refreshBackgroundDetail(): Promise { const id = chatState.sessionId; if (!id) { - bgPanel.detail = { jobs: [], watches: [] }; + bgPanel.detail = { jobs: [], watches: [], crons: [] }; return; } const seq = ++bgPanel.fetchSeq; @@ -1567,7 +1568,12 @@ export function createChatSurface( const row = sessionsState.list.find((r) => chatState.sessionId ? r.id === chatState.sessionId : r.threadRef === chatState.threadRef, ); - if (row && ((row.backgroundJobs ?? 0) !== d.jobs.length || (row.watches ?? 0) !== d.watches.length)) { + if ( + row && + ((row.backgroundJobs ?? 0) !== d.jobs.length || + (row.watches ?? 0) !== d.watches.length || + (row.crons ?? 0) !== d.crons.length) + ) { await refreshSessions({ silent: true }); redrawBackgroundPanel(); } @@ -1620,7 +1626,7 @@ export function createChatSurface( const row = conversationBackground(sessionsState.list, chatState.sessionId, chatState.threadRef); const live = bgPanel.open && bgPanel.detail - ? backgroundLabel(bgPanel.detail.jobs.length, bgPanel.detail.watches.length) + ? backgroundLabel(bgPanel.detail.jobs.length, bgPanel.detail.watches.length, bgPanel.detail.crons.length) : null; const label = (live ?? row)?.label; if (!label && !bgPanel.open) return nothing; @@ -1643,13 +1649,14 @@ export function createChatSurface( function backgroundPanelBody(): TemplateResult { const d = bgPanel.detail; - const empty = d && d.jobs.length === 0 && d.watches.length === 0; + const empty = d && d.jobs.length === 0 && d.watches.length === 0 && d.crons.length === 0; return html`
${bgPanel.error ? html`
${bgPanel.error}
` : nothing} ${!d && bgPanel.loading ? html`
Loading…
` : nothing} ${empty && !bgPanel.error ? html`
Nothing running here anymore.
` : nothing} ${d ? d.jobs.map((j) => backgroundJobRow(j)) : nothing} ${d ? d.watches.map((w) => backgroundWatchRow(w)) : nothing} + ${d ? d.crons.map((c) => backgroundCronRow(c)) : nothing}
`; } @@ -1679,6 +1686,26 @@ export function createChatSurface( `; } + function backgroundCronRow(c: SessionBackgroundView["crons"][number]): TemplateResult { + return html` +
+
+ ${icon(Clock3, 13)} + Cron — ${c.title ?? "scheduled task"} + ${c.nextFireAt ? `next fire ${nextFireIn(c.nextFireAt)}` : "paused"} +
+
+ `; + } + + function nextFireIn(at: number): string { + const mins = Math.round((at - Date.now()) / 60_000); + if (mins <= 0) return "due now"; + if (mins < 60) return `in ${mins}m`; + if (mins < 1440) return `in ${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, "0")}m`; + return `in ${Math.floor(mins / 1440)}d`; + } + function backgroundWatchRow(w: SessionBackgroundView["watches"][number]): TemplateResult { const what = w.pattern ? `output matching /${w.pattern}/` : "any new output"; const note = w.instructions?.trim(); diff --git a/plugins/web-ui/src/core-bridge.ts b/plugins/web-ui/src/core-bridge.ts index a7cef34db..2ab33e494 100644 --- a/plugins/web-ui/src/core-bridge.ts +++ b/plugins/web-ui/src/core-bridge.ts @@ -61,6 +61,7 @@ export interface CoreSession { awaitingInput?: boolean; backgroundJobs?: number; watches?: number; + crons?: number; forkedFrom?: { sessionId: string; title?: string | null }; forkBoundarySeq?: number; } @@ -137,6 +138,7 @@ export interface SessionBackgroundView { expiresAt: number; lastFiredAt?: number; }>; + crons: Array<{ id: string; title?: string; nextFireAt?: number }>; } export interface SessionBackgroundOutput { diff --git a/plugins/web-ui/src/session-list.ts b/plugins/web-ui/src/session-list.ts index ab3bb21e8..ead35ede0 100644 --- a/plugins/web-ui/src/session-list.ts +++ b/plugins/web-ui/src/session-list.ts @@ -167,17 +167,19 @@ export function applySessionState( export interface RowIndicators { working: boolean; awaiting: boolean; - background: { jobs: number; watches: number; label: string } | null; + background: { jobs: number; watches: number; crons: number; label: string } | null; } export function backgroundLabel( jobs: number, watches: number, -): { jobs: number; watches: number; label: string } | null { + crons: number, +): { jobs: number; watches: number; crons: number; label: string } | null { const parts: string[] = []; if (jobs > 0) parts.push(`${jobs} background job${jobs === 1 ? "" : "s"} running`); if (watches > 0) parts.push(`${watches} watch${watches === 1 ? "" : "es"} armed`); - return parts.length ? { jobs, watches, label: parts.join(" · ") } : null; + if (crons > 0) parts.push(`${crons} cron${crons === 1 ? "" : "s"} scheduled here`); + return parts.length ? { jobs, watches, crons, label: parts.join(" · ") } : null; } export function rowIndicators(s: CoreSession, liveThreads: ReadonlySet | string | null): RowIndicators { @@ -185,7 +187,7 @@ export function rowIndicators(s: CoreSession, liveThreads: ReadonlySet | return { working: Boolean(s.working) || (Boolean(s.threadRef) && live.has(s.threadRef)), awaiting: Boolean(s.awaitingInput), - background: backgroundLabel(s.backgroundJobs ?? 0, s.watches ?? 0), + background: backgroundLabel(s.backgroundJobs ?? 0, s.watches ?? 0, s.crons ?? 0), }; } diff --git a/plugins/web-ui/src/sessions.ts b/plugins/web-ui/src/sessions.ts index 738e164f0..55db60e59 100644 --- a/plugins/web-ui/src/sessions.ts +++ b/plugins/web-ui/src/sessions.ts @@ -8,6 +8,7 @@ import { ArchiveRestore, ChevronDown, ChevronRight, + Clock3, Cog, EllipsisVertical, Folder, @@ -591,7 +592,7 @@ function statusMarks(s: CoreSession): TemplateResult { @keydown=${(e: KeyboardEvent) => (e.key === "Enter" || e.key === " ") && openBackgroundInspector(e, s)} >${ind.background.jobs > 0 ? icon(Cog, 11) : nothing}${ ind.background.watches > 0 ? icon(Binoculars, 11) : nothing - } 0 ? icon(Clock3, 11) : nothing}` : nothing }`; diff --git a/plugins/web-ui/src/split.ts b/plugins/web-ui/src/split.ts index 90b8fecd3..a06c41cd9 100644 --- a/plugins/web-ui/src/split.ts +++ b/plugins/web-ui/src/split.ts @@ -1,6 +1,6 @@ import { html, nothing, render, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; -import { Binoculars, Cog, Expand, Maximize2, Plus, Shrink, X } from "lucide"; +import { Binoculars, Clock3, Cog, Expand, Maximize2, Plus, Shrink, X } from "lucide"; import { createDockview, type DockviewApi, @@ -823,7 +823,7 @@ class PaneTab implements ITabRenderer { @mouseleave=${(e: Event) => hideTooltip(e.currentTarget as Element)} >${background.jobs > 0 ? icon(Cog, 11) : nothing}${ background.watches > 0 ? icon(Binoculars, 11) : nothing - } 0 ? icon(Clock3, 11) : nothing}` : nothing } diff --git a/plugins/web-ui/test/session-list.test.ts b/plugins/web-ui/test/session-list.test.ts index cf2ad4f99..2c9cab5e4 100644 --- a/plugins/web-ui/test/session-list.test.ts +++ b/plugins/web-ui/test/session-list.test.ts @@ -363,15 +363,23 @@ test("rowIndicators: awaitingInput maps through", () => { assert.equal(rowIndicators({ ...saved("1", "web:u:x"), awaitingInput: true }, null).awaiting, true); }); -test("backgroundLabel: jobs and watches fold into one chip with a spoken label", () => { - assert.deepEqual(backgroundLabel(1, 0), { jobs: 1, watches: 0, label: "1 background job running" }); - assert.deepEqual(backgroundLabel(2, 1), { +test("backgroundLabel: jobs, watches and crons fold into one chip with a spoken label", () => { + assert.deepEqual(backgroundLabel(1, 0, 0), { jobs: 1, watches: 0, crons: 0, label: "1 background job running" }); + assert.deepEqual(backgroundLabel(2, 1, 0), { jobs: 2, watches: 1, + crons: 0, label: "2 background jobs running · 1 watch armed", }); - assert.deepEqual(backgroundLabel(0, 2), { jobs: 0, watches: 2, label: "2 watches armed" }); - assert.equal(backgroundLabel(0, 0), null, "nothing running, nothing to say"); + assert.deepEqual(backgroundLabel(0, 2, 0), { jobs: 0, watches: 2, crons: 0, label: "2 watches armed" }); + assert.deepEqual(backgroundLabel(0, 0, 1), { jobs: 0, watches: 0, crons: 1, label: "1 cron scheduled here" }); + assert.deepEqual(backgroundLabel(0, 1, 2), { + jobs: 0, + watches: 1, + crons: 2, + label: "1 watch armed · 2 crons scheduled here", + }); + assert.equal(backgroundLabel(0, 0, 0), null, "nothing running, nothing to say"); }); test("rowIndicators: background counts flow through backgroundLabel — zero counts treated as absent", () => { @@ -379,9 +387,15 @@ test("rowIndicators: background counts flow through backgroundLabel — zero cou assert.deepEqual(both.background, { jobs: 2, watches: 1, + crons: 0, label: "2 background jobs running · 1 watch armed", }); - assert.equal(rowIndicators({ ...saved("1", "web:u:x"), backgroundJobs: 0, watches: 0 }, null).background, null); + const cronOnly = rowIndicators({ ...saved("1", "web:u:x"), crons: 3 }, null); + assert.deepEqual(cronOnly.background, { jobs: 0, watches: 0, crons: 3, label: "3 crons scheduled here" }); + assert.equal( + rowIndicators({ ...saved("1", "web:u:x"), backgroundJobs: 0, watches: 0, crons: 0 }, null).background, + null, + ); assert.equal(rowIndicators(saved("1", "web:u:x"), null).background, null); }); @@ -390,13 +404,19 @@ test("conversationBackground: resolves the mounted conversation by session id", assert.deepEqual(conversationBackground(list, "2", null), { jobs: 2, watches: 1, + crons: 0, label: "2 background jobs running · 1 watch armed", }); }); test("conversationBackground: falls back to threadRef while the conversation is still pending adoption", () => { const list = [{ ...saved("1", "web:u:a"), watches: 1 }]; - assert.deepEqual(conversationBackground(list, null, "web:u:a"), { jobs: 0, watches: 1, label: "1 watch armed" }); + assert.deepEqual(conversationBackground(list, null, "web:u:a"), { + jobs: 0, + watches: 1, + crons: 0, + label: "1 watch armed", + }); }); test("conversationBackground: null when the conversation has nothing running, or isn't in the list", () => { diff --git a/src/api/app-sessions.ts b/src/api/app-sessions.ts index 6faaf7f23..e40c88df8 100644 --- a/src/api/app-sessions.ts +++ b/src/api/app-sessions.ts @@ -184,7 +184,18 @@ export function createSessionMethods( if (m.expiresAt <= now) continue; watchCounts.set(m.threadRef, (watchCounts.get(m.threadRef) ?? 0) + 1); } - if (workingThreadRefs.size === 0 && waiting.size === 0 && jobCounts.size === 0 && watchCounts.size === 0) + const cronCounts = new Map(); + for (const c of await deps.crons.list()) { + if (!c.enabled || c.archived || !c.destination) continue; + cronCounts.set(c.destination.target, (cronCounts.get(c.destination.target) ?? 0) + 1); + } + if ( + workingThreadRefs.size === 0 && + waiting.size === 0 && + jobCounts.size === 0 && + watchCounts.size === 0 && + cronCounts.size === 0 + ) return sessions; return sessions.map((s) => ({ ...s, @@ -192,6 +203,7 @@ export function createSessionMethods( ...(waiting.has(s.id) ? { awaitingInput: true } : {}), ...(jobCounts.has(s.threadRef) ? { backgroundJobs: jobCounts.get(s.threadRef)! } : {}), ...(watchCounts.has(s.threadRef) ? { watches: watchCounts.get(s.threadRef)! } : {}), + ...(cronCounts.has(s.threadRef) ? { crons: cronCounts.get(s.threadRef)! } : {}), })); }, @@ -216,7 +228,15 @@ export function createSessionMethods( expiresAt: m.expiresAt, ...(m.lastFiredAt !== undefined ? { lastFiredAt: m.lastFiredAt } : {}), })); - return { jobs, watches }; + const crons = (await deps.crons.list()) + .filter((c) => c.enabled && !c.archived && c.destination?.target === session.threadRef) + .sort((a, b) => b.createdAt - a.createdAt) + .map((c) => ({ + id: c.id, + ...(c.title !== undefined ? { title: c.title } : {}), + ...(c.nextFireAt !== undefined ? { nextFireAt: c.nextFireAt } : {}), + })); + return { jobs, watches, crons }; }, async readSessionBackgroundOutput(sessionId, processId, viewer, sinceCursor) { diff --git a/src/api/app-types.ts b/src/api/app-types.ts index 679567dc7..8ad649f40 100644 --- a/src/api/app-types.ts +++ b/src/api/app-types.ts @@ -199,6 +199,7 @@ interface SessionBackgroundView { expiresAt: number; lastFiredAt?: number; }>; + crons: Array<{ id: string; title?: string; nextFireAt?: number }>; } interface SessionBackgroundOutput { diff --git a/src/api/routes/admin/scope-config.ts b/src/api/routes/admin/scope-config.ts index 22acc4f7e..17d206168 100644 --- a/src/api/routes/admin/scope-config.ts +++ b/src/api/routes/admin/scope-config.ts @@ -132,10 +132,25 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { const crons = await app.listCrons(); const deployments = await app.listDeployments(); const skills = await app.listSkills(); + const environmentRows = await app.listEnvironments(); + const environments = environmentRows.map(({ environment, attachments }) => ({ + id: environment.id, + name: environment.name, + ownerActorId: environment.ownerActorId, + attachedScopes: attachments.map((attachment) => attachment.scopeId).sort(), + })); + const environmentById = new Map(environments.map((environment) => [environment.id, environment])); + const attachmentByScope = new Map( + environments.flatMap((environment) => + environment.attachedScopes.map((attachedScope) => [attachedScope, environment] as const), + ), + ); const owners = [ ...crons.map((c) => c.ownerScopeId), ...deployments.map((d) => d.ownerScopeId), ...skills.map((s) => s.scopeId), + ...environments.map((environment) => environment.id), + ...environments.flatMap((environment) => environment.attachedScopes), ]; const labels = await discoverScopes(app, deps, owners); const countBy = (ids: string[]): Map => { @@ -171,18 +186,31 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { const cronN = countBy(crons.map((c) => c.ownerScopeId)); const deployN = countBy(deployments.map((d) => d.ownerScopeId)); const skillN = countBy(skills.map((s) => s.scopeId)); - const scopes = [...labels].map(([id, label]) => ({ - scopeId: id, - ...(label ? { label } : {}), - sessions: sessionN.get(id) ?? 0, - backgroundSessions: backgroundN.get(id) ?? 0, - lastActivity: lastActivityBy.get(id) ?? 0, - lastConversationActivity: lastConversationBy.get(id) ?? 0, - lastMessage: lastMessageBy.get(id) ?? "", - crons: cronN.get(id) ?? 0, - deployments: deployN.get(id) ?? 0, - skills: skillN.get(id) ?? 0, - })); + const scopes = [...labels].map(([id, label]) => { + const environment = environmentById.get(id); + const attachment = attachmentByScope.get(id); + return { + scopeId: id, + ...(label ? { label } : {}), + ...(environment?.name ? { environmentName: environment.name } : {}), + ...(attachment + ? { + environmentAttachment: { + environmentId: attachment.id, + environmentName: attachment.name, + }, + } + : {}), + sessions: sessionN.get(id) ?? 0, + backgroundSessions: backgroundN.get(id) ?? 0, + lastActivity: lastActivityBy.get(id) ?? 0, + lastConversationActivity: lastConversationBy.get(id) ?? 0, + lastMessage: lastMessageBy.get(id) ?? "", + crons: cronN.get(id) ?? 0, + deployments: deployN.get(id) ?? 0, + skills: skillN.get(id) ?? 0, + }; + }); scopes.sort( (a, b) => b.lastActivity - a.lastActivity || @@ -190,7 +218,35 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { b.backgroundSessions - a.backgroundSessions || a.scopeId.localeCompare(b.scopeId), ); - return sendJson(res, 200, { scopeId: scope, scopes }); + return sendJson(res, 200, { scopeId: scope, scopes, environments }); +} + +interface ScopeEnvironmentMetadata { + environment?: { id: string; name: string; ownerActorId: string | null }; + environmentAttachment?: { environmentId: string; environmentName: string | null }; +} + +async function scopeEnvironmentMetadata(deps: ApiCtx["deps"], targetScope: string): Promise { + const store = deps.environments; + if (!store) return {}; + + const [environment, attachment] = await Promise.all([store.get(targetScope), store.getAttachment(targetScope)]); + const metadata: ScopeEnvironmentMetadata = {}; + if (environment?.name) { + metadata.environment = { + id: environment.id, + name: environment.name, + ownerActorId: environment.ownerActorId, + }; + } + if (attachment) { + const attachedEnvironment = await store.get(attachment.environmentId); + metadata.environmentAttachment = { + environmentId: attachment.environmentId, + environmentName: attachedEnvironment?.name ?? null, + }; + } + return metadata; } export async function getScopeConfig(ctx: ApiCtx): Promise { @@ -202,6 +258,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { if (!actor) return; await deps.config.refreshScope(targetScope); audit(deps, { principalId: actor.id, action: "config.read", resource: "config", scopeLabel: targetScope }); + const environmentMetadata = await scopeEnvironmentMetadata(deps, targetScope); const serviceCredentials = await Promise.all( (deps.serviceCreds ? await deps.serviceCreds.listServiceCredentials(targetScope) : []).map(async (c) => { const usage = (await deps.credentialUsage?.list({ slug: c.slug, limit: 5000 })) ?? []; @@ -261,6 +318,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { }; return sendJson(res, 200, { scopeId: targetScope, + ...environmentMetadata, ...values, soulVersion: deps.config.soulVersion(targetScope), soulHistory: deps.config.soulHistory(targetScope), diff --git a/src/types.ts b/src/types.ts index ab5319b37..d3f74551e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,6 +80,7 @@ export interface Session { awaitingInput?: boolean; backgroundJobs?: number; watches?: number; + crons?: number; } export type EntryType = diff --git a/test/admin-scopes-directory.test.ts b/test/admin-scopes-directory.test.ts index dcb280e50..b604750af 100644 --- a/test/admin-scopes-directory.test.ts +++ b/test/admin-scopes-directory.test.ts @@ -16,6 +16,8 @@ function start() { admin: built.admin, auditLog: built.auditLog, sessions: built.sessions, + config: built.config, + environments: built.environments, }); server.listen(0); const base = `http://localhost:${(server.address() as AddressInfo).port}`; @@ -118,3 +120,34 @@ test("scopes without a session label fall back to the org directory (people's na await s.close(); } }); + +test("the admin scope directory exposes named environments and attached scopes", async () => { + const s = start(); + try { + await s.built.app.upsertChannels([ + { channelId: "A", name: "source" }, + { channelId: "B", name: "attached" }, + ]); + await s.built.app.createEnvironment({ scopeId: "channel:A", name: "A-permanent", actorId: "U1" }); + await s.built.app.attachScope({ scopeId: "channel:B", environmentId: "channel:A", actorId: "U1" }); + + const directory = await json(await fetch(`${s.base}/v1/admin/scopes`, { headers: ALICE_ADMIN })); + const byId = new Map(directory.scopes.map((row: any) => [row.scopeId, row])); + assert.deepEqual(directory.environments, [ + { id: "channel:A", name: "A-permanent", ownerActorId: "U1", attachedScopes: ["channel:B"] }, + ]); + assert.equal((byId.get("channel:A") as any).environmentName, "A-permanent"); + assert.deepEqual((byId.get("channel:B") as any).environmentAttachment, { + environmentId: "channel:A", + environmentName: "A-permanent", + }); + + const attached = await json(await fetch(`${s.base}/v1/admin/scopes/channel:B`, { headers: ALICE_ADMIN })); + assert.deepEqual(attached.environmentAttachment, { + environmentId: "channel:A", + environmentName: "A-permanent", + }); + } finally { + await s.close(); + } +}); diff --git a/test/sessions-background-activity.test.ts b/test/sessions-background-activity.test.ts index 50b666cf8..36e1aa98a 100644 --- a/test/sessions-background-activity.test.ts +++ b/test/sessions-background-activity.test.ts @@ -116,3 +116,53 @@ test("readSessionBackgroundOutput binds the job to the conversation, not just th assert.equal(await app.readSessionBackgroundOutput(r2.sessionId!, "p-other", "U2", 0), null); assert.equal(await app.readSessionBackgroundOutput(r1.sessionId!, "p-ghost", "U1", 0), null); }); + +function cronInput(target: string | undefined, action: string) { + return { + ownerScopeId: "personal:U1" as const, + owner: "U1", + createdBy: "U1", + schedule: { everyMs: 60_000 }, + action, + ...(target ? { destination: { type: "web", target } } : {}), + }; +} + +test("listSessions counts session-targeted crons — enabled and unarchived only, keyed by destination target", async () => { + const { app, crons } = freshApp(); + const here = "web:U1:cronned"; + const elsewhere = "web:U1:plain"; + await app.turn(dm("watch my PR", here)); + await app.turn(dm("nothing scheduled", elsewhere)); + + await crons.create(cronInput(here, "check the pipeline")); + await crons.create(cronInput(here, "refresh the dashboard")); + const paused = await crons.create(cronInput(here, "poll the checks")); + await crons.setEnabled(paused.id, false); + const archived = await crons.create(cronInput(here, "sweep the queue")); + await crons.update(archived.id, { archived: true }); + await crons.create(cronInput(undefined, "cron with no destination")); + await crons.create(cronInput("slack:C123", "cron aimed elsewhere")); + + const list = await app.listSessions("U1"); + const hereRow = list.find((s) => s.threadRef === here); + const plainRow = list.find((s) => s.threadRef === elsewhere); + assert.equal(hereRow?.crons, 2, "enabled, unarchived crons aimed at this conversation"); + assert.equal(plainRow?.crons, undefined, "clean rows carry no zero-count fields"); +}); + +test("sessionBackground lists the session's crons alongside jobs and watches", async () => { + const { app, crons } = freshApp(); + const thread = "web:U1:cron-inspect"; + const r = await app.turn(dm("schedule it", thread)); + + const made = await crons.create({ ...cronInput(thread, "watch PR checks"), title: "PR watch" }); + const paused = await crons.create(cronInput(thread, "poll the checks")); + await crons.setEnabled(paused.id, false); + + const view = await app.sessionBackground(r.sessionId!, "U1"); + assert.equal(view?.crons.length, 1); + assert.equal(view?.crons[0]?.id, made.id); + assert.equal(view?.crons[0]?.title, "PR watch"); + assert.ok(view?.crons[0]?.nextFireAt, "carries the next fire time"); +});