Commit Graph
5531 Commits
Author SHA1 Message Date
Alex f658515ca3 Address review findings on the agents navigation
- Opening a folder dropped the active filter. agentsListPath() always
  built off the bare /agents/manage root, so from /agents/manage/mine it
  navigated to /agents/manage?folder=..., and filterFromPath() then read
  back `all`. A regression from making the filters routable: they used
  to be component state, which a replace-navigation left alone. The
  helper now takes the filter and builds off agentsFilterPath(), which
  also fixes the other half — switching filter while inside a folder
  re-navigates to the filtered path with the folder kept, instead of the
  sync effect pulling it back to the unfiltered URL.

- Both manage-agents handlers cleared the selected agent and closed the
  mobile nav before returning early for a modifier-click, so opening the
  list in a new tab mutated the tab you stayed on. The modifier check
  and preventDefault() now run first.

- The detail breadcrumbs had the name precedence inverted. Tools.tsx
  shows `customName || displayName` throughout, so a renamed tool kept
  its old name in the breadcrumb alone. ToolConfig now matches, and
  RemoteDeviceConfig leads with device?.name as its own heading does.
2026-09-22 00:42:17 +01:00
Alex 448de3c48a Use a lighter icon for Custom Models
Boxes is the densest glyph in the settings nav by some way — nine
elements and 43 path commands against a median of about twelve. Three
cubes with their internal facets inside 20px puts the strokes almost on
top of each other, so at the nav's 1.75 weight it renders as a darker
blob than the icons above and below it, even though the stroke width is
identical. Blocks sits at the median, matching Sources, Tools and Teams,
and stays distinct from the database glyph two rows up.
2026-09-22 00:30:56 +01:00
Alex 2f6b2ee8dd Fix a crash on the filtered agent routes, and two animation defects
AgentSection computed its folder breadcrumb in a useMemo placed below two
early returns for the empty states. A hook after an early return is
skipped on the render that takes it, which React rejects outright with
"rendered fewer hooks than expected" — the page showed only the error
boundary. Reachable now that each filter is its own route: landing
straight on an empty one renders once while the data loads and again
once it arrives empty, taking the early return the second time. The
memo moves above them.

Two problems in the chat's entrance animations, found while looking for
the reported flicker:

- .fade-in-bubble carried `opacity: 0` on the element and reached full
  opacity only by running `fadeInUp` to completion with `forwards`. The
  answer text was therefore visible *because* an animation had finished,
  so anything that stopped one running left it blank — including the
  obvious reduced-motion reset, which is presumably why the rule covers
  only .shimmer-text today. The start state moves into the keyframes,
  the element rests visible, and both entrances now honour
  prefers-reduced-motion.

- The timings were long for their jobs. An expanded tool call reaches
  its full height at once, so fading its content over half a second read
  as the content lagging the layout rather than as a reveal; it is now
  0.16s. The answer entrance goes to 0.26s with a 6px rise instead of
  0.5s and 10px.
2026-09-22 00:24:52 +01:00
Alex ac87430715 Animate the sidebar as a stack, and let it move on the click
The sidebar cross-faded between two states, which stopped describing
what was happening once sections could nest: entering a section slid,
but opening an agent from the agent list swapped in place with no
motion at all, so going deeper and going sideways looked identical.

Panels are now positioned from a single number — their depth relative
to the level on screen. A panel above the current level waits off to
the right, the current one sits at rest, and ones below park just off
to the left, so push and pop fall out of the same rule and no direction
has to be tracked. The panel behind travels a quarter of the width and
dims rather than sliding out with the one in front, and the arriving
panel carries a shadow off its leading edge that the container clips
once it lands, so the two read as stacked rather than adjacent.

The motion was also starting far too late. Mounting a section's page
costs a single ~170ms blocking frame in a production build, and the
sidebar's own class change rode along in that same commit: measured
from the click, the panels did not begin moving for ~290ms, so the
animation played to an audience that had stopped expecting it. The two
updates are now split by priority. The level lands as an urgent update
touching nothing but the sidebar, so React can commit and paint it
straight away; the route change goes through startTransition, which
renders the page at low priority and yields instead of blocking that
paint. The target's section is resolved from the path up front, so the
incoming panel arrives with its content already in place. The style
change now lands ~53ms after the click. Only translate and opacity are
animated, so the compositor keeps the motion smooth across the frames
the page render still costs.

