diff --git a/apps/client/src/features/page-history/work-time/work-time-stat.tsx b/apps/client/src/features/page-history/work-time/work-time-stat.tsx index d59eb5fc..b963456f 100644 --- a/apps/client/src/features/page-history/work-time/work-time-stat.tsx +++ b/apps/client/src/features/page-history/work-time/work-time-stat.tsx @@ -14,17 +14,23 @@ interface Props { * #395 — the clickable "time worked on this article" headline (§6.1). Renders * the `work` estimate with a "≈" sign and the inactivity threshold in a tooltip * (it is an estimate, not a stopwatch). Clicking opens the daily punch-card - * (§6.2). Renders nothing until there is a non-zero estimate, so a brand-new / - * never-edited page shows no widget. + * (§6.2). Renders nothing until there is a non-zero human OR agent estimate, so a + * brand-new / never-edited page shows no widget. For an agent-only-edited page + * (workMs===0, agentOnlyMs>0) the headline shows the agent estimate (labelled + * `agent:`, matching the punch-card) so the punch-card stays reachable (#395: + * "how much a HUMAN and separately the AGENT"). */ export default function WorkTimeStat({ pageId }: Props) { const { t } = useTranslation(); const [opened, { open, close }] = useDisclosure(false); const { data } = usePageWorkTime(pageId); - if (!data || data.workMs <= 0) return null; + if (!data || (data.workMs <= 0 && data.agentOnlyMs <= 0)) return null; - const label = formatHeadline(data.workMs, t); + const agentOnly = data.workMs <= 0; + const label = agentOnly + ? t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) }) + : formatHeadline(data.workMs, t); const gapMin = formatGapMinutes(data.config.tGap); return ( diff --git a/apps/client/src/features/page-history/work-time/work-time.types.ts b/apps/client/src/features/page-history/work-time/work-time.types.ts index 761ebf9b..2ef5f114 100644 --- a/apps/client/src/features/page-history/work-time/work-time.types.ts +++ b/apps/client/src/features/page-history/work-time/work-time.types.ts @@ -3,12 +3,6 @@ export type WorkSessionClass = "work" | "agent_only"; -export interface IWorkSession { - start: number; - end: number; - class: WorkSessionClass; -} - export interface IDayWindow { start: number; end: number; @@ -37,7 +31,6 @@ export interface IWorkTimeConfig { export interface IPageWorkTime { workMs: number; agentOnlyMs: number; - sessions: IWorkSession[]; perDay: IPerDay[]; config: IWorkTimeConfig; tz: string; diff --git a/apps/server/src/core/page/page.controller.spec.ts b/apps/server/src/core/page/page.controller.spec.ts index e4640493..59d595ac 100644 --- a/apps/server/src/core/page/page.controller.spec.ts +++ b/apps/server/src/core/page/page.controller.spec.ts @@ -1,4 +1,8 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { PageController } from './page.controller'; // Direct instantiation with stub deps. The Test.createTestingModule form failed @@ -74,6 +78,23 @@ describe('PageController', () => { expect(out).toEqual({ workMs: 42 }); }); + it('propagates a denied view gate and does NOT reach compute (security)', async () => { + // If validateCanView is moved AFTER computeWorkTime, the timeline of a page + // the caller may not see would be read/estimated before the gate — this + // locks the order: a rejecting gate must short-circuit before any compute. + const validate = jest.fn().mockRejectedValue(new ForbiddenException()); + const compute = jest.fn().mockResolvedValue({ workMs: 1 }); + const { c, pageHistoryService } = build({ + page: { id: 'pg' }, + validate, + compute, + }); + await expect( + c.getPageWorkTime({ pageId: 'pg' } as any, user), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(pageHistoryService.computeWorkTime).not.toHaveBeenCalled(); + }); + it('maps an unknown-timezone RangeError to a 400', async () => { const compute = jest.fn().mockRejectedValue(new RangeError('bad tz')); const { c } = build({ page: { id: 'pg' }, compute }); diff --git a/apps/server/src/core/page/services/page-history.service.ts b/apps/server/src/core/page/services/page-history.service.ts index 518a62c3..66518987 100644 --- a/apps/server/src/core/page/services/page-history.service.ts +++ b/apps/server/src/core/page/services/page-history.service.ts @@ -9,13 +9,11 @@ import { DEFAULT_WORK_TIME_CONFIG, WorkTimeConfig, PerDay, - WorkSession, } from '../work-time'; export interface PageWorkTime { workMs: number; agentOnlyMs: number; - sessions: WorkSession[]; perDay: PerDay[]; /** the config actually used, so the UI can show "≈" + the T_gap threshold. */ config: WorkTimeConfig; @@ -60,11 +58,12 @@ export class PageHistoryService { const rows = await this.pageHistoryRepo.findTimelineByPageId(pageId); const result = computeWorkTime(rows, config); const usedConfig: WorkTimeConfig = { ...DEFAULT_WORK_TIME_CONFIG, ...config }; + // `bucketByDay` consumes the pure core's un-bucketed sessions here; the + // full session list is NOT shipped on the response (no client reads it). const perDay = bucketByDay(result.sessions, tz); return { workMs: result.workMs, agentOnlyMs: result.agentOnlyMs, - sessions: result.sessions, perDay, config: usedConfig, tz, diff --git a/apps/server/src/core/page/work-time/compute-work-time.spec.ts b/apps/server/src/core/page/work-time/compute-work-time.spec.ts index da73808e..62f89176 100644 --- a/apps/server/src/core/page/work-time/compute-work-time.spec.ts +++ b/apps/server/src/core/page/work-time/compute-work-time.spec.ts @@ -1,8 +1,33 @@ import { computeWorkTime } from './compute-work-time'; -import { TimelineSample } from './work-time.types'; +import { bucketByDay } from './bucket-by-day'; +import { TimelineSample, WorkSession } from './work-time.types'; const MIN = 60 * 1000; +/** Union wall-clock of a set of intervals (touching intervals merge). */ +function unionMs(intervals: Array<[number, number]>): number { + if (intervals.length === 0) return 0; + const sorted = [...intervals].sort((a, b) => a[0] - b[0]); + let total = 0; + let [cs, ce] = sorted[0]; + for (let i = 1; i < sorted.length; i++) { + const [s, e] = sorted[i]; + if (s <= ce) { + if (e > ce) ce = e; + } else { + total += ce - cs; + cs = s; + ce = e; + } + } + return total + (ce - cs); +} + +const ivsOf = (sessions: WorkSession[], cls?: string): Array<[number, number]> => + sessions + .filter((x) => cls == null || x.class === cls) + .map((x) => [x.start, x.end] as [number, number]); + function s( iso: string, opts: { @@ -216,4 +241,118 @@ describe('computeWorkTime', () => { }), ).toThrow(/tGap/); }); + + it('rejects an invalid config (2·agentTGap < pIn + pOut)', () => { + // tGap (default 15m) still ≥ pIn+pOut, so only the 2·agentTGap guard trips. + // Without it a short session of one class between two of the other could + // produce a NON-adjacent cross-class overlap the adjacent-only clip misses. + expect(() => + computeWorkTime([s('2026-07-04T10:00:00')], { + agentTGap: 2 * MIN, + pIn: 5 * MIN, + pOut: 5 * MIN, + }), + ).toThrow(/agentTGap/); + }); + + // F1 — cross-class double-count. On the DEFAULT config agentTGap (7m) < pIn+pOut + // (10m), so a `work` session ending in an agent segment and a nearby separate + // `agent_only` run (gap in (7m,10m]) used to produce OVERLAPPING padded + // intervals — the same wall-clock counted into BOTH workMs and agentOnlyMs. The + // cross-class padding clip must make the two per-class unions disjoint. + it('does NOT double-count wall-clock across work/agent_only (§F1)', () => { + // user@0s ; agent(chatX)@60s (breaks into a work session with the human) ; + // agent(chatY)@560s,590s (a separate agent_only run). Raw gap between the work + // session (ends 60s) and the agent run (starts 560s) is 500s ∈ (agentTGap, + // pIn+pOut] once padded — the classic overlap window. + const rows: TimelineSample[] = [ + s('2026-07-04T00:00:00'), // user @ 0s + s('2026-07-04T00:01:00', { source: 'agent', chat: 'cX', kind: 'agent' }), // @ 60s + s('2026-07-04T00:09:20', { source: 'agent', chat: 'cY', kind: 'agent' }), // @ 560s + s('2026-07-04T00:09:50', { source: 'agent', chat: 'cY', kind: 'agent' }), // @ 590s + ]; + const r = computeWorkTime(rows); // DEFAULT config + + // Both classes present. + expect(r.workMs).toBeGreaterThan(0); + expect(r.agentOnlyMs).toBeGreaterThan(0); + + // Per-class metrics are exactly their own union (union, not Σ). + expect(r.workMs).toBe(unionMs(ivsOf(r.sessions, 'work'))); + expect(r.agentOnlyMs).toBe(unionMs(ivsOf(r.sessions, 'agent_only'))); + + // The F1 invariant: work-union and agent-union are cross-class-disjoint, so + // the union of ALL padded intervals equals workMs + agentOnlyMs (no overlap). + // With the clip disabled this fails (union < sum by the 100s overlap). + expect(unionMs(ivsOf(r.sessions))).toBe(r.workMs + r.agentOnlyMs); + }); + + // F1 property/fuzz — random timelines across several timezones must uphold the + // work-time invariants. Backs the (corrected) PR claim of a real fuzz test. + it('property: random timelines uphold union & cross-class-disjoint invariants', () => { + // Deterministic LCG (numerical-recipes constants) so a failure is reproducible. + let seed = 0x9e3779b9 >>> 0; + const rand = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed / 0x100000000; + }; + const pick = (arr: T[]): T => arr[Math.floor(rand() * arr.length)]; + + const tzs = [ + 'UTC', + 'America/New_York', + 'Europe/Moscow', + 'Australia/Lord_Howe', // 30-min DST offset — a nasty bucket stress + ]; + const base = Date.UTC(2026, 5, 1, 0, 0, 0); // 2026-06-01Z + const chats = ['c1', 'c2', 'c3']; + + for (let iter = 0; iter < 250; iter++) { + const tz = pick(tzs); + const n = 2 + Math.floor(rand() * 18); // 2..19 rows + const rows: TimelineSample[] = []; + // Walk time forward by a random inter-sample gap. The gap distribution is + // centred on the DANGEROUS band — a bit under to a bit over pIn+pOut (10m) + // AND straddling agentTGap (7m) — so adjacent samples routinely split into + // separate sessions whose ±P padding would overlap if a class boundary sits + // there. Mixing user/agent classes at these gaps reliably manufactures the + // work-ending-in-agent → agent_only cross-class boundary F1 is about, plus + // dense within-class runs (occasional 0–2m gaps) that exercise the union. + let t = base + Math.floor(rand() * 60 * MIN); + for (let i = 0; i < n; i++) { + const roll = rand(); + const gap = + roll < 0.25 + ? Math.floor(rand() * 2 * MIN) // dense burst (same-class union) + : roll < 0.85 + ? 5 * MIN + Math.floor(rand() * 8 * MIN) // 5–13m: the split band + : 20 * MIN + Math.floor(rand() * 40 * MIN); // long idle → new day-ish + t += gap; + const iso = new Date(t).toISOString().slice(0, 19); // 'YYYY-MM-DDTHH:MM:SS' + const isAgent = rand() < 0.5; + rows.push( + isAgent + ? s(iso, { source: 'agent', chat: pick(chats), kind: 'agent' }) + : s(iso, { source: 'user', kind: rand() < 0.3 ? 'idle' : 'manual' }), + ); + } + + const r = computeWorkTime(rows); // DEFAULT config + + const workIvs = ivsOf(r.sessions, 'work'); + const agentIvs = ivsOf(r.sessions, 'agent_only'); + + // (1) each metric is exactly its per-class union (catches a union→Σ regress). + expect(r.workMs).toBe(unionMs(workIvs)); + expect(r.agentOnlyMs).toBe(unionMs(agentIvs)); + + // (2) NO cross-class overlap: union(all) == workMs + agentOnlyMs (F1). + expect(unionMs(ivsOf(r.sessions))).toBe(r.workMs + r.agentOnlyMs); + + // (3) bucket invariant: Σ per-day activeMs == workMs (§6.3). + const perDay = bucketByDay(r.sessions, tz); + const sumActive = perDay.reduce((a, d) => a + d.activeMs, 0); + expect(sumActive).toBe(r.workMs); + } + }); }); diff --git a/apps/server/src/core/page/work-time/compute-work-time.ts b/apps/server/src/core/page/work-time/compute-work-time.ts index fa93c3c0..ccc19bd4 100644 --- a/apps/server/src/core/page/work-time/compute-work-time.ts +++ b/apps/server/src/core/page/work-time/compute-work-time.ts @@ -167,8 +167,10 @@ function unionDuration(intervals: Array<[number, number]>): number { * over all segments (threshold depends on the pair: both-agent → agentTGap, else * tGap; the last session is ALWAYS closed after the loop) → class per finished * session (all-agent → agent_only, else work) → pad each session (multi-sample - * → [first−P_in, last+P_out]; lone scalar → [t−P_single, t]) → metrics are the - * union wall-clock within each class (union, not Σ, so overlaps never double). + * → [first−P_in, last+P_out]; lone scalar → [t−P_single, t]) → clip padding of + * adjacent DIFFERENT-class sessions at the raw-gap midpoint (so work/agent_only + * never overlap) → metrics are the union wall-clock within each class (union, not + * Σ, so overlaps never double, and cross-class-disjoint by the clip above). */ export function computeWorkTime( rows: TimelineSample[], @@ -196,29 +198,70 @@ export function computeWorkTime( } if (cur != null) rawSessions.push(cur); // MUST close the last session (§5, §9#1) - const sessions: WorkSession[] = []; - const workIvs: Array<[number, number]> = []; - const agentIvs: Array<[number, number]> = []; + // A finished session with BOTH its raw (unpadded) span and its padded bounds. + // `rawSessions` are already in ascending time order, so `built` is too. + interface BuiltSession { + rawStart: number; + rawEnd: number; + padStart: number; + padEnd: number; + cls: WorkSession['class']; + } + const built: BuiltSession[] = []; for (const segs of rawSessions) { const first = segs[0]; const last = segs[segs.length - 1]; const cls = segs.every((s) => s.isAgent) ? 'agent_only' : 'work'; - let start: number; - let end: number; + let padStart: number; + let padEnd: number; if (segs.length === 1 && first.tStart === first.tEnd) { // Lone single-instant session (one scalar, or a one-snapshot agent run): // pre-roll only, no invented "future" work (§5). - start = first.tStart - cfg.pSingle; - end = first.tStart; + padStart = first.tStart - cfg.pSingle; + padEnd = first.tStart; } else { - start = first.tStart - cfg.pIn; - end = last.tEnd + cfg.pOut; + padStart = first.tStart - cfg.pIn; + padEnd = last.tEnd + cfg.pOut; } - sessions.push({ start, end, class: cls }); - (cls === 'work' ? workIvs : agentIvs).push([start, end]); + built.push({ + rawStart: first.tStart, + rawEnd: last.tEnd, + padStart, + padEnd, + cls, + }); + } + + // Clip cross-class padding so a `work` and an `agent_only` session that abut + // never claim the same wall-clock. For each ADJACENT pair of DIFFERENT classes, + // cap the earlier session's trailing pad and the later session's leading pad at + // the MIDPOINT of the raw (unpadded) inactivity gap between them: the earlier + // padded interval then ends ≤ midpoint and the later one starts ≥ midpoint, so + // the two are disjoint (they touch at most at the midpoint). This makes the + // per-class unions (workMs / agentOnlyMs) cross-class-disjoint BY CONSTRUCTION + // — closing the double-count where a work session ending in an agent segment + // and a nearby agent_only session (gap in (agentTGap, pIn+pOut]) overlapped and + // were counted into both metrics (§5, §9). Within-class adjacency is left + // untouched: `unionDuration` already dedups it, and clipping there could perturb + // the per-class metric value. + for (let i = 1; i < built.length; i++) { + const a = built[i - 1]; + const b = built[i]; + if (a.cls === b.cls) continue; + const midpoint = (a.rawEnd + b.rawStart) / 2; + if (a.padEnd > midpoint) a.padEnd = midpoint; + if (b.padStart < midpoint) b.padStart = midpoint; + } + + const sessions: WorkSession[] = []; + const workIvs: Array<[number, number]> = []; + const agentIvs: Array<[number, number]> = []; + for (const s of built) { + sessions.push({ start: s.padStart, end: s.padEnd, class: s.cls }); + (s.cls === 'work' ? workIvs : agentIvs).push([s.padStart, s.padEnd]); } sessions.sort((a, b) => a.start - b.start); diff --git a/apps/server/src/core/page/work-time/work-time.config.ts b/apps/server/src/core/page/work-time/work-time.config.ts index 2e186255..497633a1 100644 --- a/apps/server/src/core/page/work-time/work-time.config.ts +++ b/apps/server/src/core/page/work-time/work-time.config.ts @@ -52,10 +52,20 @@ void IDLE_MAX_WAIT_USER; void IDLE_MAX_WAIT_AGENT; /** - * Fill a partial config with defaults and validate it. `tGap ≥ pIn + pOut` is - * NOT required for the §6.3 per-day invariant (union takes care of that), but is - * RECOMMENDED and enforced: otherwise the P-padding of adjacent sessions of - * DIFFERENT classes could overlap and be counted into both metrics (§5, §10). + * Fill a partial config with defaults and validate it. Cross-class metric + * disjointness is guaranteed jointly by `computeWorkTime`'s adjacent-pair padding + * clip (it caps the padding of adjacent DIFFERENT-class sessions at the raw-gap + * midpoint) AND the two bounds enforced below (§5): + * - `tGap ≥ pIn + pOut`: a session's own padding never exceeds its inactivity + * window. + * - `2·agentTGap ≥ pIn + pOut`: makes the adjacent-only clip provably COMPLETE. + * A NON-adjacent (i, i+2) cross-class overlap could only arise from two + * same-class sessions separated by a full intervening session of the other + * class; that separation spans at least two inter-session gaps, each strictly + * `> agentTGap`, so it is `> 2·agentTGap`. Requiring `2·agentTGap ≥ pIn + pOut` + * means even the widest padded reach (pIn + pOut) cannot bridge it — so the + * only cross-class overlaps possible are between ADJACENT sessions, which the + * clip handles. `workMs`/`agentOnlyMs` are therefore disjoint by construction. */ export function resolveWorkTimeConfig( partial?: Partial, @@ -80,7 +90,12 @@ export function resolveWorkTimeConfig( } if (config.tGap < config.pIn + config.pOut) { throw new Error( - 'work-time config: tGap must be ≥ pIn + pOut so work/agent_only metrics cannot overlap', + "work-time config: tGap must be ≥ pIn + pOut (a session's padding may not exceed its inactivity window)", + ); + } + if (2 * config.agentTGap < config.pIn + config.pOut) { + throw new Error( + 'work-time config: 2·agentTGap must be ≥ pIn + pOut (so non-adjacent cross-class padding cannot overlap)', ); } return config;