diff --git a/src/cron/isolated-agent.session-identity.test.ts b/src/cron/isolated-agent.session-identity.test.ts index e110882feea7..9e29e10717e6 100644 --- a/src/cron/isolated-agent.session-identity.test.ts +++ b/src/cron/isolated-agent.session-identity.test.ts @@ -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(); diff --git a/src/cron/isolated-agent/run-prepare.ts b/src/cron/isolated-agent/run-prepare.ts index 07cf1a99c00a..6a5018f45a11 100644 --- a/src/cron/isolated-agent/run-prepare.ts +++ b/src/cron/isolated-agent/run-prepare.ts @@ -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, diff --git a/src/cron/isolated-agent/run-session-state.test.ts b/src/cron/isolated-agent/run-session-state.test.ts index 47458385478b..5bb1364b11b1 100644 --- a/src/cron/isolated-agent/run-session-state.test.ts +++ b/src/cron/isolated-agent/run-session-state.test.ts @@ -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(); diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts index a551aa1b01fd..2bfbecc6d572 100644 --- a/src/cron/isolated-agent/run-session-state.ts +++ b/src/cron/isolated-agent/run-session-state.ts @@ -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: { diff --git a/src/cron/public-job.test.ts b/src/cron/public-job.test.ts index 8f0b13fd91c3..59bbe58c3baa 100644 --- a/src/cron/public-job.test.ts +++ b/src/cron/public-job.test.ts @@ -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, diff --git a/src/cron/public-job.ts b/src/cron/public-job.ts index 1f9e8dbc22cf..5b8af3c23ad1 100644 --- a/src/cron/public-job.ts +++ b/src/cron/public-job.ts @@ -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, diff --git a/src/cron/service.declarative-jobs.test.ts b/src/cron/service.declarative-jobs.test.ts index 4d1a19e3f3d3..55b2653080c5 100644 --- a/src/cron/service.declarative-jobs.test.ts +++ b/src/cron/service.declarative-jobs.test.ts @@ -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); diff --git a/src/cron/service/ops-mutations.ts b/src/cron/service/ops-mutations.ts index 1d3459d7a2e8..593cd510bcf6 100644 --- a/src/cron/service/ops-mutations.ts +++ b/src/cron/service/ops-mutations.ts @@ -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, diff --git a/src/cron/service/state.test.ts b/src/cron/service/state.test.ts index 8f6d0ec18665..3a4d1b62196f 100644 --- a/src/cron/service/state.test.ts +++ b/src/cron/service/state.test.ts @@ -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"); + }); }); diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 4a5b2347d959..05262a1c5d7b 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -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. */ diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts index 2b3e49271fed..b81ac8e14a88 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -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): 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): CronStoredJob | null { const jsonOwner = isRecord(jobJson.owner) ? jobJson.owner : undefined; const ownerAccountId = normalizeOptionalAccountId( @@ -300,6 +317,7 @@ function rowToCronJob(row: CronJobRow, jobJson: Record): 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): 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 diff --git a/src/cron/types.ts b/src/cron/types.ts index 723a35e4833f..6d9bbf9e4c9b 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -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; diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 8146f9f919f0..4ed0883c828c 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -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) => diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index da985b59b3a4..57f392985d19 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -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); diff --git a/src/gateway/session-sharing.test.ts b/src/gateway/session-sharing.test.ts index ce0626183096..d2ed63f1f43e 100644 --- a/src/gateway/session-sharing.test.ts +++ b/src/gateway/session-sharing.test.ts @@ -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, diff --git a/ui/src/e2e/activity-session-feed.capture.e2e.test.ts b/ui/src/e2e/activity-session-feed.capture.e2e.test.ts index 6004bcbad05e..55776ceea084 100644 --- a/ui/src/e2e/activity-session-feed.capture.e2e.test.ts +++ b/ui/src/e2e/activity-session-feed.capture.e2e.test.ts @@ -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"), }); }, ); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 1da03422e200..4e56a0f7caa5 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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}", diff --git a/ui/src/pages/activity/session-activity-view.test.ts b/ui/src/pages/activity/session-activity-view.test.ts index 2f623472c127..0c2efdb54fdf 100644 --- a/ui/src/pages/activity/session-activity-view.test.ts +++ b/ui/src/pages/activity/session-activity-view.test.ts @@ -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", () => { diff --git a/ui/src/pages/activity/session-activity-view.ts b/ui/src/pages/activity/session-activity-view.ts index 80837395255c..687332162353 100644 --- a/ui/src/pages/activity/session-activity-view.ts +++ b/ui/src/pages/activity/session-activity-view.ts @@ -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`