Timing is tuned against where the travel actually lands rather than by
feel: half the distance by ~65ms so the panel tracks the click, 90% by
~180ms so the movement reads as movement, settled by ~300ms.
2026-09-21 23:45:50 +01:00
Alex cfbf61f4f3 Split the agents URL space and move its navigation into the sidebar
Routes under /agents covered two different things: using an agent (a
conversation) and managing them (the list, editor, logs, schedules).
Sharing the prefix left no way to tell them apart from the pathname, so
the sidebar could not react to one without also reacting to the other.

Management now lives under /agents/manage, and every caller builds its
links from agents/paths.ts rather than from a literal. Pre-split URLs
redirect, keeping their query.

With the prefixes distinct, agent management becomes a section like
settings and admin:

- The list's five filters are routes rather than component state, so a
  filtered view is linkable and survives a reload. The hand-rolled pill
  row is gone from the content on desktop.
- An agent's own pages (overview, logs, schedules) get a nav titled
  after the agent, replacing the breadcrumb-and-underline sub-nav.
  Sections nest to support it: `parentPath` makes back mean "up one
  level", so leaving an agent lands on the agent list rather than the
  chat.
- Sections named after a record are built per route, since the title
  comes from the store rather than the path.
- `pageTitle` distinguishes sections whose destinations are separate
  pages from ones whose destinations are views of a single page; the
  latter keep their own heading instead of flipping to "All".

Below lg, where the sidebar is an overlay, those view-style
destinations appear as a pill row on the page — bouncing out to an
index page to change a filter would be worse than a row of pills.

The workflow builder keeps its full-screen canvas and its own header,
the one place a content-owned nav still earns its keep.

Page shells converge on the shared padding, max width and header, and
the agent list's folder trail uses the shared breadcrumb primitives.
2026-09-21 23:04:12 +01:00
Alex 60532ec46c Move settings and admin navigation into the sidebar
Settings and admin both drove their pages from a horizontal tab strip.
Seven tabs no longer fit: settings had grown scroll arrows, gradient
masks and a hiddenGradient state machine just to survive on mobile, and
the active tab was resolved by comparing translated labels against the
URL. Teams and admin had no home in the strip at all — admin was
reachable only from the Help popover.

Replace both strips with a vertical nav that takes over the sidebar
while you are inside a section, plus a back button that returns to the
app. A declarative registry in navigation/sections.ts holds each
section's destinations; the active item is resolved from the route by
longest path match, so a detail route like /settings/tools/slack keeps
Tools active. Section state is derived from the route rather than
stored, so deep links and browser back keep working.

- Below lg the sidebar is an overlay, so /settings and /admin render
  their destination list as page content and each page carries a back
  link to it.
- Entering settings no longer clears the conversation; the back button
  returns to the route you came from and the chat list stays mounted,
  keeping its scroll position.
- Collapsing the sidebar inside a section shows the same destinations
  as icons instead of stranding you on one page.
