The work ledger (Step 10): unified effort tracking where the actor can be a human or an AI agent and the quantity minutes, tokens, or cents — one table, so "what did this task actually cost" is a single query across people-hours and AI spend.
Append-only: corrections are new entries; nothing here updates or
deletes rows. Ledger writes ride the ledger feature flag at the
CALLER (LV) layer — the context trusts its callers, mirroring the rest
of the module.
Human time
Ledger.log_time(project, 45, assignment_uuid: a.uuid,
note: "Design pass", actor_uuid: user.uuid)AI usage
Ledger.record_ai(project, %{tokens: 1234, cost_cents: 12,
model: "gpt-x", agent_uuid: agent.uuid}, assignment_uuid: a.uuid)writes a tokens entry and (when cost is present) a cost entry
sharing metadata — sums stay trivial per kind. This is the API the
phoenix_kit_ai attribution seam calls when it lands; nothing here
depends on that package.
Summary
Functions
Entries for a project, newest first (capped).
Logs human time in MINUTES. Options: :assignment_uuid, :note,
:billable, :actor_uuid (a core user), :actor_kind (default
"user"), :source ("manual"/"timer"), :started_at/:ended_at.
Records AI usage attributed to a project/task: a tokens entry plus a
cost entry when cost_cents is POSITIVE, both actor_kind: "ai_agent"
with shared metadata (model, endpoint, anything else passed).
Returns {:ok, [entries]}.
The AI attribution sink's resolver (Phase G): maps a persisted
phoenix_kit_ai request (duck-typed map/struct — this package never
references that package's modules) onto record_ai/3.
Logged-time minutes per assignment for a DISPLAYED set (one query). Keyed by assignment uuid regardless of owning project, so a show page rendering a parent's tasks plus expanded sub-project child tasks gets every chip from one call (panel round: entries attribute to the project that owns the assignment, which for child rows is not the viewed one).
Effort totals for a project:
%{time_minutes, tokens, cost_cents, billable_minutes} — zeros when
empty; fail-safe zeros on a DB hiccup (a summary line must never take
the show page down).
Functions
@spec list_entries( binary(), keyword() ) :: [PhoenixKitProjects.Schemas.WorkEntry.t()]
Entries for a project, newest first (capped).
@spec log_time(map() | binary(), pos_integer() | Decimal.t(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.WorkEntry.t()} | {:error, term()}
Logs human time in MINUTES. Options: :assignment_uuid, :note,
:billable, :actor_uuid (a core user), :actor_kind (default
"user"), :source ("manual"/"timer"), :started_at/:ended_at.
@spec record_ai(map() | binary(), map(), keyword()) :: {:ok, [PhoenixKitProjects.Schemas.WorkEntry.t()]} | {:error, term()}
Records AI usage attributed to a project/task: a tokens entry plus a
cost entry when cost_cents is POSITIVE, both actor_kind: "ai_agent"
with shared metadata (model, endpoint, anything else passed).
Returns {:ok, [entries]}.
Zero/absent quantities are SKIPPED, not errors — cost_cents: 0 is the
normal shape for free/cached/local calls (panel round: 0 is truthy in
Elixir, so a naive && built a zero-amount entry that failed the
amount>0 validation AFTER the tokens row committed). Both inserts run in
one transaction; activity/broadcast fire only after it commits.
@spec record_ai_request(map() | struct()) :: {:ok, [PhoenixKitProjects.Schemas.WorkEntry.t()]} | :skipped | {:error, term()}
The AI attribution sink's resolver (Phase G): maps a persisted
phoenix_kit_ai request (duck-typed map/struct — this package never
references that package's modules) onto record_ai/3.
The request's metadata["attribution"] carries resource_type +
resource_uuid from the translate pipeline (or any caller passing
:attribution). Resolution:
"assignment"→ that assignment's project + the assignment;"project"→ the project itself;"task"/"template"/anything else → not ours (library tasks and templates have no project) —:skipped.
UNIT TRAP (scout-verified): phoenix_kit_ai's cost_cents column is
NANODOLLARS despite the name (1e-6 dollars); the ledger stores CENTS.
Divide by 10_000 as a Decimal so sub-cent calls stay positive
fractions instead of silently rounding to zero.
The AI actor identity is the ENDPOINT uuid (agent_uuid) — the
stable "which AI did the work" until first-class agent records exist.
Logged-time minutes per assignment for a DISPLAYED set (one query). Keyed by assignment uuid regardless of owning project, so a show page rendering a parent's tasks plus expanded sub-project child tasks gets every chip from one call (panel round: entries attribute to the project that owns the assignment, which for child rows is not the viewed one).
Effort totals for a project:
%{time_minutes, tokens, cost_cents, billable_minutes} — zeros when
empty; fail-safe zeros on a DB hiccup (a summary line must never take
the show page down).