fix: automation runs appear in creator activity (#128951)

* fix: attribute automation sessions to creators

* test: prove cron session creator isolation

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

---------

Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
RoboClaw
2026-08-24 19:37:12 -07:00
committed by GitHub
parent 110b7474b4
commit dca05ea39a
20 changed files with 287 additions and 21 deletions
@@ -34,7 +34,7 @@ import {
runEmbeddedAgentMock,
} from "./isolated-agent/run.test-harness.js";
import { normalizeCronJobCreate } from "./normalize.js";
import type { CronJob } from "./types.js";
import type { CronJob, CronStoredJob } from "./types.js";
setupRunCronIsolatedAgentTurnSuite();
@@ -277,6 +277,40 @@ describe("runCronIsolatedAgentTurn session identity", () => {
});
});
it("attributes stable and exact run sessions to the stored automation creator", async () => {
await useRealCronSessionState();
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
mockEmbeddedTranscriptWrite(storePath, "creator-attributed transcript");
const job: CronStoredJob = {
...makeJob({ kind: "agentTurn", message: "persist this turn" }),
createdActor: { type: "human", id: "profile-ada" },
delivery: { mode: "none" },
};
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps: makeDeps(),
job,
message: "persist this turn",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status, res.status === "error" ? res.error : undefined).toBe("ok");
await expect(readCronSessionEntry(storePath, "agent:main:cron:job-1")).resolves.toMatchObject(
{
createdVia: "cron",
createdActor: { type: "human", id: "profile-ada" },
},
);
await expect(readCronSessionEntry(storePath, res.sessionKey!)).resolves.toMatchObject({
createdVia: "cron",
createdActor: { type: "human", id: "profile-ada" },
});
});
});
it("persists rotated transcript identity for current-bound cron runs", async () => {
await withTempHome(async (home) => {
const deps = makeDeps();
+2
View File
@@ -311,6 +311,7 @@ export async function prepareCronRunContext(params: {
const persistSessionEntry = createPersistCronSessionEntry({
cronSession,
agentSessionKey,
createdActor: input.job.createdActor,
persistSessionEntry: persistCronSessionRow,
});
const withRunSession: WithRunSession = (result) => ({
@@ -656,6 +657,7 @@ export async function prepareCronRunContext(params: {
? createCronRunContinuationSession({
cronSession,
runSessionKey,
createdActor: input.job.createdActor,
thinkingLevel: requestedThinkLevel,
toolsAllow: agentPayload?.toolsAllow,
toolsAllowIsDefault: agentPayload?.toolsAllowIsDefault,
@@ -268,6 +268,7 @@ describe("createPersistCronSessionEntry", () => {
const continuation = createCronRunContinuationSession({
cronSession,
runSessionKey,
createdActor: { type: "human", id: "profile-ada" },
thinkingLevel: "high",
toolsAllow: ["image_generate", "write"],
toolsAllowIsDefault: true,
@@ -301,7 +302,7 @@ describe("createPersistCronSessionEntry", () => {
});
expect(store[runSessionKey]).toMatchObject({
createdVia: "cron",
createdActor: { type: "system" },
createdActor: { type: "human", id: "profile-ada" },
createdAt: expect.any(Number),
sessionId: "run-session-id",
modelProvider: "claude-cli",
@@ -385,6 +386,7 @@ describe("createPersistCronSessionEntry", () => {
const persist = createPersistCronSessionEntry({
cronSession,
agentSessionKey: "agent:main:cron:job",
createdActor: { type: "human", id: "profile-ada" },
persistSessionEntry,
});
@@ -392,12 +394,12 @@ describe("createPersistCronSessionEntry", () => {
expect(cronSession.store["agent:main:cron:job"]).toMatchObject({
createdVia: "cron",
createdActor: { type: "system" },
createdActor: { type: "human", id: "profile-ada" },
createdAt: expect.any(Number),
});
expect(persistedStore["agent:main:cron:job"]).toMatchObject({
createdVia: "cron",
createdActor: { type: "system" },
createdActor: { type: "human", id: "profile-ada" },
createdAt: expect.any(Number),
});
expect(cronSession.store["agent:main:cron:job:run:run-session-id"]).toBeUndefined();
+8 -2
View File
@@ -7,6 +7,7 @@ import type { SessionEntry } from "../../config/sessions.js";
import { resolveSessionAuthProfileOverrideSource } from "../../config/sessions/auth-profile-override-provenance.js";
import { readTranscriptStatsSync } from "../../config/sessions/session-accessor.js";
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import type { SessionCreatedActor } from "../../config/sessions/session-entry-provenance.js";
import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
import { isCronSessionKey } from "../../sessions/session-key-utils.js";
import { isSessionWorkAdmissionActive } from "../../sessions/session-lifecycle-admission.js";
@@ -132,6 +133,7 @@ function toNonResumableCronSessionEntry(entry: SessionEntry): SessionEntry {
export function createPersistCronSessionEntry(params: {
cronSession: MutableCronSession;
agentSessionKey: string;
createdActor?: SessionCreatedActor;
persistSessionEntry: PersistSessionEntry;
}): PersistCronSessionEntry {
return async () => {
@@ -158,7 +160,7 @@ export function createPersistCronSessionEntry(params: {
if (!currentEntry) {
const creationStamp = buildSessionCreationStamp({
via: "cron",
actor: { type: "system" },
actor: params.createdActor ?? { type: "system" },
});
committedEntry = { ...persistedEntry, ...creationStamp };
mergedLiveEntry = { ...liveEntry, ...creationStamp };
@@ -238,6 +240,7 @@ export function createPersistCronSessionEntry(params: {
export function createCronRunContinuationSession(params: {
cronSession: MutableCronSession;
runSessionKey: string;
createdActor?: SessionCreatedActor;
thinkingLevel?: string;
toolsAllow?: string[];
toolsAllowIsDefault?: boolean;
@@ -303,7 +306,10 @@ export function createCronRunContinuationSession(params: {
...current,
...source,
...(!current
? buildSessionCreationStamp({ via: "cron", actor: { type: "system" } })
? buildSessionCreationStamp({
via: "cron",
actor: params.createdActor ?? { type: "system" },
})
: {}),
...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}),
cronRunContinuation: {
+10
View File
@@ -63,6 +63,16 @@ describe("toPublicCronJob", () => {
});
});
it("strips private creator provenance without mutating the stored job", () => {
const job: CronStoredJob = {
...makeCronJob({}),
createdActor: { type: "human", id: "profile-ada" },
};
expect(toPublicCronJob(job)).not.toHaveProperty("createdActor");
expect(job.createdActor).toEqual({ type: "human", id: "profile-ada" });
});
it("strips private runtime authority without mutating the stored job", () => {
const runtimeAuthority = {
version: 1 as const,
+1
View File
@@ -3,6 +3,7 @@ import type { CronJob, CronStoredJob } from "./types.js";
/** Remove scheduler-only state before a cron job crosses a public API boundary. */
export function toPublicCronJob(job: CronStoredJob): CronJob {
const {
createdActor: _createdActor,
toolsAllowProvenance: _toolsAllowProvenance,
runtimeAuthority: _runtimeAuthority,
runtimeAuthorityRecoveryRequired: _runtimeAuthorityRecoveryRequired,
+36
View File
@@ -198,6 +198,42 @@ describe("CronService declarative jobs", () => {
}
});
it("keeps the first creator across declaration convergence and restart", async () => {
const { storePath } = await makeStorePath();
const writer = createCronService(storePath);
await writer.start();
let createdId = "";
try {
const created = declarativeResult(
await writer.add(declaration(), {
createdActor: { type: "human", id: "profile-ada" },
}),
);
createdId = created.id;
expect(created.job).toMatchObject({
createdActor: { type: "human", id: "profile-ada" },
});
const converged = declarativeResult(
await writer.add(declaration({ displayName: "Updated report" }), {
createdActor: { type: "human", id: "profile-bob" },
}),
);
expect(converged).toMatchObject({ created: false, updated: true, id: created.id });
expect(converged.job).toMatchObject({
createdActor: { type: "human", id: "profile-ada" },
});
} finally {
writer.stop();
}
const reader = createCronService(storePath, false);
await expect(reader.readJob(createdId)).resolves.toMatchObject({
createdActor: { type: "human", id: "profile-ada" },
});
});
it("keeps declaration-key uniqueness local to the caller visibility predicate", async () => {
const { storePath } = await makeStorePath();
const cron = createCronService(storePath);
+3
View File
@@ -433,6 +433,9 @@ export async function add(
toolsAllowProvenance: opts?.toolsAllowProvenance,
configuredChannels,
});
if (opts?.createdActor) {
job.createdActor = structuredClone(opts.createdActor);
}
const runtimeAuthorityMutation = consumeRuntimeAuthorityMutationOptions(opts);
reconcileRuntimeAuthority({
job,
+23 -1
View File
@@ -1,6 +1,7 @@
// Cron service state tests cover in-memory scheduler state transitions.
import { describe, expect, it, vi } from "vitest";
import { createCronServiceState } from "./state.js";
import { makeCronJob } from "../delivery.test-helpers.js";
import { createCronServiceState, emit } from "./state.js";
describe("cron service state seam coverage", () => {
it("threads heartbeat and session-store dependencies into internal state", () => {
@@ -69,4 +70,25 @@ describe("cron service state seam coverage", () => {
nowSpy.mockRestore();
});
it("projects store-private job provenance before emitting events", () => {
const onEvent = vi.fn();
const state = createCronServiceState({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
storePath: "/tmp/cron/jobs.json",
cronEnabled: false,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
onEvent,
});
const job = {
...makeCronJob({}),
createdActor: { type: "human" as const, id: "profile-ada" },
};
emit(state, { action: "added", jobId: job.id, job });
expect(onEvent.mock.calls[0]?.[0]?.job).not.toHaveProperty("createdActor");
});
});
+7 -2
View File
@@ -3,12 +3,14 @@
import type { AdmittedRunContext } from "../../agents/admitted-run-context.js";
import type { ExecutionIdentityAdmissionFacts } from "../../audit/execution-identity-admission.js";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { SessionCreatedActor } from "../../config/sessions/session-entry-provenance.js";
import type { CronConfig } from "../../config/types.cron.js";
import type { HeartbeatRunResult, HeartbeatWakeRequest } from "../../infra/heartbeat-wake.js";
import type { CommandLaneTaskMarker } from "../../process/command-queue.js";
import { LEGACY_IMPLICIT_AGENT_ID } from "../../routing/session-key.js";
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { CronActiveJobMarker } from "../active-jobs.js";
import { toPublicCronJob } from "../public-job.js";
import type { CronRuntimeAuthority } from "../runtime-authority.js";
import type { CronScheduledToolPolicy } from "../scheduled-tool-policy.js";
import type { QuarantinedCronConfigJob } from "../store.js";
@@ -370,10 +372,11 @@ export function createCronServiceState(deps: CronServiceDeps): CronServiceState
/** Dispatches a cron event without letting subscriber errors escape scheduler work. */
export function emit(state: CronServiceState, evt: CronEvent, context?: CronEventContext) {
try {
const publicEvent = evt.job ? { ...evt, job: toPublicCronJob(evt.job) } : evt;
if (context) {
state.deps.onEvent?.(evt, context);
state.deps.onEvent?.(publicEvent, context);
} else {
state.deps.onEvent?.(evt);
state.deps.onEvent?.(publicEvent);
}
} catch {
/* ignore */
@@ -438,6 +441,8 @@ export type CronAddOptions = {
enabledExplicit?: boolean;
/** Gateway/doctor-owned heartbeat jobs require this opt-in at service creation. */
systemOwned?: boolean;
/** Trusted creator provenance persisted with new jobs; never accepted from public input. */
createdActor?: SessionCreatedActor;
/** Authenticated caller provenance stamped by the service, never public input. */
scheduledToolPolicy?: CronScheduledToolPolicy;
/** Private proof from an authenticated agent-runtime caller. */
+19
View File
@@ -3,6 +3,7 @@ import type { DatabaseSync } from "node:sqlite";
import { safeParseJson } from "@openclaw/normalization-core";
import { asOptionalObjectRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { SessionCreatedActor } from "../../config/sessions/session-entry-provenance.js";
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
@@ -289,6 +290,22 @@ function pacingFromJobJson(jobJson: Record<string, unknown>): CronPacing | undef
};
}
function createdActorFromJobJson(value: unknown): SessionCreatedActor | undefined {
if (
!isRecord(value) ||
(value.type !== "human" && value.type !== "agent" && value.type !== "system")
) {
return undefined;
}
const id = normalizeOptionalString(typeof value.id === "string" ? value.id : undefined);
const label = normalizeOptionalString(typeof value.label === "string" ? value.label : undefined);
return {
type: value.type,
...(id ? { id } : {}),
...(label ? { label } : {}),
};
}
function rowToCronJob(row: CronJobRow, jobJson: Record<string, unknown>): CronStoredJob | null {
const jsonOwner = isRecord(jobJson.owner) ? jobJson.owner : undefined;
const ownerAccountId = normalizeOptionalAccountId(
@@ -300,6 +317,7 @@ function rowToCronJob(row: CronJobRow, jobJson: Record<string, unknown>): CronSt
const failureAlert = failureAlertFromRow(row);
const trigger = triggerFromRow(row);
const pacing = pacingFromJobJson(jobJson);
const createdActor = createdActorFromJobJson(jobJson.createdActor);
const scheduledToolPolicy = normalizeCronScheduledToolPolicy(jobJson.scheduledToolPolicy);
const toolsAllowProvenance =
isRecord(jobJson.toolsAllowProvenance) &&
@@ -319,6 +337,7 @@ function rowToCronJob(row: CronJobRow, jobJson: Record<string, unknown>): CronSt
const createdAtMs = normalizeNumber(row.created_at_ms) ?? Date.now();
return {
id: row.job_id,
...(createdActor ? { createdActor } : {}),
...(row.declaration_key ? { declarationKey: row.declaration_key } : {}),
...(row.display_name ? { displayName: row.display_name } : {}),
...(row.owner_agent_id || row.owner_session_key || ownerAccountId
+3
View File
@@ -2,6 +2,7 @@ import type { EmbeddedAgentExecutionPhase } from "../agents/embedded-agent-runne
/** Cron scheduling, delivery, diagnostics, and store data contracts. */
import type { FailoverReason } from "../agents/failover/signal.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import type { SessionCreatedActor } from "../config/sessions/session-entry-provenance.js";
import type { HookExternalContentSource } from "../security/external-content.js";
import type { CronRuntimeAuthority } from "./runtime-authority.js";
import type {
@@ -523,6 +524,8 @@ export type CronToolsAllowProvenance = {
/** Persisted row shape; public Gateway and wire contracts use CronJob. */
export type CronStoredJob = CronJob & {
/** Immutable creator provenance stamped by the trusted cron creation seam. */
createdActor?: SessionCreatedActor;
toolsAllowProvenance?: CronToolsAllowProvenance;
/** Runtime-private authority omitted from public Gateway and wire contracts. */
runtimeAuthority?: CronRuntimeAuthority;
+3
View File
@@ -79,6 +79,7 @@ import {
import { isCronInvalidRequestError } from "./cron-error-classification.js";
import { listCronPageForCallerScope } from "./cron-list-caller-scope.js";
import { cronRunLogPageFilters, filterCronRunLogJobsByAgent } from "./cron-run-log-filters.js";
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
import type {
GatewayClient,
GatewayRequestContext,
@@ -848,6 +849,7 @@ export const cronHandlers: GatewayRequestHandlers = {
return;
}
const callerScope = readCronCallerScope(client);
const createdActor = resolveOperatorSessionCreation(client, { allowTrustedHint: true }).actor;
let captureRuntimeAuthority: (() => CronRuntimeAuthority | undefined) | undefined;
try {
captureRuntimeAuthority = resolveCronCreatorAuthorityCapture(callerScope);
@@ -908,6 +910,7 @@ export const cronHandlers: GatewayRequestHandlers = {
try {
result = await context.cron.add(jobCreate, {
enabledExplicit,
...(createdActor ? { createdActor } : {}),
...(commitGuard ? { commitGuard } : {}),
...(captureRuntimeAuthority ? { captureRuntimeAuthority } : {}),
matchesExisting: (job) =>
@@ -1368,6 +1368,36 @@ describe("cron method validation", () => {
expectCronSuccess(respond);
});
it("stamps the authenticated profile as private cron creator provenance", async () => {
const client: GatewayClient = {
connect: {} as GatewayClient["connect"],
authenticatedUserProfile: {
profileId: "profile-ada",
displayName: "Ada",
hasAvatar: false,
updatedAt: 1,
},
};
const { context, respond } = await invokeCronAdd(agentTurnCronParams(), { client });
const options = requireRecord(context.cron.add.mock.calls[0]?.[1], "cron.add options");
expect(options.createdActor).toEqual({ type: "human", id: "profile-ada" });
expect(requireCronAddPayload(context)).not.toHaveProperty("createdActor");
expectCronSuccess(respond);
});
it("rejects caller-supplied cron creator provenance", async () => {
const { context, respond } = await invokeCronAdd(
agentTurnCronParams({
createdActor: { type: "human", id: "spoofed-profile" },
}),
);
expect(context.cron.add).not.toHaveBeenCalled();
expectResponseError(respond, { code: "INVALID_REQUEST" });
});
it("consumes an exact live configured-MCP grant once at cron.add commit", async () => {
const scope = createCronCreatorAuthorityRunScope("run-add");
const grant = mintCronCreatorAuthorityGrant(scope);
+24 -10
View File
@@ -380,18 +380,20 @@ describe("session sharing policy", () => {
});
});
it("hides foreign sessions with none access across listings, direct reads, mutation, and broadcasts", async () => {
it("hides foreign cron sessions with none access across listings, reads, mutations, and broadcasts", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
const cfg = rolePolicyConfig();
const creator = roleClient("none", "cron-creator");
const creatorId = creator.authenticatedUserProfile!.profileId;
const restricted = roleClient("none", "restricted");
const restrictedId = restricted.authenticatedUserProfile!.profileId;
const foreignKey = "agent:main:team-private";
const foreignKey = "agent:main:cron:job-1:run:run-1";
const ownKey = "agent:main:team-own";
const foreignEntry = {
sessionId: "session-team-private",
sessionId: "session-cron-run",
updatedAt: 1,
visibility: "shared" as const,
createdActor: { type: "human" as const, id: "another-profile" },
createdVia: "cron" as const,
createdActor: { type: "human" as const, id: creatorId },
};
await upsertSessionEntryCore({ agentId: "main", sessionKey: foreignKey }, foreignEntry);
await upsertSessionEntryCore(
@@ -405,15 +407,13 @@ describe("session sharing policy", () => {
);
addSessionMember(
{ agentId: "main", sessionKey: foreignKey },
{
identityId: restrictedId,
addedBy: "another-profile",
expectedSessionId: "session-team-private",
},
{ identityId: restrictedId, addedBy: creatorId, expectedSessionId: foreignEntry.sessionId },
);
const entryFilter = createSessionListEntryFilter({ cfg, client: restricted });
const creatorEntryFilter = createSessionListEntryFilter({ cfg, client: creator });
expect(entryFilter?.(foreignKey, foreignEntry)).toBe(false);
expect(creatorEntryFilter?.(foreignKey, foreignEntry)).toBe(true);
expect(
entryFilter?.(ownKey, {
sessionId: "session-team-own",
@@ -424,6 +424,9 @@ describe("session sharing policy", () => {
expect(
canReceiveSessionEvent({ cfg, client: restricted as never, sessionKeys: [foreignKey] }),
).toBe(false);
expect(
canReceiveSessionEvent({ cfg, client: creator as never, sessionKeys: [foreignKey] }),
).toBe(true);
expect(
canReceiveSessionEvent({ cfg, client: restricted as never, sessionKeys: [ownKey] }),
).toBe(true);
@@ -471,6 +474,17 @@ describe("session sharing policy", () => {
message: `Session "${foreignKey}" was not found.`,
});
}
for (const method of ["chat.history", "chat.send"] as const) {
expect(
resolveSessionMutationAuthorization({
client: creator,
method,
requestParams: { sessionKey: foreignKey },
context,
}).error,
`creator ${method}`,
).toBeNull();
}
expect(
resolveSessionMutationAuthorization({
client: restricted,
@@ -120,6 +120,7 @@ suite.define(() => {
owner: {
actor: { type: "human", id: "profile-bob", label: "Bob Rivera" },
},
createdVia: "cron",
participants: [{ type: "human", id: "profile-alice", label: "Alice Chen" }],
hasAutomation: true,
updatedAt: now - 42 * 60_000,
@@ -133,6 +134,7 @@ suite.define(() => {
owner: {
actor: { type: "human", id: "profile-carol", label: "Carol Singh" },
},
createdVia: "cron",
hasAutomation: true,
updatedAt: now - 2 * 60 * 60_000,
},
@@ -145,6 +147,7 @@ suite.define(() => {
owner: {
actor: { type: "human", id: "profile-carol", label: "Carol Singh" },
},
createdVia: "cron",
hasAutomation: true,
updatedAt: now - 3 * 60 * 60_000,
},
@@ -319,6 +322,26 @@ suite.define(() => {
path: path.join(outputDir, "06-person-activity.png"),
});
await activityPage.locator(".activity-feed__people-clear").click();
await expect.poll(() => new URL(page.url()).searchParams.get("person")).toBeNull();
await activityPage.locator(".activity-feed__people-trigger").click();
await activityPage.locator('[data-activity-person="profile-carol"]').click();
await expect
.poll(() => new URL(page.url()).searchParams.get("person"))
.toBe("profile-carol");
await expect.poll(() => activitySession(nightlyMaintenanceKey).count()).toBe(1);
await expect
.poll(() =>
activitySession(nightlyMaintenanceKey)
.locator('[data-activity-created-via="cron"]')
.count(),
)
.toBe(1);
await page.screenshot({
animations: "disabled",
path: path.join(outputDir, "06-automation-creator-desktop.png"),
});
await activityPage.locator(".activity-feed__people-clear").click();
await expect.poll(() => new URL(page.url()).searchParams.get("person")).toBeNull();
await page.setViewportSize({ height: 844, width: 390 });
@@ -354,10 +377,19 @@ suite.define(() => {
};
}),
).toEqual({ backgroundColor: "rgba(0, 0, 0, 0)", borderTopWidth: "0px" });
await activityPage.locator(".activity-feed__people-trigger").click();
await activityPage.locator('[data-activity-person="profile-carol"]').click();
await expect
.poll(() => new URL(page.url()).searchParams.get("person"))
.toBe("profile-carol");
await expect.poll(() => activitySession(nightlyMaintenanceKey).count()).toBe(1);
await expect
.poll(() => activityFeed.locator('[data-activity-created-via="cron"]').count())
.toBe(2);
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(outputDir, "07-global-activity-mobile.png"),
path: path.join(outputDir, "07-automation-creator-mobile.png"),
});
},
);
+1
View File
@@ -3576,6 +3576,7 @@ export const en: TranslationMap = {
unknownDate: "Unknown date",
noSessions: "No sessions match these filters.",
automationGroup: "{count} automation sessions",
automation: "Automation",
inspectRun: "Inspect run",
backToSessions: "Back to sessions",
channelLabel: "Channel: {value}",
@@ -236,6 +236,40 @@ describe("session activity automation grouping", () => {
expect(container.querySelectorAll("[data-activity-session]")).toHaveLength(2);
}
});
it("labels only cron-origin sessions from their recorded creation provenance", () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderSessionActivityView(
props({
rows: [
row("Scheduled report", { id: "owner", label: "Owner" }, Date.now(), {
createdVia: "cron",
}),
row("Automation-bound chat", { id: "owner", label: "Owner" }, Date.now() - 1, {
hasAutomation: true,
}),
],
}),
),
container,
);
expect(
container
.querySelector(
'[data-activity-session="Scheduled report"] [data-activity-created-via="cron"]',
)
?.textContent?.trim(),
).toContain("Automation");
expect(
container.querySelector(
'[data-activity-session="Automation-bound chat"] [data-activity-created-via]',
),
).toBeNull();
});
});
describe("session activity live status", () => {
@@ -262,6 +262,7 @@ function renderSessionLink(context: ApplicationContext, row: GatewaySessionRow)
: row.agentId
? t("activityFeed.agentLabel", { value: row.agentId })
: null;
const source = row.createdVia === "cron" ? t("activityFeed.automation") : null;
return html`<div class="activity-feed__session-row">
<a
class="activity-feed__session"
@@ -291,7 +292,11 @@ function renderSessionLink(context: ApplicationContext, row: GatewaySessionRow)
data-health=${row.observerDigest?.health ?? nothing}
>${headline}</span
>`
: html`<span>${ownerName}</span>`}${scope
: html`<span>${ownerName}</span>`}${source
? html`<span class="activity-feed__session-source" data-activity-created-via="cron"
${source}${scope ? " ·" : ""}</span
>`
: nothing}${scope
? html`<span class="activity-feed__session-scope">${scope}</span>`
: nothing}
</span>
+4
View File
@@ -451,6 +451,10 @@ openclaw-activity-page {
white-space: nowrap;
}
.activity-feed__session-source {
white-space: nowrap;
}
.activity-feed__session-headline,
.activity-feed__session-owner {
min-width: 0;