- Detail views nested in a section page (a tool's config, a team) now
  use a breadcrumb rather than a second back arrow, so only the section
  nav means "leave".
- Teams and admin join the settings nav; admin-only entries are hidden
  from non-admins along with the group heading they leave empty.
2026-09-21 22:31:47 +01:00
Alex 295969b6a4 Merge pull request #2818 from arc53/branding-upd
logo upd
2026-09-21 18:00:18 +01:00
Alex a25bec6821 Merge remote-tracking branch 'origin/main' into branding-upd
# Conflicts:
#	docsgpt/core/models/anthropic.yaml
#	docsgpt/core/models/openai.yaml
#	frontend/src/admin/AdminUI.tsx
2026-09-21 17:52:53 +01:00
Pavel 2fafb92c90 Fix content 2026-09-21 20:31:49 +04:00
Alex 4755fee7c5 Merge pull request #2817 from arc53-machine/chore/zed-editor-config
chore: Zed project config, .editorconfig and shared pyright settings
2026-09-21 17:06:24 +01:00
Pavel 2e07390782 logo upd 2026-09-21 19:45:09 +04:00
arc53-machine 53facb460c chore: add Zed project config, .editorconfig and shared pyright settings
- .zed/settings.json: ruff + basedpyright for Python (no format on save, the
  tree is not ruff-format clean), ESLint fixes then Prettier for the frontend,
  scan exclusions for caches and build outputs, .jwt_secret_key as private
- .zed/tasks.json: dev services, API, worker, frontend, pytest/vitest for the
  current file or test, linting, uv lock + requirements export
- .zed/debug.json: debugpy targets matching .vscode/launch.json
- .editorconfig: whitespace rules for every editor
- [tool.pyright] in pyproject.toml: venv, import root and excludes shared by
  pyright, basedpyright and Pylance
- .gitignore: track only the shared files under .zed/
- CONTRIBUTING: editor setup section; fix the stale ESLint config path
2026-09-21 16:10:31 +01:00
Alex 9b85755c3b Merge pull request #2816 from arc53/feat/admin-quotas
feat: admin-set usage quotas per user and per team
2026-09-21 16:07:26 +01:00
Alex 1e14605ee7 fix(quotas): keyless agent chat bucket, keep disabled policies disabled, cached rates
- check_usage treats a request through a keyless (draft) agent as agent
  traffic, matching how its usage rows are bucketed and the headless rule
- dashboard edits carry the stored enabled flag instead of re-enabling the
  policy; disabled policies are labelled in the Quotas tab and the editor
- quota 429s send x-should-retry: false so OpenAI SDK clients do not retry
  a refusal that cannot succeed before the reset
- cached-input and cache-write rates for Anthropic, OpenRouter and Groq
  gpt-oss-120b; refresh OpenRouter deepseek-v3.2 list prices
- UsageQuota reuses usagePercent; docs note that a user override needs an
  existing user
2026-09-21 15:26:29 +01:00
Alex b5296df8a9 feat(pricing): cached-input rates for gpt-5.4-mini and gpt-5.4-nano
Checked against OpenAI's pricing page: gpt-5.5 at $5 / $30 (cached $0.50)
was already right. The mini and nano models declared no cached rate, so
cached prompt tokens were billed at the full input rate.
2026-09-21 14:30:48 +01:00
Alex 3a30f5cce9 feat(pricing): rates for the default DocsGPT model
$0.15 input, $0.50 output and $0.03 cached input per 1M tokens, so cost
budgets see usage of the default model instead of recording it at $0.
2026-09-21 13:36:22 +01:00
Alex 4bd259fc09 fix(quotas): address review: resume claims, agent bucket rule, UI races
- A tool continuation refused for usage now releases the resume claim it
  took; before, retries got a 409 until the stale claim was reverted.
- Agent traffic is any row with an agent key or an agent id, so keyless
  agents and workflow nodes count toward the agent bucket, not direct.
- The user quota modal discards responses for a previously opened user.
- The usage meter shows every limited bucket, not only 'all'.
- Restore the class separator on the analytics stat card that a formatter
  run removed, and align the OpenRouter DeepSeek description with its rates.
2026-09-21 12:44:26 +01:00
Alex 69f55b74cb refactor(quotas): validate policy bodies without exception text in responses
Validation problems are returned as values rather than raised and echoed
with str(exc), and a huge integer limit is rejected as out of range instead
of overflowing. Tests no longer call mutating endpoints inside asserts.
2026-09-21 12:15:42 +01:00
Alex 6db9014dfb fix(quotas): list unpriced models by recorded cost; integer token limits in status
The unpriced-model notice asked the live registry whether a model has a
price, so a priced model whose provider was later disabled showed up as
unpriced. It now lists models whose calls this period were all recorded at
$0. Token limits are serialized as integers.
2026-09-21 12:14:14 +01:00
Alex 1eacfdd3d0 docs: usage quotas
How the instance default, team allowances and user overrides resolve
(including users in several teams), the quota window, who is charged for
agent traffic, how cost budgets price models and what happens to unpriced
ones, and the admin and user API.
2026-09-21 12:11:38 +01:00
Alex 1c9c94eba7 feat(frontend): admin quota management, a usage meter and the quota chat error
The admin dashboard gets a Quotas tab for the instance default, team
allowances and user overrides, with a notice listing models that cost
limits cannot see. A Quota action on the Users tab shows a user's effective
limits, the layer each comes from and their usage, next to the editor for
their override. Each budget is either not set at that layer, a limit, or
unlimited.

Users with a quota see a usage meter with the reset time on the Analytics
settings page, and a refused chat request shows the used amount, the limit
and the reset time in the user's language.
2026-09-21 12:11:12 +01:00
Alex 21f43b2966 feat(quotas): admin quota API and GET /api/user/quota
Admins read and set the instance default, team allowances and user
overrides under /api/admin/quotas. A user's endpoint also returns the
limits those layers resolve to, the layer each came from and the usage
against them. The overview lists catalog models used this period that have
no price, since a cost limit cannot see them. Every write is audited.

GET /api/user/quota gives a user their own limited buckets, usage and reset
time without naming the policies behind them; any valid token may call it.
2026-09-21 12:11:12 +01:00
Alex 5406550bca feat(quotas): enforce user quotas on chat, agent, scheduled and webhook runs
check_usage now checks the billable user's quota on every request, before
the per-agent 24h limits, which keep applying to traffic through an agent.
Until now a request without an agent key skipped every limit. A refusal is
a 429 with Retry-After and a body naming the budget, usage, limit, the
layer the limit came from and when it resets.

Headless runs check the agent owner's quota before starting. A refused
scheduled run is recorded as budget_exceeded; a refused webhook run returns
a quota_exceeded result instead of raising, so Celery does not retry it.
2026-09-21 12:11:11 +01:00
Alex 6826313b60 feat(quotas): limit resolution and the quota service
docsgpt/quotas resolves a user's effective token and cost limits from the
policy rows that apply to them: their own override, then the most generous
allowance among their teams, then the instance default, then any defaults a
registered provider supplies. Each budget resolves on its own, and a team
counts once however many memberships the user holds in it.

QuotaService compares those limits with the user's token_usage totals over
the current QUOTA_PERIOD window (calendar-aligned, UTC, computed at read
time). It fails open, and skips the usage query for unlimited users.
2026-09-21 12:11:11 +01:00
Alex 6c42139224 fix(usage): stop double counting scheduled runs; attribute workflow node usage
sum_tokens_in_range now skips the scheduler's per-run rollup rows, whose
tokens are already on the run's per-call rows, so the per-agent 24h limit
and the admin total no longer count scheduled spend twice.

Workflow node LLMs carry the workflow agent's id, so their usage rows are
attributed to the agent instead of landing with a user id only.

usage_totals returns a user's tokens and cost since a window start, split
by interactive and agent-key traffic.
2026-09-21 12:11:11 +01:00
Alex 43fad2a865 feat(quotas): quota_policies table and a per-call cost on token_usage
Migration 0033 adds quota_policies (instance default, team per-member
allowance, user override; a token budget and a USD budget per row) and
token_usage.cost. The column is added IF NOT EXISTS so a database that
already carries it upgrades cleanly.

Every usage row now records the call's USD cost from the model catalog;
bring-your-own models are recorded at $0.
2026-09-21 12:11:11 +01:00
Alex b77561288d feat(pricing): per-million model rates and a cost module
Rename the unused *_cost_per_token capability fields to USD per 1M tokens,
add prompt-cache read/write rates, and ship list prices for the hosted
catalogs. The old per-token keys still load, scaled, with a warning.

docsgpt/pricing.py turns a call's token bins into a USD cost. Models with
no declared rate cost $0 unless QUOTA_UNPRICED_RATE_PER_MILLION is set.
2026-09-21 11:38:22 +01:00
Alex 1c1bc2538f Merge pull request #2812 from arc53-machine/feat/personal-access-tokens
feat: personal access tokens (scoped API tokens for CLI and CI/CD)
2026-09-21 10:55:33 +01:00
arc53-machine 52069e3f8a feat(frontend): regenerate access tokens; fix resource pickers inside the create modal
Each token gets a Regenerate action: a confirmation with the new expiration
(preselecting the lifetime the token was issued with), then the one-time
secret view.

The "restrict to specific resources" pickers could not be scrolled and did
not close on an outside click. Inside a Modal a non-modal popover is
portalled outside the dialog, so the dialog's scroll lock swallowed the wheel,
and Radix defers its outside-click dismissal to the document click, which
Modal stops from propagating. MultiSelect takes a `modal` prop for that case.
2026-09-21 10:32:17 +01:00
arc53-machine 91f2ec2f09 feat(pat): regenerate a token's secret and reset its expiry
POST /api/user/tokens/<id>/regenerate swaps the secret of an existing token
in place: name, scopes and restrictions stay, the old secret stops matching
at once, and the expiry is reset. The lifetime defaults to the one the token
was last issued with (clamped to today's policy) or to expires_in_days when
given. An expired token can be renewed this way; a revoked one cannot. It is
session only like the rest of token management, and writes a pat_regenerated
audit event. regenerated_at records the rotation.
2026-09-21 10:32:17 +01:00
arc53-machine 3e38dc19ec docs: key CI source uploads by commit so a revert is ingested again 2026-09-20 19:47:26 +01:00
arc53-machine 94b5924e36 fix(pat): close restricted-token paths through workflows, chat, schedules and conversations
A resource restriction was checked on ids in the request, not on what the
addressed row pulls in or belongs to. Closed:

- Workflow writes for tokens restricted on sources, tools or prompts (a graph
  names those inside its nodes), and attaching a workflow to an agent unless
  the token is restricted on workflows too.
- Chat for tools-restricted tokens (chat executes tools; rejected at token
  creation as well), and agent-less chat for tokens restricted on prompts or
  workflows.
- conversation_id on chat: it must belong to the agent being run, or to no
  agent for agent-less chat. Otherwise the server continued, appended to, or
  resumed pending tool calls of another agent's conversation.
- Schedules for tokens restricted on anything but agents; schedule-id routes
  for every restricted token.
- Conversations and analytics for every restricted token, not only
  agent-restricted ones.

Also: create, first publish and adopt return the agent API key masked to a
token without agents:keys; token ids must be canonical UUIDs (urn:uuid: gave
a 500); an expired token is reported as expired; token creation takes a
per-user advisory lock so the cap cannot be raced; admin revoke-sessions
writes a pat_revoked event per token. The UI drops a row whose revoke returns
404 and does not offer a tools restriction next to chat:run.
2026-09-20 19:46:43 +01:00
arc53-machine b1ff8f516e fix(frontend): access tokens dates and names render unescaped, align the scopes block
i18next HTML-escaped interpolated values, so expiry dates showed as
26&#x2F;09&#x2F;2026 and token names containing & or quotes were mangled in
the created and revoke dialogs; React already escapes on render, so those
strings opt out like utils/streamingStatusUtils does. The scopes fieldset
drops the browser's default padding so it lines up with the other fields, and
the native checkboxes follow the dark colour scheme.
2026-09-20 13:32:46 +01:00
arc53-machine 7ddb5f6400 fix(pat): align replay scopes, keep 404/405, retire expired names, document the PAT_ENABLED switch
The ASGI message events route now accepts the same scopes as its Flask
sibling (conversations:read or chat:run) through a shared constant. A token
request that fails routing gets Flask's 404/405 instead of a 403. Creating a
token retires an expired token that still held the name. allowed_ids uses
is_pat instead of a bare literal. PAT_ENABLED is documented as the master
switch it is: turning it off stops every existing token from authenticating.
The docs explain that sources are matched by name (oldest wins) and point CI
flows at sources upload --replace.
2026-09-20 12:12:42 +01:00
Alex 9ad09039e4 Merge pull request #2804 from arc53/fix/graphrag-extraction-and-retrieval
fix(graphrag): make graph retrieval and extraction work in a default install
2026-09-20 11:20:54 +01:00
Alex 63d93fc4b9 test(graphrag): pin that identical pages collapse into one
Review asked whether two document rows with the same text and metadata should
stay two pages. They should not: the caller gets four pages to hand a model,
and a crawl that ingested the same text twice would spend two of them on it.
The rows differ only by an id the model never sees. Pinned either way now.
2026-09-20 10:54:55 +01:00
Alex 692edcdd4e test(agents): give the graph tool tests the vector store they need
The tool now asks graphrag_available(), which wants pgvector as well as the
flag. One case set only the flag and passed locally off a dev .env, then
failed on CI's faiss default. An autouse fixture sets it for the module, so a
case that forgets fails for its own reason; the two about a different vector
store override it themselves.
2026-09-20 10:54:55 +01:00
Alex ef5b718f37 fix(graphrag): drop entities whose name normalizes to nothing
``canonical_name`` answers "" for a punctuation-only name, which callers are
meant to read as "no entity" -- ``_resolve_endpoint`` already does. Entity
extraction did not, and nodes merge on that key, so every such entity in a
source collapsed onto one shared node that belonged to none of them.
2026-09-20 10:45:05 +01:00
Alex 2b6d4d509e fix(graphrag): return a chunk once from entity_pages
The subject flag was in the GROUP BY, so a chunk two matching entities link --
one the page is about, one merely mentioned in it -- came back as two
identical pages and spent the caller's page budget twice on the same text.
It is aggregated with bool_or now, which is what the ordering wanted anyway.

Covered by a live test against a pgvector-shaped documents table: the graph
tables alone cannot answer this query, so nothing exercised it before.
2026-09-20 10:45:04 +01:00
Alex ccd8eb612f fix(agents): hand the graph tool's connection back, and gate it like the rest
Three faults in the graph tool, all on the agent's path:

The store was cached on the tool, and the executor caches the tool for the
whole agent run -- so one pgvector pooled connection stayed checked out across
every LLM round trip of that run, minutes at a time, and enough concurrent
runs exhaust the pool. GraphRAGRetriever releases its store before falling
back for this reason. The tool now releases it at the end of each action.

It gated on GRAPHRAG_ENABLED where everything else asks graphrag_available(),
which also requires the pgvector store. Under any other vector store the graph
tables are not the ones the sources were ingested into, but the tool was still
offered and still queried Postgres.

Pages were labelled by hand rather than through labels_from_metadata, which
exists so citation labels match across retrievers. A page read by the tool and
the same chunk retrieved by internal_search are one document, and citations
key on (source, title) -- so the research agent gave that document two
citation numbers. The recorded doc also keeps the full chunk text now, so it
dedupes against the retriever's copy; only what the model reads is truncated.
2026-09-20 10:45:04 +01:00
Alex bc0ef9f3b0 fix(retriever): search a graph source classically when its graph answers nothing
Only a raise routed a source to the ClassicRAG fallback, and every graph read
logs its own failure and returns empty. So a query that broke, a half-built
graph and a walk that genuinely found nothing were indistinguishable, and each
made the source contribute nothing at all to the answer -- no fallback, no
vector blend, which is skipped by the same early return.

A graph source that produces no documents now joins the classic batch, exactly
as a source with no graph already does.

The passage stage also rescanned every node's chunk list once per candidate.
It inverts the links once instead: 7.3 ms to 0.16 ms on a 400-node subgraph at
the candidate cap, with identical output.
2026-09-20 10:45:03 +01:00
Alex 8c5a5190d9 fix(retriever): carry a graph source's own options to the retriever
The three per-source graph options are read from the retrieval config the
Dispatcher hands over, and it only hands one over for a source it considers
overridden -- which it decided from chunks, score_threshold, rephrase_query
and prescreen alone. A source that changed only its graph options was not
"overridden", so nothing was carried and every graph source ran the defaults:
the UI toggles did nothing at all.

They count as an override now, for graphrag sources only. They mean nothing to
any other retriever, and an override also hands the source its own chunk
budget, which a classic source must not pick up from a graph setting.
2026-09-20 10:45:03 +01:00
arc53-machine 934ae1afb3 fix(pat): close message replay to restricted tokens, filter builtin tools, reject non-object bodies
Message tail and the ASGI reconnect stream cannot tie a message to an
allowlist, so any token with a resource filter is refused there. The tools
listing now applies the allowlist to default and builtin rows as well. Token
creation answers 400 instead of 500 for a JSON body that is not an object.
2026-09-19 23:54:35 +01:00
arc53-machine 17d66c061d docs: personal access tokens guide 2026-09-19 23:39:58 +01:00
arc53-machine 225d9d3065 feat(frontend): access tokens settings tab
Settings > Access Tokens lists a user's personal access tokens and lets them
create and revoke tokens: scopes grouped by family, optional restriction to
specific resources, and an expiry bounded by the server's policy. The secret
is shown once after creation and never stored client side. Removes the
unused legacy api-key endpoints, wrappers, type and locale block.
2026-09-19 23:39:58 +01:00
arc53-machine 82ec6cba0e feat(pat): enforce scopes and resource restrictions, deny by default
A central rule table maps each route and method to the scope a token needs;
a route that is not listed cannot be called with a token, and a test fails
when a registered route is left unclassified. Token management, admin, team
management, sign-in, device pairing and OAuth handshakes are never token
reachable, and a token never carries the admin role.

A token restricted to specific agents, sources, prompts, tools or workflows
is held to its allowlist: ids are checked wherever a route carries them,
listings are filtered, creation is refused, and routes whose rows cannot be
tied to the allowlist are closed. Agent import checks the resolved target.
2026-09-19 23:39:58 +01:00
arc53-machine 3a74aa23f0 feat(pat): token management API and admin revocation
Users list, create and revoke their own tokens under /api/user/tokens; the
plaintext is returned once at creation. Admins can list a user's tokens and
revoke any token, and the admin revoke-sessions action now revokes the user's
tokens too. Creation and revocation are written to auth_events.
2026-09-19 23:39:58 +01:00
arc53-machine 174130607a feat(pat): authenticate personal access tokens
handle_auth resolves a dgpt_pat_ bearer against the database instead of
decoding it as a JWT, for both the Flask and the ASGI routes. Scopes and the
resource filter always come from the token row, and the claims that mark a
PAT are stripped from decoded JWTs so a session token cannot pose as one.
ASGI routes reject tokens unless they name the scope that admits them, and
/api/user/me reports what the calling token may do.
2026-09-19 23:39:58 +01:00
arc53-machine 22ecc0aee3 feat(pat): add personal access token storage and settings
A personal_access_tokens table (migration 0032) holds scoped user-level API
credentials. Only the SHA-256 of the secret is stored, like device session
tokens. Lookups exclude revoked and expired tokens and the tokens of
deactivated users. PAT_* settings cover the feature switch, default and
maximum lifetime, the operator opt-in for non-expiring tokens and the
per-user cap.
2026-09-19 23:39:58 +01:00
Alex ecf02d0d60 fix(graphrag): take a source's write lock per chunk, and keep zero weights
Two builds of one source can overlap: the extraction lease is keyed by the
source's updated_at, and enabling a graph updates the source before it
dispatches, so a rebuild started while the last build runs gets a new key and
a lease of its own. Both builds could then pass a chunk's "done" check before
either committed and apply it twice -- doc_freq bumped twice, reproduced with
two live writers. A reset could also land in the middle of a chunk.

apply_chunk and delete_by_source now take a transaction-scoped advisory lock
keyed by the source before touching a row, as the schema bootstrap already
does for DDL. A single build's writes were already serial, so it loses
nothing; overlapping builds take turns chunk by chunk, and the second sees the
first's "done" row and returns (0, 0).

apply_chunk also still defaulted with `rel.get("weight") or 1.0`, turning an
explicit zero into a full-strength edge -- the conversion 3f774d81 removed from
add_edge and the ranker but missed here. Only a missing weight defaults now.
2026-09-19 20:27:27 +01:00