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");
+});