The Atlas BigLaw / Big Michael — documentation bound to its code
7 documents

What a matter costs

See both ledgers a matter accrues: a CostEntry for every single model call (tokens, USD, cache buckets, local power) and a billable TimeEntry in 6-minute units for task runs, gate reviews, and AI agent work.

src/time/index.ts277 lines · TimeStore L40–255
Outline 19 symbols
1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Discover Legal
3// This program is free software: you can redistribute it and/or modify it
4// under the terms of the GNU Affero General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// (at your option) any later version. See <https://www.gnu.org/licenses/>.
7
8/**
9 * TimeStore — automatic billable time tracking.
10 *
11 * Records open/close time entries for task execution and gate reviews.
12 * Persists to a JSON file (atomic tmp+rename like other stores).
13 * Billing units are 6-minute increments (0.1 hr each), rounded UP.
14 */
15
16import { randomUUID } from "crypto";
17import { readFile, writeFile, rename } from "fs/promises";
18import { Config } from "../config.js";
19import { logger } from "../logger.js";
20import { auditLogger, ACTOR_SYSTEM } from "../audit/index.js";
21import { classifyUtbms } from "../billing/utbms.js";
22import type { TimeEntry, TimeEventType, OcgSuggestion } from "../types.js";
23
24export type { TimeEntry, TimeEventType };
25
26export interface TimeFilter {
27 profileId?: string;
28 agentId?: string;
29 taskId?: string;
30 matterNumber?: string;
31 clientNumber?: string;
32 from?: Date;
33 to?: Date;
34 /** When true, only return agent_work entries. When false, exclude them. Omit for all. */
35 agentOnly?: boolean;
36}
37
38const SIX_MIN_MS = 6 * 60 * 1000; // 360 000 ms
39
40export class TimeStore {
41 private readonly path = Config.persistence.timeFile;
42 private entries: TimeEntry[] = [];
43 private writeChain = Promise.resolve();
44
45 async init(): Promise<void> {
46 try {
47 const raw = await readFile(this.path, "utf8");
48 const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
49 this.entries = parsed.map((e) => ({
50 ...e,
51 startedAt: new Date(e.startedAt as string),
52 endedAt: e.endedAt ? new Date(e.endedAt as string) : undefined,
53 })) as TimeEntry[];
54 logger.info("Time entries loaded", { count: this.entries.length });
55 } catch (err) {
56 logger.warn("Time entries file could not be loaded — starting empty", {
57 error: err instanceof Error ? err.message : String(err),
58 });
59 this.entries = [];
60 }
61 }
62
63 /**
64 * Open a new time entry. The entry is open (durationMs=0, billingUnits=0)
65 * until `close()` is called.
66 */
67 open(
68 entry: Omit<TimeEntry, "id" | "endedAt" | "durationMs" | "billingUnits">,
69 ): TimeEntry {
70 if (entry.billingRate !== undefined) {
71 if (!Number.isFinite(entry.billingRate) || entry.billingRate < 0) {
72 throw new Error(`Invalid billingRate: ${entry.billingRate} — must be a non-negative finite number`);
73 }
74 }
75 const newEntry: TimeEntry = {
76 ...entry,
77 id: randomUUID(),
78 durationMs: 0,
79 billingUnits: 0,
80 };
81 this.entries.push(newEntry);
82 this.persist().catch((err) => logger.warn("Failed to persist time entries", { error: (err as Error).message }));
83 auditLogger.write({
84 event: "time.opened",
85 actorId: newEntry.profileId ?? ACTOR_SYSTEM,
86 taskId: newEntry.taskId,
87 agentId: newEntry.agentId,
88 data: { entryId: newEntry.id, event: newEntry.event, description: newEntry.description.slice(0, 200), matterNumber: newEntry.matterNumber, clientNumber: newEntry.clientNumber },
89 });
90 return newEntry;
91 }
92
93 /**
94 * Close an open time entry. Computes durationMs and billingUnits.
95 * Returns undefined if the entry is not found.
96 */
97 close(id: string): TimeEntry | undefined {
98 const entry = this.entries.find((e) => e.id === id);
99 if (!entry) return undefined;
100 const endedAt = new Date();
101 entry.endedAt = endedAt;
102 entry.durationMs = Math.max(0, endedAt.getTime() - entry.startedAt.getTime());
103 entry.billingUnits = Math.ceil(entry.durationMs / SIX_MIN_MS);
104 if (entry.billingRate) {
105 entry.billingAmountUsd = parseFloat((entry.billingUnits * 0.1 * entry.billingRate).toFixed(4));
106 }
107 this.persist().catch((err) => logger.warn("Failed to persist time entries", { error: (err as Error).message }));
108 auditLogger.write({
109 event: "time.closed",
110 actorId: entry.profileId ?? ACTOR_SYSTEM,
111 taskId: entry.taskId,
112 agentId: entry.agentId,
113 durationMs: entry.durationMs,
114 data: { entryId: entry.id, event: entry.event, billingUnits: entry.billingUnits, billingAmountUsd: entry.billingAmountUsd, matterNumber: entry.matterNumber, clientNumber: entry.clientNumber },
115 });
116 classifyUtbms(entry.description ?? entry.event, entry.event).then(({ taskCode, activityCode }) => {
117 entry.utbmsTaskCode = taskCode;
118 entry.utbmsActivityCode = activityCode;
119 this.persist().catch((err) => logger.warn("Failed to persist time entries after UTBMS", { error: (err as Error).message }));
120 }).catch(() => { /* classification failure is non-fatal */ });
121 return entry;
122 }
123
124 /** Get a single entry by ID. */
125 getById(id: string): TimeEntry | undefined {
126 return this.entries.find((e) => e.id === id);
127 }
128
129 /** Overwrite the description on an entry (used by the queue worker). */
130 updateDescription(id: string, description: string): void {
131 const entry = this.entries.find((e) => e.id === id);
132 if (!entry) return;
133 entry.description = description;
134 this.persist().catch((err) => logger.warn("Failed to persist time entries", { error: (err as Error).message }));
135 }
136
137 /** List entries with optional filtering. */
138 list(filter?: TimeFilter): TimeEntry[] {
139 return this.entries.filter((e) => matchesFilter(e, filter));
140 }
141
142 /** Mark an entry as synced to a Clio matter activity (idempotency guard for sync-to-clio). */
143 markClioSynced(id: string): void {
144 const entry = this.entries.find((e) => e.id === id);
145 if (!entry) return;
146 entry.clioSyncedAt = new Date().toISOString();
147 this.persist().catch((err) => logger.warn("Failed to persist time entries", { error: (err as Error).message }));
148 }
149
150 /** Set OCG suggestions for an entry (replaces any prior suggestions). */
151 setSuggestions(entryId: string, suggestions: OcgSuggestion[]): void {
152 const entry = this.entries.find((e) => e.id === entryId);
153 if (!entry) return;
154 entry.ocgSuggestions = suggestions;
155 entry.ocgCheckedAt = new Date().toISOString();
156 this.persist().catch((err) => logger.warn("Failed to persist time entries", { error: (err as Error).message }));
157 }
158
159 /**
160 * Accept a suggestion — rewrites the entry description and marks the
161 * suggestion accepted. Returns the updated entry, or undefined if not found.
162 */
163 acceptSuggestion(entryId: string, ruleId: string): TimeEntry | undefined {
164 const entry = this.entries.find((e) => e.id === entryId);
165 if (!entry || !entry.ocgSuggestions) return undefined;
166 const suggestion = entry.ocgSuggestions.find((s) => s.ruleId === ruleId);
167 if (!suggestion) return undefined;
168 entry.description = suggestion.suggestedDescription;
169 suggestion.status = "accepted";
170 this.persist().catch((err) => logger.warn("Failed to persist time entries", { error: (err as Error).message }));
171 return entry;
172 }
173
174 /**
175 * Dismiss a suggestion — marks it dismissed without changing the description.
176 * Returns the updated entry, or undefined if not found.
177 */
178 dismissSuggestion(entryId: string, ruleId: string): TimeEntry | undefined {
179 const entry = this.entries.find((e) => e.id === entryId);
180 if (!entry || !entry.ocgSuggestions) return undefined;
181 const suggestion = entry.ocgSuggestions.find((s) => s.ruleId === ruleId);
182 if (!suggestion) return undefined;
183 suggestion.status = "dismissed";
184 this.persist().catch((err) => logger.warn("Failed to persist time entries", { error: (err as Error).message }));
185 return entry;
186 }
187
188 /** List entries that have at least one pending OCG suggestion. */
189 listWithSuggestions(filter?: TimeFilter): TimeEntry[] {
190 return this.list(filter).filter(
191 (e) => e.ocgSuggestions?.some((s) => s.status === "pending"),
192 );
193 }
194
195 /** Explicit JSON export — same as list(), for the export endpoint. */
196 exportJson(filter?: TimeFilter): TimeEntry[] {
197 return this.list(filter);
198 }
199
200 /** CSV export with headers. */
201 exportCsv(filter?: TimeFilter): string {
202 const rows = this.list(filter);
203 const header = "id,event,profileId,profileName,agentId,agentName,taskId,matterNumber,clientNumber,description,startedAt,endedAt,durationMs,billingUnits,billingRate,billingAmountUsd,utbmsTaskCode,utbmsActivityCode,clioSyncedAt";
204 // Neutralize spreadsheet formula injection: a field beginning with = + - @
205 // (or a leading control char) is executed as a formula by Excel/Sheets when
206 // the CSV is opened. Prefix such values with a single quote. Several fields
207 // (description, names) carry LLM- or user-supplied content.
208 const esc = (v: unknown) => {
209 let s = String(v ?? "").replace(/[\r\n]+/g, " ");
210 if (/^[=+\-@\t]/.test(s)) s = `'${s}`;
211 return `"${s.replace(/"/g, '""')}"`;
212 };
213 const lines = rows.map((e) =>
214 [
215 esc(e.id),
216 esc(e.event),
217 esc(e.profileId ?? ""),
218 esc(e.profileName ?? ""),
219 esc(e.agentId ?? ""),
220 esc(e.agentName ?? ""),
221 esc(e.taskId),
222 esc(e.matterNumber ?? ""),
223 esc(e.clientNumber ?? ""),
224 esc(e.description),
225 esc(e.startedAt.toISOString()),
226 esc(e.endedAt?.toISOString() ?? ""),
227 esc(e.durationMs),
228 esc(e.billingUnits),
229 esc(e.billingRate ?? ""),
230 esc(e.billingAmountUsd ?? ""),
231 esc(e.utbmsTaskCode ?? ""),
232 esc(e.utbmsActivityCode ?? ""),
233 esc(e.clioSyncedAt ?? ""),
234 ].join(","),
235 );
236 return [header, ...lines].join("\r\n");
237 }
238
239 /** Atomic write — serialized through writeChain to prevent concurrent writes. */
240 persist(): Promise<void> {
241 this.writeChain = this.writeChain.then(() => this.doWrite()).catch(() => this.doWrite());
242 return this.writeChain;
243 }
244
245 private async doWrite(): Promise<void> {
246 const tmp = `${this.path}.tmp`;
247 const serialisable = this.entries.map((e) => ({
248 ...e,
249 startedAt: e.startedAt.toISOString(),
250 endedAt: e.endedAt?.toISOString(),
251 }));
252 await writeFile(tmp, JSON.stringify(serialisable, null, 2), "utf8");
253 await rename(tmp, this.path);
254 }
255}
256
257function matchesFilter(entry: TimeEntry, filter?: TimeFilter): boolean {
258 if (!filter) return true;
259 if (filter.agentOnly === true && entry.event !== "agent_work") return false;
260 if (filter.agentOnly === false && entry.event === "agent_work") return false;
261 // profileId filter: match lawyer entries for that profile OR agent entries attributed to them
262 if (filter.profileId && entry.profileId !== filter.profileId) return false;
263 if (filter.agentId && entry.agentId !== filter.agentId) return false;
264 if (filter.taskId && entry.taskId !== filter.taskId) return false;
265 if (filter.matterNumber && entry.matterNumber !== filter.matterNumber) return false;
266 if (filter.clientNumber && entry.clientNumber !== filter.clientNumber) return false;
267 if (filter.from) {
268 if (isNaN(filter.from.getTime())) return false; // skip entries if filter date is invalid
269 if (entry.startedAt < filter.from) return false;
270 }
271 if (filter.to) {
272 if (isNaN(filter.to.getTime())) return false;
273 if (entry.startedAt > filter.to) return false;
274 }
275 return true;
276}
277