All notable changes to Aexy, documented.
Reviewing the campaign work against its own claims found the places where three
components still disagreed, or where a fix stopped one step short.
The wizard's send gate was workspace-wide; the backend's is campaign-specific.
"Send Now" appeared whenever *some* domain was verified, while start_sending
requires *this campaign's* From address to resolve to one. With acme.com verified
and a From of hi@other.com the wizard offered to send, the field below said it
would save as a draft, and the detail page then refused — three answers to one
question. The wizard now asks the same question the backend does, through a
client-side twin of email_matches_domain, and the Review step says which domains
would work rather than silently dropping the button.
"Send Now" had never sent. It routed to the campaign with ?action=send, and
the detail page read no search params at all — so the wizard's final button
created a draft and left the user to find Send themselves. It now fires the send
on arrival, still through the same confirm: an irreversible action deserves the
same prompt however you reach it.
A test send on a pooled campaign showed an address it would never use. The
endpoint resolved the domain through the pool but then sent from
campaign.from_email, while a real pooled send takes the From address from the
domain the router picked per recipient. Address resolution now lives in one place
(resolve_send_sender) that both the real send and the test send call, and the
response reports the address each test actually went out as. A test send that
reassures you about the wrong address is worse than no test send.
Scheduling no longer refuses an unverified sender. 0.14.0 gated it at schedule
time *and* in the poller, which meant you couldn't schedule next week's newsletter
while DNS propagated — even though the poller is built to hold exactly that
campaign until the domain verifies. Scheduling now records what it is waiting for
and leaves the gate to the poller, which is where it can actually be re-checked.
But the poller no longer waits forever. A blocked campaign was retried on
every poll indefinitely, with the reason living only in a worker's log. It is now
held for three days — long enough for DNS and for a weekend — and then handed back
as a draft with the reason on it, notifying the campaign's creator (new
campaign_send_blocked notification, email on by default since the send time has
already passed). last_error also rides the campaign *list*, so a blocked campaign
is visible as a Blocked chip without opening it.
A campaign built from "Custom HTML Content" could never be sent — found while
verifying the above in the browser, and it is the same shape. The wizard's Content
step offers custom HTML as an alternative to picking a template, but
start_sending requires campaign.template and process_campaign_sending cancels
a campaign whose template is missing. Confirmed against the running API:
POST /campaigns/{id}/send returns 400 *"Campaign must have a template"*. The
wizard's Send Now now requires a template as well as a sendable sender, and says
which of the two is missing. HTML-only still saves as a draft. Note this is the
gate reported honestly, not the underlying limitation removed: making the send path
render html_content is a feature, not a fix.
Two defaults could claim to be the default pool. create_pool cleared the
previous holder; the PATCH added in 0.14.0 — the path the *Make default* button
takes — did not.
`resolve_domain_for_email` fetched every domain in the workspace and filtered
in Python, once per recipient. The name match is now a SQL predicate, so a
workspace with fifty domains stops shipping fifty rows per recipient; the Python
rule still decides, so the two cannot drift apart.
A stalled backend could make the app unbuildable. community-api's server-side
fetches had no deadline, and community/[slug]/sitemap.xml is prerendered — so a
backend that accepted the connection and then hung took out the whole production
build at Next's 60s per-route limit, which is exactly what happened here. Those
fetches now time out at 10s and fall back to the empty result they already had a
path for.
Next 16 removed next lint and ESLint 9 stopped reading .eslintrc.*, so
npm run lint had been failing with *"Invalid project directory provided, no such
directory: .../lint"* and a bare eslint had no config to load. A flat
eslint.config.mjs restores it, and npm run lint now runs eslint ..
It finds 244 errors and 1039 warnings across the existing codebase — mostly
react-hooks/* rules from the plugin's v7 rewrite (86 set-state-in-effect) and
79 unescaped apostrophes. None are new; nothing had been checking. Left as the
shared config rates them rather than quietly downgraded, so the number is visible.
eslint-config-next to match.connect-calendar is back on the sales onboarding checklist — 0.14.0 dropped itto make room for the sending-domain item, which nothing required.
The pool endpoints had no client code at all, so a pool could only be created with
curl — which is why the routing they drive stayed broken without anyone noticing.
A Pools tab on Settings → Email Infrastructure creates a pool, picks its
strategy, and manages its member domains. Two details it gets right rather than
showing everything unconditionally: a new pool starts with every verified domain
already in it, because a pool with no members routes nothing; and the per-member
knob shown follows the strategy — weight only means something under *Weighted*,
priority only under *Failover*. Each strategy explains what it does, since
"health_based" is not self-evident. Members show whether they can currently send
and how much of today's limit they have used, so a pool that looks full but
cannot route is visibly so.
Two endpoints were missing and are added: PATCH /pools/{id} (used by the
strategy dropdown and *Make default*) and DELETE /pools/{id}, which refuses
while an unfinished campaign still routes through the pool and says how many —
a campaign's sending_pool_id FKs only to sending_pools.id, so deleting the
pool beneath it would leave it pointing at nothing and quietly fall back to the
platform mailer. A sent campaign does not pin a pool forever; only unfinished
sends do.
The campaign wizard offers a pool when one exists, and says plainly that picking
one means the From Email above will not be used — the pool takes the address from
whichever domain it routes to.
A sending pool spreads a campaign across several domains, picking the healthiest
per recipient — which is how you keep one domain's reputation from sinking a whole
send, and how transactional mail is kept off the marketing domain. It shipped with
the multi-domain infrastructure and had never executed once.
Nothing could set a pool. EmailCampaign.sending_pool_id and
sending_identity_id existed as columns and the send path branched on both, but
neither was on any schema or endpoint, so both were always NULL and both branches
were dead. RoutingConfigUpdate — the schema written to set them — was referenced
by nothing at all. They are now on campaign create/update and behind
PUT /campaigns/{id}/routing, validated against the caller's workspace, because
both columns FK to their own tables rather than to anything workspace-scoped.
And it would have crashed if it had run. route_email was called with
pool_id/strategy and no workspace_id (a TypeError), its RoutingDecision
return was read as a dict, and get_fallback_domain was called with
exclude_domain_id where it takes exclude_domain_ids — the same three mistakes
in both call sites. RoutingDecision.provider_id was also non-optional while the
column is nullable, so a pool member without its own provider raised a validation
error instead of falling back to the workspace default.
Fallback escaped the pool. get_fallback_domain selected from every active
domain in the workspace, so a campaign whose pooled domain hit its daily limit
would have sent from a domain the pool deliberately excluded — defeating the
separation the pool was built for. It now takes pool_id and stays inside.
The From address follows the domain. Pool routing picks the domain per
recipient, so the decision's from_email (from that domain's identity) now wins
over campaign.from_email. Sending as an address on one domain through another is
precisely what the sender gate exists to prevent.
And the gate asks the right question per mode. A pooled campaign's From is
chosen from the pool, so validating from_email against a verified domain would
refuse a perfectly sendable campaign. sender_status now reports its mode
(from_email / identity / pool) and checks accordingly — for a pool, whether
any member can send; the reason names the pool.
Creating a pool through the API had never returned successfully.
POST /pools committed the pool and then 500'd serializing the response, because
SendingPoolResponse.members lazy-loaded on an async session
(MissingGreenlet) — so the natural retry then failed on the unique name.
GET /pools/{id} had always eager-loaded correctly; create had not.
The Email Marketing empty state promised a four-step setup starting with *"Configure
a sending domain"* — and then offered one button, which skipped all four into the
campaign wizard. Following the instructions was the slower path. Underneath, the
domain it told you to configure was never read.
A campaign never used the workspace's own domain or provider. The send path
resolved a sending domain only through campaign.sending_identity_id or
sending_pool_id, and neither field exists on any schema or is set by any
endpoint — so both were always NULL, every campaign fell through to the
platform-global mailer, and mail went out from the deployment's own address
(noreply@yourdomain.com on an unconfigured install) rather than the From address
the user chose. ProviderService.get_default_provider had never been called by
anything. The path now resolves the domain from from_email, checks it with the
existing can_send, and sends through that domain's provider or the workspace
default. The platform mailer remains, but only for a workspace that has configured
no provider at all — and a provider that *is* configured and fails now fails the
recipient instead of quietly re-sending from the platform address.
The sender gate was workspace-wide, so it checked the wrong thing. It asked
"does this workspace have any verified domain?", which let a campaign send as
sender@somewhere-else.com on the strength of an unrelated verified domain — and
it was the only sender validation anywhere, since nothing compared from_email to
a domain. It now resolves *this campaign's* from_email, using the same rule
identity creation already used (extracted, so the two cannot disagree). Sending,
scheduling and test-sending all refuse with the same message, which names the
address and what to do about it.
A scheduled campaign reported success having delivered nothing. The poller
flipped a due campaign to sending and dispatched by hand rather than calling
start_sending, so it skipped both the sender gate *and* populate_recipients.
With no recipient rows the send activity found nothing pending and marked the
campaign sent. It now goes through start_sending; a refusal leaves the campaign
scheduled with the reason in a new email_campaigns.last_error, so it sends
itself once the domain verifies instead of needing to be rescheduled by hand.
The test send proved nothing. POST /campaigns/{id}/test bypassed every check
and used the platform mailer, so a test could arrive from the deployment's address
and appear to validate a sender that a real send would refuse. It now resolves the
sender the same way and reports which path delivered it.
And the Send button on the campaign detail page was disabled forever.
useSendingDomains returns { domains }, but the page destructured { data }, so
the value was always undefined — no number of verified domains could enable it.
Its status list also tested active and warming, which the frontend
DomainStatus union does not contain, leaving two of three arms dead. The campaign
payload now carries the backend's own sender verdict, so there is nothing left to
re-derive.
Setup comes first, and the steps know whether they are done. A panel derived
from live rows replaces the inert list: each step shows todo / pending / done —
"added but not verified yet" is a state worth distinguishing — and links to where
it is actually done. The primary action is *Set up a sending domain* until one
exists, with drafting still available beside it, because DNS propagation can take a
day. The wizard now warns on the Details and Review steps rather than after the
record is written, validates the From address for real (the type="email" input is
not inside a <form>, so the browser never checked it and hello world passed),
and offers addresses on verified domains. /email-marketing and
/email-marketing/campaigns show the same panel — they previously carried
different copy for the same empty state.
`bash
docker exec aexy-backend python scripts/run_migrations.py --file migrate_campaign_last_error.sql
`
It drew departments and nothing else, so a one-department workspace rendered as a
single row reading "3 members" and the reporting lines on
workspace_members.manager_id — which set_manager validates and rejects cycles
for — were visible nowhere. GET /organization/org-chart now returns each
department's members with their manager, and the chart nests people under whoever
they report to. Three queries for the whole chart rather than one per department.
Someone whose manager sits in a different department, or who has no manager set,
appears at the department's top level: most workspaces start with no reporting
lines, and a chart that only rendered nested people would show them nothing.
0.11.0 shipped a ticketing desk shaped like one insurance brokerage. The
stakeholders a ticket could wait on were a Python enum (KAM, INSURER,
PARTNER), the request types were another (POLICY_ISSUANCE, CLAIMS,
PAYOUT), and the master-data tables were named service_desk_partners,
service_desk_insurers and service_desk_lobs. None of it was editable, because
none of it was data.
Four industry templates, in service_desk_industry_templates.py: generic,
software_support, insurance_broking and financial_services. Each declares
its stakeholders, request types, departments and vocabulary as frozen
dataclasses, validated at import time — duplicate slugs, a stakeholder routed to
a department no template provides, more than one default request type, or
anything other than exactly one closed stakeholder is a startup error rather
than a runtime surprise. Templates are code, not rows, for the same reason
dashboard presets are: they are product decisions, and a half-applied one is
worse than none.
The enums became per-workspace tables. service_desk_stakeholders and
service_desk_request_types are editable, and each stakeholder carries a stable
semantics value (internal / external / closed). Code branches on
semantics; only humans read the slug and label. That is the pattern
WorkspaceStatusCategory already used, and it is what makes renaming "Insurer"
to "Carrier" a text edit rather than a migration.
Master data is named for what it is: accounts, vendors and products, with the
ticket foreign keys renamed to match. The insurance template deliberately pins
the old slugs (kam, insurer, partner, query, policy_issuance, …), so an
insurance desk reads exactly as it did before — the vocabulary moved out of the
schema, not out of the product.
**Everything else that was one operation's constant is now workspace
configuration**: the ticket prefix (BSD → a neutral SD), the digest hours,
the breach target and its amber threshold, the working-hours window the clock
resolves day boundaries in, and the eight terminology words the UI interpolates.
The frontend reads all of it from the API; statusColors.ts no longer holds a
label map keyed by insurance slugs, and assigns colours by position and
semantics.
First run. A desk with no taxonomy now shows a template picker instead of an
empty queue board, and applying one also creates the departments its internal
stakeholders route to — so Organization is populated as a side effect, which is
the ordering that actually works, because visibility rules resolve through
departments.
A read was writing configuration. list_stakeholders, list_request_types
and get_dashboard all seeded a default taxonomy when they found none, so merely
opening the desk pre-empted the first-run picker and left an eleven-stakeholder
mixture of two templates. Of the thirteen call sites, only ticket creation still
seeds — inbound mail must never be dropped for want of configuration.
`migrate_service_desk.sql` creates the current shape. It was still building
the insurance-named tables for the agnostic migration to rename moments later in
the same run, which was churn on a fresh install and a hard stop on a
Docker-first one: create_all had already made service_desk_tickets with
account_id, so CREATE TABLE IF NOT EXISTS no-oped and
CREATE INDEX … (partner_id) failed with *column "partner_id" does not exist* —
taking the whole run down with it, since the runner stops at the first failure.
Corrected in place, which is safe because a changed checksum is a warning the
runner will not act on without --force. Verified on all three shapes — nothing,
create_all-built, and legacy-migrated — which now converge on identical
columns, constraint names and foreign keys.
`bash
docker exec aexy-backend python scripts/run_migrations.py --file migrate_service_desk_agnostic.sql
`
The migration handles three database shapes, because create_all on startup may
already have built the new tables alongside the populated old ones: it renames
when only the old tables exist, copies then drops when both exist and only one
side holds rows, and refuses when both hold data rather than guessing which
is authoritative. It applies no taxonomy: an existing desk keeps its data, and
choosing a template is a deliberate act. Seeding a fresh workspace:
`bash
docker exec aexy-backend python scripts/seed_service_desk.py --workspace <id> --template insurance_broking
`
Settings had grown to 40 pages with no shared anything. Four different page-title
sizes, six competing per-page max-w-* values fighting the shell's own width, 18
copies of the same bg-card rounded-xl border block, and two unrelated ideas of
"loading" (17 bespoke skeletons, 22 spinners). bg-card is the *same* value as
bg-background in dark mode, so every one of those cards had no elevation at
all — the reason the whole area read as unfinished.
A design system: SettingsPage, SettingsSection, SettingsRow,
SettingsChoiceCard, SettingsEmptyState, SettingsSkeleton, SettingsSaveBar
and SettingsAccessDenied, on bg-surface so sections are actually raised. All
40 pages are migrated onto them, each with a useTranslations namespace and
English plus Hindi messages.
A landing page. /settings was a bare router.replace("/settings/appearance")
— "Settings" dropped you into the theme picker with no sense of the other 29
destinations, discoverable only by reading the sidebar top to bottom. It is now a
searchable index built from the same declarations the sidebar uses, and the
sidebar itself has collapsible categories, an inline filter and persistence.
CRM and Email Marketing settings moved in, to /settings/crm,
/settings/crm/integrations and /settings/email-marketing. They were the only
pages where "Settings" meant somewhere outside /settings, which also meant the
shell's permission gate never applied to them. The CRM page had carried two
navigation trees of its own — a section switcher and the object list — that would
have sat beside the Settings sidebar; the switcher is gone and Integrations has
its own URL, replacing a route that existed only to redirect into
?section=integrations. The old URLs redirect: /crm/settings cannot simply
404, because /crm/[objectSlug] matches it and a stale link would render an
object page for a nonexistent object called "settings".
`owner` and `admin` were byte-identical. Both role templates were
list(PERMISSIONS.keys()), so "owner" meant nothing. Seven permissions are now
owner-only — the four deletes, billing, and the two role-management
permissions — leaving the owner with 61 and an admin with 54. Roles are on that
list because an admin who can edit roles can grant themselves every other
owner-only permission and lock the owner out. An owner can still delegate any of
them to one person through the existing permission_overrides.
`Workspace.owner_id` is now authoritative. get_effective_permissions
resolves ownership up front but applies it last, after project-role replacement
and after overrides, so a transferred or externally-seeded workspace can no
longer leave its real owner on admin permissions — and an override cannot strip
the owner of their own workspace.
The client had no workspace permission data at all. usePermissions returned
a literal false for every workspace-level check, with a comment saying it should
be wired up. There is now a GET /workspaces/{id}/my-permissions behind it,
modelled on the project-level endpoint.
Every settings page declares a gate. The old mechanism was an adminOnly
boolean set on 10 of 30 entries — repositories, projects, task configuration,
integrations, escalation, ticket forms and billing were visible to every member.
A page you may not open is now hidden from the sidebar and the index, and
visiting the URL renders a panel naming what is missing and who can grant it.
Hiding a page is not access control, so the routers behind them enforce it
too: one require_workspace_permission dependency, plus a method-aware variant
for routers whose reads should stay open, applied to the eight that backed
ungated pages. A 403 now names the permission it wanted.
39 phantom permission constants were removed from the frontend map, which had
drifted to naming permissions the backend has never defined — can_manage_webhooks,
can_view_teams, can_delete_workspace — while missing 30 real ones. Gating a
page on a key that does not exist hides it from *everyone*, permanently, with no
error anywhere. A test now reads the backend catalogue directly and asserts the
two agree, that every non-personal page is gated, and that every nav entry points
at a page that exists on disk.
models/team.py, api/workspace_teams.py, teamApi and the useTeams hooks
were all complete, and ten places *consumed* teams — escalation routing, on-call
rotations, standups, insights, tickets, forms. There was simply nowhere in the UI
to create one, so a workspace could only get a team by calling the API directly.
The page manages the list, members and in-team roles, the optional department
link, and repository-backed sync. Deleting a team is owner-only.
Only platform staff ever saw the admin-only settings pages. The gate called
useAdmin, which reports Aexy staff, not workspace admins — so a workspace owner
was denied their own Organization, Billing and SSO pages while an Aexy employee
saw them in every tenant.
Sidebar tooltips broke the layout they wrapped. SimpleTooltip renders an
inline-block wrapper, which re-flows block children; a caller passing
className="block w-full" now wins, since cn is tailwind-merge.
`GET /crm/objects` returned 500 for any workspace that ran the standard seed.
seed_standard_objects creates a Lead object, but the response schema's
CRMObjectType literal omitted "lead" — the response model rejected a row the
service itself had written. The frontend union also gained the "project" type
the backend allows.
The Google OAuth callback pointed at a route that no longer exists in ten
places, so completing a connect landed on a dead page.
The email-delivery page fetched with a null caller, and the CRM attribute list
offered two affordances with nothing behind them: rows were draggable with no
endpoint to persist an order, and an edit button set state nothing rendered.
Four `service_desk` entries had gone missing from the stock app bundles, which
would have left new workspaces without the module. Its own module blurb still
read "Partners, insurers, LOBs" as well.
Review follow-up on 0.11.0. Three of these are tenancy bugs, one is an
authentication gap, and the rest is the module quietly being Bimaplan-shaped in
ways no other workspace could change.
A mailbox could point at another workspace's Google account. integration_id
arrived in the request body and was stored unchecked; it only FKs to
google_integrations.id, and the Gmail-sync fan-out matched on that id alone.
A service-desk manager who knew another workspace's integration id therefore got
that workspace's inbound mail filed as tickets *in their own desk* on the next
sync, and outbound closure mail sent *as them*. Now: refused at create/update
time, the sync lookup is scoped to the integration's own workspace, and the
mailer refuses a mismatch outright — every other body-supplied id on this branch
was already checked (_validate_refs, _require_own, the convert_to_task team
check); this one had been missed.
Two workspaces could claim the same mailbox address. The unique constraint is
per workspace, but the inbound webhook has no workspace context and resolves
to → mailbox across all of them, picking the oldest. Registering an address you
don't own therefore diverted someone else's mail — first registration won.
Addresses are now globally unique among service-desk mailboxes.
The inbound-mail webhook was unauthenticated. /webhooks/email/inbound
verified nothing, unlike the four event webhooks hardened under WS-057/WS-082.
Since 0.11.0 it also creates tickets and sends an acknowledgement to the address
in the payload, which made it both a ticket-injection vector — spoof a partner
domain and land in that partner's KAM queue — and an email reflector on the
workspace's own sending domain. It now accepts Postmark Basic Auth, a Mailgun
signature block, or a shared token in the URL/header, and falls through to the
existing webhooks_require_signing rule (default True, so an unconfigured
deployment fails closed). SendGrid Inbound Parse does not sign its posts at all,
which is why the token exists.
Migrations could not be applied in order. migrate_org_onboarding.sql sorted
*before* migrate_org_structure.sql (o < s) but references departments,
which the latter creates — and run_migrations.py stops at the first failure, so
the whole 0.11.0 migration set failed on any database that didn't already have
the table from create_all. Renamed to migrate_org_structure_onboarding.sql.
The three partial unique indexes are now declared in the model metadata as well
as in SQL, because create_all only builds what is in the metadata: a
Docker-first environment could accumulate rows that made CREATE UNIQUE INDEX
impossible afterwards. And accepting an invite no longer writes a second
is_primary department row — which violated exactly that index, and was
swallowed by the placement's own error handler, so the person joined with no
department at all: the one outcome the feature exists to prevent.
Everything that was one customer's operation is now per workspace. The ticket
prefix (BSD, for "Bimaplan Service Desk", written as a constant in four
modules), the timezone the breach clock resolves day boundaries in
(Asia/Kolkata, hardcoded), the 2-business-day breach target and its amber
warning, and the built-in email copy that signed off as "Bimaplan Operations" —
so every other company sent Bimaplan-branded acknowledgements until someone
edited three templates. All of it is editable on Service Desk → Master Data,
and every default reproduces the previous behaviour exactly: display ids are
rendered from ticket_number rather than stored, so changing the default would
have silently relabelled existing tickets. Subject-line threading accepts the
workspace's prefix *and* the legacy BSD, so mail already in flight still lands
on the right ticket. Service Desk is also no longer force-enabled in all four
stock app bundles; it stays in the catalog and is enabled per workspace, which is
appropriate for a module with insurers, LOBs and KAMs in its data model.
Also fixed: the closure email was sent inside the request, before the
transaction committed, so a rollback left the requester told their ticket was
resolved — it is queued and flushed after commit now, like intake already did.
The digest no longer mails people who have left the workspace (department rows
outlive membership, which is why intake's _random_kam already joined
WorkspaceMember), isolates each workspace so one bad template can't cost every
later workspace its digest, and runs with a 30-minute activity timeout instead of
the 300s default it inherited while fanning out over every workspace. A
department head and a headcount seat must now be workspace members —head_id
decides who receives the entire desk's open-ticket list. A duplicate
function_key returns 409 naming the clash instead of a 500. Reopening a ticket
clears resolved_at as well as closed_at. Manual logging calls a public
create_ticket rather than reaching into intake's private method. And
TemplateService renders through Jinja's SandboxedEnvironment: template bodies
are authored through the API, and can_manage_service_desk (which defaults to
include support) had made a plain environment reachable by more roles.
test_digest_builder asserted a 3-calendar-day age against a clock that only
accrues working hours, so it passed on a Thursday and failed on a Monday — the
same trap 0.11.0's own changelog describes fixing in test_service_desk_tat.py,
missed one file over.
Inbound mail now requires a credential. Set one and put it in the provider's
webhook URL, or inbound mail will 401:
`bash
INBOUND_EMAIL_WEBHOOK_TOKEN=<random string>
`
Two things a workspace owner could not previously say: "no AI on our data", and
"use our Anthropic account, not yours".
The kill switch. Every module that used AI carried its own toggle — Service
Desk's ai_classification_enabled, agent settings, file understanding — so
turning AI off across a workspace meant finding each one and hoping nobody
shipped a new module next week. Settings → AI & Providers now has a single
switch for the whole workspace, with an optional reason recorded alongside it so
the answer to "why has nothing been classified since Tuesday?" is in the row
rather than in somebody's memory.
It is enforced in llm/gateway.py, at the point a provider is resolved — not at
the API edge. A switch that only guarded HTTP handlers would leave every Temporal
activity, scheduled job and inbound webhook still calling out, which is most of
the AI in the product. analyze, analyze_batch, extract_task_signals,
call_llm and score_match all route through the same check, and the
vision/embeddings helpers are covered through their shared rate-limit gate — image
understanding of a workspace's own files is AI processing of that workspace's
data, whatever the endpoint is called. The check sits *before* the analysis cache,
so a disabled workspace gets a hard stop rather than a previously-generated
answer. It raises AIDisabledError, deliberately not ValueError, because
Temporal's LLM retry policy treats ValueError as non-retryable and everything
else as worth retrying for an hour — a disabled workspace is neither.
Your own provider. An owner or admin can point the workspace at claude,
gemini, openrouter, deepseek, ollama or lmstudio with its own key, model and (for
self-hosted) endpoint. The credential is stored with the same Fernet envelope as
integration credentials and has no read path at all: the API returns only the
last four characters and when it was installed. Provider instances are cached by
a fingerprint that includes the key, so rotation takes effect immediately and a
per-call httpx client isn't leaked on every LLM call.
allow_platform_fallback defaults to off. An organisation that supplied a
key did so precisely so its prompts would not travel through the platform's
account; falling back the moment that key misbehaved would defeat the point
without anyone noticing. With fallback off and the key unusable, the workspace
stops instead. Rate-limit accounting follows the provider actually used, so a
workspace on its own Claude key is no longer counted against — or throttled
by — the deployment's provider bucket.
Editing these settings requires the Pro or Enterprise plan and an owner/admin
role. *Enforcement of an existing setting is not plan-gated:* a workspace that
turned AI off keeps it off after a downgrade, and gets a 402 if it tries to turn
it back on. Silently resuming LLM calls on someone's data because a card expired
would be the worst available failure mode for this particular switch. Reading the
settings is open to any member — whether AI is on is about their data.
A live Test connection button probes the configured provider with a
one-token prompt and reports the provider's own message, because "wrong key" and
"wrong model name" and "endpoint unreachable" otherwise all present identically:
as a batch of features quietly degrading, hours later, in a worker log.
Two provider bugs found while testing this, both fixed. ClaudeProvider
used the full messages endpoint as its httpx base_url and then requested "",
which resolves to /v1/messages/ — with the trailing slash — and Anthropic
answers 307 to the un-slashed form. httpx does not follow redirects by
default, so *every* Claude call raised instead of completing; it went unnoticed
because this deployment defaults to Gemini. And a workspace that selected a
provider without naming a model was handed the deployment's model name
regardless of provider, so picking Claude on a Gemini deployment asked Anthropic
for gemini-2.0-flash.
An absent settings row means "platform default, AI on", so nothing changes for
any existing workspace and no backfill is needed. If
migrate_workspace_ai_settings.sql has not been applied yet, the lookup degrades
to that default in a savepoint rather than taking every AI feature down —
fail-open for exactly that one error, and fail-closed for anything else, because
ignoring any other failure would mean ignoring a switch somebody deliberately
turned on.
`bash
docker exec aexy-backend python scripts/run_migrations.py --file migrate_workspace_ai_settings.sql
`
Three parts, released together: the two modules, the onboarding paths that
turned out never to place anyone in a department, and the breach clock.
Adds two new modules. Organization models the company itself — departments,
reporting lines, headcount — and Service Desk is an email-first ticketing
desk for Bimaplan's insurance operations: mail sent to a shared mailbox becomes
a ticket, gets classified and auto-assigned to a KAM, and is tracked by *who
currently owes an action* rather than by a status column.
Organization structure. departments is a materialised-path tree
(path/depth, so subtree reads are one LIKE query and reparenting rewrites
descendants in a single statement), plus department_members with
head/manager/member roles and per-person allocation, and department_positions
for planned-vs-filled headcount. Two joins into existing tables: delivery teams
roll up via teams.department_id, and people-level reporting lines live on
workspace_members.manager_id — both nullable, so existing workspaces are
unaffected. A function_key (ops_kam, sales, finance, hr, …) marks what
a department *does*, which is what Service Desk routing keys off, so ops can
rename "Operations" without breaking assignment. Frontend: departments manager,
org chart, and a people directory. migrate_org_structure.sql.
Email → ticket. Service Desk tickets are ordinary Ticket rows
(source='service_desk_*') with a 1:1 service_desk_tickets extension, so they
inherit comments, attachments and audit trail — but they're filtered out of the
generic tickets list and stats, which stay the general-purpose module. Intake
accepts both inbound-parse webhooks and Gmail sync, resolves the sender to a
partner (by email domain) or insurer, picks the request type (query,
policy_issuance, claims, payout), assigns the partner's KAM (falling back
to a random active member of the ops/KAM department), and sends an
acknowledgement. Replies thread back onto the original ticket by subject token
(BSD-123) and reopen it if it had been closed.
Pending-with ledger. Instead of a status field, every hand-off appends to
ticket_pending_segments — an append-only ledger of who held the ticket and for
how long. The TAT/breach clock counts only time held by *Bimaplan* functions
(kam, sales, finance, marketing), so a ticket parked with an insurer or
partner doesn't accrue against us. Dashboard aggregates open volume, breaches
and per-function load off the same ledger.
Master data, templates, digest. Workspace-scoped partners (with KAM +
domains), insurers, and lines of business drive classification and assignment;
the three customer-facing emails (receipt, hand-off, closure) are editable
templates with a live preview. A Temporal schedule (service-desk-digest,
09:00/13:00/17:00 IST via new cron support in schedules.py) mails each KAM
their open tickets. Any ticket can be converted into a sprint task, linked both
ways.
Authorization. require_app_access only checks the workspace-wide module
toggle — and defaults to *enabled* — so it says nothing about who is asking. A
new require_workspace_member() guard is mounted alongside it on both routers,
where a future endpoint can't forget it; mutations additionally require
can_manage_service_desk / can_manage_org, and every by-id ticket path (not
just the list) applies the KAM row-scope clause, 404-ing rather than 403-ing so
out-of-scope ids stay unenumerable. Cross-workspace ids passed as
partner_id/lob_id/project_id are validated against the caller's workspace.
Intake is idempotent per Message-ID (service_desk_ingested_messages, enforced
by a unique constraint rather than a read-then-write check), subject threading is
joined to service_desk_tickets so a Re: BSD-7 can't attach an external
sender's mail to an unrelated generic ticket #7, ticket-number collisions retry
on a savepoint, and outbound mail is queued and flushed only after commit so a
requester can't be acknowledged for a ticket that rolled back.
Read-only UI. Both modules tell the client what the caller may do, so pages
stop offering actions that would only 403: Service Desk returns can_manage on
the settings payload the Master Data page already fetches, and Organization —
which has no settings object — gets a small GET /organization/my-permissions,
named after the existing projects.py::get_my_permissions. Non-managers see the
data with an explanatory banner and no controls.
Fully internationalised (new serviceDesk + organization namespaces, en + hi).
Migrations: migrate_org_structure.sql, migrate_service_desk.sql,
migrate_service_desk_hardening.sql.
The Organization module shipped with departments, reporting lines and Service
Desk routing that all key off department membership — and no path that ever
creates it. Workspace creation seeds no departments, the invite carried only
email and role, and addMember/removeMember/addPosition/setManager existed
in the API and the hooks with no caller anywhere in the UI. The only way to
place a person in a department was the seed script. So every new joiner landed
unassigned: invisible in the directory (which iterates departments), permanently
out of scope for Service Desk row filtering, and ineligible for KAM
auto-assignment.
The seed produced unusable KAMs. It created a Developer and a
DepartmentMember but never a WorkspaceMember. Since auto-assignment requires
an active workspace member and every Service Desk route sits behind
require_workspace_member, seeded KAMs could not be assigned a ticket and could
not open the workspace at all. Fixed, and re-seeding now reactivates a
previously-removed KAM instead of silently skipping them.
Membership is confined to the workspace. add_member accepted any
developer_id on the platform and returned that person's name and email, so it
doubled as a cross-workspace read of someone else's contact details;
set_manager accepted a manager from another workspace (the column FKs to
developers.id, not to workspace_members) and accepted reporting cycles — A→B
plus B→A was fine, which would make anything walking the chain recurse until it
ran out of stack. Both now require an active member of the same workspace, and
cycles are refused by walking the proposed manager's chain.
Somewhere to actually do it. A department roster dialog on
Organization → Departments wires up the four orphaned mutations: add and remove
people, change head/manager/member, and define positions (the department detail
read now returns positions, which it previously accepted writes for and never
returned). The person picker offers only people not already in the department and
flags the ones in no department at all. Everything is gated on can_manage_org,
so a read-only caller gets the roster without the controls.
Unassigned people are visible. A new GET /organization/people walks from
workspace membership rather than from departments — the only read that can show
someone who belongs to nothing. The directory now renders an "Unassigned"
group off it (and dropped its per-department N+1 reads), the
workspace members settings page shows each person's departments or an
"assign" link, and reporting lines are finally readable and editable there
instead of manager_id being a write-only column.
Optional department on invite. workspace_pending_invites gains nullable
department_id and role_in_department (migrate_org_onboarding.sql), applied
on accept as the person's primary department with source="invite". It stays
optional by design — an admin inviting someone in a hurry is never forced to
settle the org structure first. A department that no longer exists cannot cost
someone their invitation: the placement runs in a savepoint and only logs. A
mistyped id is rejected at invite time rather than silently doing nothing days
later. Pickers appear in the settings invite dialog and the onboarding wizard,
and only when the workspace actually has departments to choose from.
`can_view_org` / `can_view_service_desk` are enforced. Both were in the
catalog and advertised by app_definitions from the start, but nothing checked
them, so revoking someone's access to a module had no effect. A new
require_workspace_permission guard is mounted on both routers. developer is
added to can_view_service_desk's defaults because the legacy workspace role
member maps to that template and a KAM is usually a plain member — opening the
module is not the same as seeing everything in it, and row-level scoping is
unchanged. In practice the gate bites on per-member overrides and custom roles;
every legacy role that clears the membership guard still has both permissions.
An empty ticket list says why. GET /service-desk/settings now reports
scope (all / function / none), so the tickets page can tell a quiet day
apart from "you are in no department, so nothing can ever match you" — the state
a new joiner is in, and previously indistinguishable from having no work.
Also fixes a latent TypeError in invite acceptance: expires_at was compared
directly against an aware datetime, which raises rather than returning False if
a naive value ever reaches it.
The BRD's ">2 days in the same stage" was implemented as calendar days, so a
ticket arriving Friday evening was already red by Monday morning — three days
elapsed, not one of them a working hour. The clock now measures **2 business
days of working time**: it accrues only inside the shift and stops overnight, at
weekends, and on holidays.
One "day" is one shift, not 24 hours, so to_days divides by the shift length
and the 2-day target is 18 working hours on a 09:30–18:30 day. A ticket arriving
17:30 on Friday has one hour of allowance left that day, reads 1.11 days at
Monday's close, and does not breach until 17:30 on Tuesday — by which point four
calendar days have passed.
The shift defaults to 09:30–18:30 IST and Ops can change it themselves from
the Master Data page — PATCH /service-desk/settings now takes
working_hours_start/working_hours_end (gated on can_manage_service_desk)
and persists to Workspace.settings["service_desk"]["working_hours"], so no
migration is needed. The patch is partial, so flipping the AI toggle can't wipe
the hours. An inverted or malformed window is refused at the API rather than
saved, and the change is logged with the actor, because moving the window
re-scores every open ticket's stage age. Clock still falls back to a 9h day if
it meets bad data, but that guard is for rows written before the validation
existed — not a licence to save nonsense. Boundaries resolve
in Asia/Kolkata — whether an instant falls inside Tuesday's shift depends on
the timezone you ask in, and 13:00 UTC is exactly the 18:30 IST close. IST has
no DST, so the boundaries are unambiguous.
Holidays come from the Leave module's existing holidays table rather than a
hardcoded calendar — its own docstring says it is for business-day calculations.
Only mandatory, workspace-wide entries count: optional holidays are not days off
for everyone, and an SLA that changes depending on which team you ask about is
not an SLA.
Overall TAT deliberately stays wall-clock. Stage and per-stakeholder figures
answer "are we late?" and must not accrue over a weekend; overall answers "how
long has the requester been waiting?", and they waited through the weekend. The
two are now labelled distinctly in the UI ("Current stage (business days)" vs
"Overall TAT (elapsed)").
All of it lives in one new services/service_desk_clock.py. The threshold was
previously written four times — as a bare > 2 in the ticket service, again in
the digest service, and a third time in the digest email copy — so it could
drift silently. The segment ledger's duration_seconds is untouched: it remains
the wall-clock audit record of each hand-off, and business time is recomputed
from the segment boundaries rather than trusting that column.
The existing red-breach test asserted on now - 3 days, which under a business
clock passes on a Thursday and fails on a Monday. It now uses a 7-day window,
which is exactly five business days whatever day the suite runs on.
Reported as "file upload on the public form is not working". It wasn't, but
chasing it turned up a second, wider failure with the same shape: files that
uploaded perfectly and then could not be fetched by anyone.
The form never sent the file. The file field's handler called
onChange(file.name) — it put the *filename* into the submission and threw the
File away. The submit payload is JSON, so bytes could never have ridden along
with it, and no upload endpoint existed to send them to. A submitter picked a
file, saw its name appear, submitted, and got a ticket whose attachment field
was a string. The field's own rules (max_file_size_mb, allowed_file_types)
were never enforced either, because there was nothing to enforce them against.
There is now POST /public/forms/{token}/uploads. The page uploads as soon as a
file is picked and submits a reference to the stored object. The reference is
HMAC-signed: attachments on a form-created ticket are readable through that
ticket's public share link, so accepting a caller-supplied storage key would
have let anyone attach — and then read — any object in the bucket. Unsigned,
tampered, or cross-form references are dropped rather than trusted. Being an
unauthenticated endpoint it is also bounded by a per-IP rate limit, the field's
size and MIME rules, and a per-field count cap.
Task attachments uploaded fine and then 404'd. These were never broken at
the upload step — the bytes are in storage. What was stored alongside them was a
dead link. get_object_url() composes {S3_PUBLIC_ENDPOINT_URL}/{bucket}/{key}
and that value went into task_attachments.file_url, but nothing serves the
configured public path in production — the request reaches the API, which
correctly 404s a route it has never had. Objects are also written with no
public-read ACL, so an unsigned URL is refused (403 AccessDenied) even where
the proxy does exist. Two independent reasons the link could not work, which is
why it looked like an upload bug: the failure only ever showed up at read time.
Client-facing URLs are now presigned per response and never persisted — a
signed URL expires, so storing one only moves the problem. Rows carry
storage_key, and the ticket module's existing approach (store the private key,
never a public URL) is now what every one of these surfaces does.
Audited the rest. Ticket attachments and compliance documents were already
correct. Chat presigns. Two more had the same defect and are fixed here:
* Drive persisted the same dead URL, and the Drive UI opens file_url
directly while the Docs viewer renders it into <img>/<video>.
* Assessment proctoring recordings *did* presign, but recovered the key by
splitting the URL on .r2.cloudflarestorage.com/ — an R2-only form. On the
path-style URLs this deployment actually writes that yields no key, silently,
inside a try/except. Recordings never played back and nothing logged why.
Key recovery now handles both addressing styles.
That last pattern also explains a quieter casualty: the AI metadata pipeline
prefers file_url over file_key, so it had been fetching these dead URLs too.
Summaries and tags for drive files and task attachments were failing for the
same reason the previews were.
Existing rows repaired. migrate_storage_keys_backfill.sql recovers
storage_key for every task attachment and drive file from the URL already
stored. Nothing was lost — upload success was always checked — so recovering the
key is enough to make old attachments load again. The derivation keys off the
object prefix rather than the bucket name, so it holds across deployments with
different S3_BUCKET_NAME values, and it skips rows whose URLs don't match
rather than writing a wrong key; those still resolve through the read-time
fallback.
The object storage route must pass the path through unmodified. SigV4 signs
the URI path along with the Host header, so the previous
rewrite ^/storage/(.*)$ /$1 silently invalidates every presigned URL
(SignatureDoesNotMatch). nginx/nginx.conf now serves storage from a
bucket-rooted location with no rewrite:
`
location /aexy-storage/ { # must equal S3_BUCKET_NAME
proxy_pass http://rustfs;
}
`
paired with a bare origin — S3_PUBLIC_ENDPOINT_URL=https://server.aexy.io,
no /storage suffix (the prod default is updated). This needs no new DNS record.
Deployments whose edge is not this nginx must apply the equivalent rule there;
until they do, attachment URLs will fail at the edge rather than at storage.
Reported by the tech team using the feature. A task set to review didn't land in
the wrong column — it left the board entirely.
Two spellings of the same status exist. The seeded status row is in_review
(task_config_service.DEFAULT_STATUSES), but the shared UI STATUS_CONFIG map
— which every status picker writes from — said review, as do the keyboard
shortcut and several hardcoded lists. The kanban builds its columns from the
seeded slugs and buckets tasks by sprint_tasks.status, so a task stored as
review went into a bucket no column reads and was never rendered.
That also explains why it looked erratic: dragging a card onto the In Review
column always worked, because the drag handler uses the project's real slugs.
Only the status dropdown, the edit modal, and the 4 shortcut broke it.
Canonicalised on write. SprintTaskService.canonical_status_slug resolves
whichever spelling a caller sends to the one that task's own board has a column
for, and all three write paths use it. So it no longer matters which spelling
arrives — an older client, the Slack integration, or a UI path nobody has found
all store something renderable. A workspace whose status set genuinely uses
review keeps it; the alias resolves toward the board, not toward one spelling.
Existing rows rescued. migrate_task_status_review_slug.sql moves stranded
tasks, per workspace so a set that legitimately uses review is untouched. It
also repairs two things the status column alone would have left broken:
* Both sides of each history transition. Rewriting only the destination left
a later row reading review → done right after an earlier one reading
todo → in_review — a timeline that jumps through a status which no longer
exists.
* WIP limits. They live in sprints.settings->'wip_limits' keyed by status
slug and are read back by slug, so a limit set on review silently stopped
applying once the column became in_review. No error — the cap just never
fired again.
And it can't hide a task again. The board now renders an amber
"unrecognised status" column for anything matching no column. A card in an ugly
column is a nuisance; a card that vanishes costs someone their work.
A place to put a credential that is not the workflow definition. Minor rather
than patch: a new stored resource with its own settings surface, a palette
action that was never runnable becoming runnable, and several behaviour changes
that affect automations already saved (see Upgrade notes).
Also covers #218 and #219, which merged without versions of their own.
Workspace secrets. Named values, Fernet-encrypted, referenced from a step
as {{secrets.NAME}}. Managed under Settings → Security → Workflow Secrets.
There is no endpoint that returns a value — not to a member, not to an admin,
not to whoever saved it — so rotation is an overwrite and a lost credential is
replaced rather than looked up. The builder inserts references from a picker on
webhook headers and on the api_request auth fields.
Run an automation by hand (#218). A published automation can be run for one
chosen record from the builder. It refuses up front — paused, over the monthly
allowance, record from another workspace, record of the wrong type — rather
than reporting "triggered" for work that cannot happen. The response promises
only that the run started; the outcome lands in run history.
workspace.** Header templates live in the workflow definition and reading a
workflow needs only member. Pasting one is now refused at save, with the
reference offered in its place.
records the response body, and receivers commonly echo the request they were
sent, so the value returned by the far end landed in the run log. Resolved
values are scrubbed from the stored response.
credential straddling the 1000-character cut was sliced in half and the
remaining prefix matched nothing. Ordering is now an invariant of the helper.
wrote api_url/api_method/api_body while the executor read
webhook_url/http_method/body_template, so every step failed on "No
webhook URL specified"; meanwhile its Bearer Token and API Key fields were
read by nothing and sat in the workflow definition in plain text. Both
executors now read those keys and apply the auth config as a header, from a
secret reference only. The action leaves the hidden set.
copy-pasted from webhook_call; the headers field invited a credential into
the graph to no purpose. Removed, stripped on save, and cleared from existing
definitions, versions and templates by migration.
Templates render before secrets resolve and the renderer rejects any
unresolvable {{...}}, so every {{secrets.NAME}} header failed with
"Dynamic value is missing".
dropped with a 200 — runs_this_month looked resettable and was not, and the
builder's trigger sync had never once taken effect, leaving the canvas and the
stored trigger free to disagree. Unknown fields are now refused.
belonging to another workspace was accepted outright — the foreign key has no
workspace in it. Both are refused with a 400.
image transitively, so production worked by accident while a clean checkout
could not run the async test suite. uv.lock had also drifted from
pyproject.toml for several releases.
migrate_workspace_secrets.sql and migrate_strip_inert_slack_config.sqlare picked up automatically. The second rewrites stored workflow JSON —
definitions, version history and templates — to drop the dead send_slack
keys. It only touches rows that carry them.
api_request auth field now blocks save, and fails the step at run time for
workflows saved before this release. Move those values into a workspace
secret before deploying — an api_request step with a pasted token was
sending unauthenticated requests regardless.
module does not offer now returns 422 instead of silently succeeding.
SECRET_KEY loses them, and there is no read path to export them first.
HTML-escaped or URL-encoded would not be caught.
recorded response.
api_request auth fields only — neverinto a body, subject or message, where they would reach run history or an
inbox.
Both halves of the release — the authoring foundation (#214, which merged
without a version of its own) and the durable execution layer (#215). Minor
rather than patch: a large feature, and it carries one behaviour change that
affects automations already running (see Upgrade notes).
Authoring and triggers. The canvas persists nodes, edges and per-node
configuration, and the palette is driven by the backend capability registry, so
an unfinished step is hidden rather than offered. Invalid configuration blocks
save and publish with per-node reasons. Record created/updated/deleted,
field-changed, list membership, form submission and tracked email open/click
all start matching automations.
Durable execution. A canvas containing timing, logic or AI steps runs on
Temporal instead of the inline executor: conditions route, waits survive a
worker restart, branches record the rule they matched, and agent output flows
into later steps through workflow variables. Action-only canvases keep the
inline path.
Delivery honesty. Automation email is recorded in an outbox inside the same
transaction as the run, so a send can no longer be handed to a worker that
cannot yet see the run. Steps report queued, sent, failed or needs-review
rather than an optimistic success, and a run abandoned without an outcome is
closed by a reaper instead of sitting on "running" for good.
accepted structural nodes while only wait was routed durably, and
flattening keeps only action nodes — so "if deal value > 50k, notify the VP"
published cleanly and then notified the VP on every deal, every step green.
as a company name was delivered live to the recipient.
replayable URL, each starting another automation run. First one only.
Temporal, internal APIs. The target must now resolve to a public address,
enforced in both executors. ALLOW_PRIVATE_WEBHOOK_TARGETS re-enables
internal targets for self-hosted deployments.
incremented at the end, so concurrent triggers all passed at the limit.
the executor, the email activity, the outbox and the reaper.
could quietly lose its "sent".
behind a Temporal patch gate, so an execution started earlier replays its
original path.
node could enqueue a second independent email, Slack message or campaign.
while email queued left the step green.
amount < 100matched every record with no amount.
success and failure alike, with no per-step detail.
migrate_automation_email_outbox.sql and migrate_automation_delivery_attempts.sql are picked up automatically.
Apply normalize_crm_automation_trigger_types.sql explicitly after reviewing
its preview.
worker consumes both the workflow and integration queues.
{{...}} reference now fails its stepinstead of rendering empty. Deliberate — a blank recipient or body is worse
than a visible failure — but it affects automations referencing an optional
field that happens to be unset. Audit live automations before deploying.
agent nodes: the older executor flattens those away.
reported as needing review rather than retried automatically. Only the
provider's log can say whether it was delivered.
Clicking any task on /sprints?tab=tasks navigated to that task's project
board (/sprints/{projectId}/board?task={taskId}) and opened the detail modal
there. Closing it left you on a board you never asked for — the only way back
to All Tasks was the browser button, and every filter, search term and view
you'd set up on the tab was gone. The tab was doing this to avoid duplicating
the board's task detail modal.
components/sprints/EditTaskModal.tsx, extracted verbatim out of
sprints/[projectId]/board/page.tsx (~1.3k lines) along with its
AssignmentHistoryPanel helper. It was already route-agnostic — every
scoped call derives its ids from task.sprint_id / task.team_id — so no
behavior changed on the board. The STATUS_CONFIG / PRIORITY_CONFIG /
SPRINT_STATUS_COLORS maps both files need moved to
components/sprints/taskFieldConfig.ts.
?task={id} (via replace, not push, so closing doesn't need two Backs),
so refresh and link-share reopen the same task over the same filtered view.
Lookup prefers the already-loaded workspace list and falls back to an
archive-inclusive fetch, so a deep link opens even when the task is archived
or sits past the 1000-row cap. The sprint picker is scoped to the task's own
project — the workspace list spans all of them.
sprint or project API the way useProjectBoard does but taking the owning
project id per call, since this tab spans projects. Also exposes epics for
the modal.
chat deep links, but skips that redirect on the Tasks tab, which now owns the
param. Switching tabs drops task so it can't re-arm the redirect elsewhere.
n / / hotkeys are now disabledwhile the detail modal is up (the shortcut hook only ignores keystrokes from
inputs, not a focused <select>), and the modal's attachment add/delete
invalidate through invalidateTaskCaches so the workspace list refreshes
too, not just the sprint/project ones.
Extends the public /community/{slug} forum so that, once you sign in, it stops
being a read-only crawlable page and becomes a hub: members see their internal
(non web-public) threads inline, can start new threads without leaving the page,
and non-members get a CTA to spin up their own community. The public shell stays
anonymously ISR-cached — all member content hydrates client-side from a new
authenticated endpoint, so nothing private ever touches the shared cache.
Authenticated member context. New GET /public/community/{slug}/me resolves
the caller's workspace membership and returns the internal channels/topics they
may access, their role, and what they may do (can_create_thread,
can_post_public). Access mirrors in-app chat exactly — never the public
predicates: DMs and archived channels are excluded, web-public channels are
omitted (they're already in the public view), a private channel needs
membership, a private topic needs channel membership, and a restricted topic
needs an access grant. Non-members get an is_member:false payload with no
workspace_id and no channels (a 200, not a 403), so the client can offer the
"start your own community" CTA without leaking anything. A web-public topic
nested in an otherwise-internal channel is kept but flagged is_web_public so
the UI can badge it. Backed by a dedicated CommunityMemberService, kept
separate from the deliberately-anonymous PublicCommunityService.
Inline member layer (frontend). A client island (CommunityMemberPanel)
reads the token from localStorage, calls /me, and renders one of: the
signed-out / non-member "start your own community" CTA (→ Settings → Community),
or — for members — an Internal threads section listing their accessible
channels/topics with unread badges and "Public" flags, deep-linking each topic
into the full in-app chat (/chat/{channelSlug}/{topicId}).
New-thread composer. Members start a thread in an existing channel or a
brand-new one via a dialog that reuses the existing chat create endpoints;
workspace admins get a "Post publicly on the web" toggle that publishes the new
topic (web_public) in one step. Publishing stays server-side admin-gated — the
toggle is only a convenience, the API returns 403 for non-admins.
Public page polish. A sticky header with the community logo/monogram + name,
a stats line, a dedicated "Channels" section, richer channel cards (icon tile,
last-activity date, pluralised topic/message counts), and a proper empty state.
All strings are internationalised (new community i18n namespace, en + hi); the
previously-hardcoded header/footer/auth strings now go through next-intl too.
Turns workspace-internal chat into the substrate for an opt-in public
community forum — a crawlable, workspace-scoped /community/{slug} site for
community building and SEO — while DMs stay strictly private. Nothing is public
unless a workspace explicitly enables it.
Community-only account isolation. Signing in from a public forum ("Sign in
to reply") creates a walled-off community account (developers.account_type),
carried as a JWT claim. A CommunityIsolationMiddleware restricts these accounts
to auth, /public/*, and their own /developers/me — every other internal
endpoint returns 403 (workspace creation is additionally blocked explicitly).
The frontend app shell redirects such accounts to the forum. Accounts are
promoted to internal automatically when invited to a workspace at viewer+.
migrate_2026_07_17_developer_account_type.sql.
Discoverability. A public directory at /community lists communities that
opted in (enabled AND a new listed flag; migrate_2026_07_17_community_directory.sql);
unknown slugs get a friendly not-found. In-chat, admins publish a channel to
the web from a new channel-settings dialog (3-tier visibility + "view public
forum" link), set per-thread visibility from the message header, and hide/unhide
individual messages from the public view (redaction, still visible internally);
the create dialog uses the workspace/private model. A "Community" sidebar entry
and the Settings → Public Community page round out the entry points.
private | workspace | web_public; topics can override with inherit | private | restricted | web_public (can
only ever *narrow* the channel's reach, never widen it). Effective public
visibility is the floor of the chain, gated behind a per-workspace master
switch (workspace_community.enabled). A single resolver
(services/chat_visibility.py) is the source of truth, mirrored as SQL
predicates in the public read model so nothing leaks even if a caller forgets
to filter.
private channels (kind='dm', deduped by a partial unique index
uq_chat_dm_key) and are excluded from every public query by predicate, not
by caller-side filtering.
/public/community/{slug}/...) + **SSR frontend** with ISR (revalidate=300), canonical/OG metadata,
DiscussionForumPosting + BreadcrumbList JSON-LD, and a per-community
sitemap.xml. noindex and thin-content topics are excluded from indexing.
allow_participation on, anysigned-in Aexy user can reply to web-public topics; brand-new posters
auto-join the host workspace as a non-billable community role that ranks
below every internal permission gate. Posts are rate-limited (Redis,
fail-open) and support post (visible immediately) or pre (held for admin
approval) moderation, with a moderation queue in settings.
real name, alias, or anonymous. Mention markup is stripped to plain @Name
and internal fields are never emitted.
chat_topic_access_grants, chat_public_member_prefs, workspace_community + visibility/kind/permalink columns on
chat_channels/chat_topics/chat_messages
(migrate_2026_07_16_public_community_chat.sql); admin settings UI at
/settings/community; SSR wiring (INTERNAL_API_URL, NEXT_PUBLIC_SITE_URL)
added to dev and prod compose.
user-authored content via JSON.stringify + dangerouslySetInnerHTML, which
does not escape </>, allowing a </script> breakout. Now serialized
through a safeJsonLd() helper that escapes <, >, and U+2028/U+2029.
allow_participation / post_moderation** — the hand-built response omitted both fields, so the API
always reported participation off regardless of what was saved, making the
settings toggle appear to revert. Both fields are now returned.
system/agent identity or a since-deleted developer (and desynced the paging
total). Switched to a left outer join with null-safe author resolution.
unconditionally overwrote last_message_at; it now only advances the
last-message pointers when the approved post is genuinely the newest.
list[...] annotation under Python 3.13's eager annotation evaluation.
Renamed to list_integrations (root fix, replacing the `from __future__
import annotations` band-aid).
Connects online logging/observability platforms (OpenObserve first; the design
generalizes to Grafana/Datadog/Sentry) to the ticketing system, so a recurring
error collapses to a single ticket instead of one per firing.
POST /webhooks/alerts/{inbound_token} — token-addressed,HMAC-or-shared-secret authenticated (fail-closed), Redis rate-limited, and
offloaded to a Temporal process_alert_event activity so a slow ticket write
can't time out the webhook and trigger duplicate upstream deliveries.
team, assignee, form, and priority.
provider:service:normalized_alert_name, or aper-integration template). Volatile tokens (UUIDs, timestamps, hex/pod
suffixes) are stripped so recurrences of one error share a fingerprint while
5xx/sev2-style tokens stay distinct. The one-open-ticket-per-fingerprint
guarantee is enforced by a partial unique index uq_tickets_open_dedup,
not app logic alone — recurrences bump an occurrence counter + throttled
comment, recently-closed tickets reopen (flapping), and recovery alerts
auto-resolve.
context, and trace deep-links land as structured fields via a new
incident_auto form template.
alert_integrations / alert_events tables + dedup columns on tickets (migrate_alert_ticketing.sql); alert.ticket_created / alert.ticket_updated
automation triggers; settings UI at /settings/alerting; operator docs at
docs/integrations/openobserve.md.
Found in review of the above, before first ship:
except IntegrityError assumed the only constraint that could fire was the
dedup index, but a uq_ticket_number collision (two concurrent deliveries for
*different* alerts computing the same max()+1) was misread as a dedup race
and finished as ERROR with no Temporal retry — losing the alert in exactly
the alert-storm scenario the feature targets. It now re-raises when no
same-fingerprint ticket surfaces, so the activity retries and picks a fresh
number.
concurrent open ticket rolls back only the reopen and falls through to the
create path's bump-existing fallback instead of raising an unhandled error.
creates a real ticket for setup verification but skips alert.ticket_*
dispatch, so a test can't page on-call or trigger escalation.
close-then-reopen, service race fallback, and the ticket-number-collision
regression).
A published record.created automation showed 0 runs when a record was added.
Root cause was a NULL-object mismatch: the /automations builder never sets
object_id, so automations are stored with object_id IS NULL, but
process_trigger matched with a strict object_id == object_id predicate —
which never matches NULL in SQL. Every record-triggered CRM automation built in
that UI silently never fired.
record's object`), so a global (unbound) automation fires for the workspace
and an object-bound one fires only for its object.
"field_changed"/"stage_changed" (underscores) while the dispatched values
are "field.changed"/"stage.changed" (dots) — so those filters never
applied. Now keyed off the enum values.
crm_service (record created/updated/deleted dispatch) with logger.exception(...). The
dispatch stays best-effort (never fails record creation) but failures are no
longer invisible — this is what made the broken trigger impossible to see.
tests/unit/test_crm_automation_trigger_matching.py reproducing theproduction scenario plus object-scoping, inactive, trigger-type, and
field-filter cases.
Follow-up to the MCP review. API tokens are what the (external) MCP server and
other integrations use to authenticate into the platform, and that surface was
previously untested.
backend/tests/unit/test_api_tokens.py covering ApiTokenService (generation/hashing, expiry, list/revoke/
delete owner-scoping, validate including the 5-minute last_used_at
debounce) and the aexy_-prefix branch of get_current_developer_id end to
end via the real endpoints — valid / unknown / expired / revoked tokens, plus
a JWT-still-works regression check.
POST /developers/me/api-tokens/{id}/revoke marks a tokeninactive but keeps the row for audit; a revoked token immediately fails
validate(). DELETE remains for permanent removal. This makes the
previously unreachable "Revoked" UI state real: active tokens get a Revoke
action, revoked tokens can then be Deleted.
/mcp docs page and the API Tokens settings page were 100% hardcoded English. Both now use next-intl with full en + hi message files
(messages/{en,hi}/mcp.json, messages/{en,hi}/api-tokens.json); technical
terms and tool identifiers stay in English per convention.
/mcp tool catalog is hand-maintained and mirrors the external aexy-io/mcp-server repo, which is
the source of truth.
and its "All Operations" entry is renamed to Overview (the old label was
vague and redundant with its own child). Applied to both the grouped and flat
sidebar layouts and the /operations page title (en + hi).
merged into the unified group, the old nav array that held the MCP entry was
orphaned, so MCP silently disappeared from navigation (the page itself was
always reachable by URL). MCP is now a sub-item of the Autopilot group:
Overview / Agents / Workflows / MCP.
aiAgentsItems / automationsItems nav arrays so thedead-link regression can't recur.
Auditing for the same anti-pattern behind the Ask chat bug turned up two more
LLM call sites that ignored the configured provider, plus one UI gap:
writing_style_service.generate_email) built a hardcoded AsyncAnthropic client, so the agent "generate email"
action failed on any non-Anthropic deployment. It now goes through the LLM
gateway (honours LLM_PROVIDER, works on DeepSeek/Gemini/etc., and gets
rate-limiting + billing tracking for free).
agents/base.py) only handled gemini and lmstudio; every other provider fell through to else -> ChatAnthropic, so an agent
configured for openai, ollama, deepseek, or openrouter silently ran
on Claude. Provider resolution is now an explicit, unit-tested map — DeepSeek/
OpenRouter/OpenAI/Ollama route through the OpenAI-compatible client with the
correct base URL, and an unknown provider raises instead of masquerading as
Claude.
GET /agents/defaults) now includes deepseek and openrouter in provider_models so they're selectable in the UI.
tests/unit/test_agent_provider_selection.py (8) and tests/unit/test_writing_style_generate_email.py (2).
The Ask feature (the floating chat widget's "AI" tab and the full /chat AI
panel) failed to return any response in production: it silently routed to
Gemini and got 403 CONSUMER_SUSPENDED, so nothing streamed back.
Root cause: AskService ignored settings.llm.llm_provider and instead picked
a provider by "first API key present" in the order Anthropic → OpenAI → Gemini,
with no DeepSeek branch at all. Every other part of the platform honours
LLM_PROVIDER via the LLM gateway — the Ask feature was the one place that
didn't. A deployment configured for deepseek therefore still called Gemini.
AskService now resolves the provider from LLM_PROVIDER (the same source oftruth the gateway uses). DeepSeek, OpenRouter, and LM Studio reuse the
OpenAI-compatible streaming path with the correct base URL; Claude/OpenAI/
Gemini keep their existing paths.
previous auto-detect so deployments that never set LLM_PROVIDER are
unaffected.
tests/unit/test_ask_provider_selection.py (11 tests) pinning theresolver, including the exact prod scenario (DeepSeek chosen even when a
Gemini key is also present).
Note: honouring deepseek requires DEEPSEEK_API_KEY to be set in the target
environment; without it the resolver falls back to auto-detect.
Automations, email, and the CRM activity feed were audited and hardened, and
the automation surface was officially scoped to CRM only. The recurring finding
was "visible ≫ wired" — the builder palette exposed triggers and actions that
nothing dispatched or handled. All changes are covered by 95 new tests (unit +
integration) plus live E2E specs.
filters to ENABLED_MODULES = ("crm",) and hides unwired capabilities
(schedule.*, date.*, webhook.received, email.* triggers;
api_request/enrich_record/classify_record/generate_summary actions).
The palette, generate-workflow endpoint, and generated fixture all consume
the filtered registry, so descoped modules disappear everywhere at once.
Non-CRM modules are inventoried in prds/automations-noncrm-deferred.md.
required fields (e.g. an email node with no recipient), malformed literal
email addresses, non-numeric wait durations and numeric-operator condition
values, and malformed/unknown-namespace {{variable}} references — instead of
saving a workflow that fails at execution time.
send_workflow_email now validates the address,honours the unsubscribe/bounce/complaint suppression list (previously only the
campaign path did), registers each recipient as a tracked subscriber (consent
basis), and carries a one-click List-Unsubscribe header (RFC 2369/8058) —
on both the multi-domain path and the default-service fallback (SES via
send_raw_email, SMTP, and Postmark).
verified sending domain (start_sending raises); the UI mirrors this by
disabling "Send Now" with an explanatory hint.
environment (rendering stays lenient so a missing optional var never breaks a
real send).
a.metadata → a.activity_metadata,a reserved SQLAlchemy attribute), a silently-dropped activity metadata payload,
and a missing actor name; automation runs now surface in the feed
(automation.triggered), and the feed's category tabs map correctly to the
dotted activity types actually stored.
across CRM records/objects/notes/activities and the automations, campaigns,
pipelines, lists, and attributes modules, covering both non-member→403 and
cross-tenant IDOR→404 (reads and mutations). No isolation vulnerabilities were
found — the tests lock the invariant in place.
the task's description_json (TipTap doc), not just the plain-text
description. The task detail editor renders description_json, so the
carried-over ticket body (with a clean From: / Ticket: TKT-N header) is
now visible instead of an empty body.
link when the task was created from a ticket (source_type === "ticket"),
opening /tickets/{id}. The frontend TaskSourceType union was aligned with
the backend (added ticket/automation).
with zero notifications previously never polled) and always advances past the
fetched window, fixing repeated re-fetching/duplication of the same
notifications every 30s. (Note: the ERR_CONNECTION_CLOSED seen against the
hosted API is a server-availability issue, not a client bug.)
The "Create task from ticket" flow accepted a required project_id but never
assigned it to the task, leaving team_id NULL. Such a task belonged to no
project — it showed on no board, sat in no sprint, and couldn't be opened via
/sprints?task=<id> deep links.
create_task_from_ticket now sets team_id from the request's project_id, so ticket-created tasks land in the right project and are
visible/openable.
(migrate_2026_07_09_backfill_ticket_task_team.sql) that recovers already-
orphaned tasks from their linked ticket's team_id (where the ticket was
assigned a team).
The public ticket form already collects the submitter's name and email in a
built-in contact section, but every pre-built template *also* defined its own
email field — so users saw two email inputs. The templates had drifted from
each other in other ways too, and the picker couldn't show anything beyond the
original three.
email field is removed from alltemplates; submitter contact is handled solely by the form's built-in
section (require_email, default on). A new invariant test
(test_ticket_templates.py) prevents a contact field from creeping back in.
builders: title-first ordering, a single attachments file convention
(always last, 10 MB), and uniform external_mappings.
Incident Report, Feedback/NPS, Sales/Demo Request, Change Request, Complaint,
and Security Report. Each carries icon/color/category metadata.
templates the API returns (icon-name → lucide with a fallback), so new
templates appear automatically instead of being hardcoded.
datetime and number to the backend TicketFieldType enum/schema to match the types the field editor already
offered (previously a 422 on save). Extended TicketFormTemplateType in the
model enum and schema Literal for the new template keys.
Existing forms created from a template are left as-is; the fixes apply to newly
created forms.
The /sprints?task=<id> links emitted by activity feeds and chat
widgets only carry a task id (no project), so they landed on the
Planning overview and did nothing. They now open the task.
/sprints?task=<id> looks upwhich project the task belongs to (via the workspace-wide task list,
including archived) and forwards to that project's board, showing an
"Opening task…" state while it resolves and a "Task not found" notice
if the id can't be resolved (deleted or no access).
?task= handler no longerrequires the task to be in the loaded board set: if it isn't there
(archived, hidden by an active filter, or otherwise outside the set),
the board fetches it directly by id and opens it. A guard ensures a
genuinely-missing task is attempted only once.
Tickets can now be shared with people outside the workspace through a
tokenised link that opens the ticket directly — no login required to
read, with an optional path to reply for members who are signed in.
TicketShareLink model backs a per-ticket link (/public/tickets/{token}) that can be enabled, copied,
regenerated, and revoked from a Share dialog on the ticket page.
Links support an optional password (bcrypt-hashed), an expiry, and a
max-use count; a public router (mounted without the tickets app
gate) serves the read-only view and enforces all four rules.
status, submitter name, and public replies only — internal notes,
assignee/team, and submitter PII beyond the name are stripped. A
visitor who is already authenticated as a member of the ticket's
workspace additionally gets a reply box (can_reply).
default_share_enabled toggle ("Create a public share link for new
tickets"); when set, create_ticket mints a share link
automatically.
uploaded to private storage keys (never public URLs) and served
through a proxy that re-validates the share token on every fetch, so
revoking/expiring/password-protecting a link also cuts off its
files. Internal-note attachments are never exposed publicly, and
downloads don't consume the link's use-count.
upload_fileobj (automatic multipart) and downloads stream back in
256 KB chunks with HTTP Range / 206 support, so large files no longer
buffer wholly in memory. A configurable cap
(TICKET_MAX_ATTACHMENT_MB, default 100) returns 413 when exceeded,
mirrored by a client-side guard.
(migrate_2026_07_09_ticket_share_links.sql) creates the table and
adds the form column; storage service gains
upload_fileobj/get_object_stream; 18 unit tests cover token
validation, expiry/password/exhaustion, internal-note filtering,
streaming + Range, and the gated proxy.
Custom reporting moves from a read-only shell to a working feature. The
missing background layer meant export jobs were created but never
processed (stuck "pending" forever) and scheduled reports were saved but
never sent; both are now wired end to end, and the reports UI can build,
view, schedule, and edit reports.
process_export_job Temporalactivity is dispatched when an export is created, moving jobs from
pending → processing → completed and writing the file. All formats
work — CSV, JSON, XLSX, and PDF (adds the reportlab dependency, so
PDF export is now available).
deliver_scheduled_reportsschedule polls due schedules every 15 minutes, renders each report to
its configured format, delivers via email and/or Slack, and computes
the next run. A daily cleanup_expired_exports job removes expired
export files and records.
(charts via Recharts, graceful per-widget notes) instead of showing
static metadata; the previously inert Schedule button opens a working
scheduling modal; and a new /reports/[id] editor edits report
metadata and widgets. Report actions now surface success/error toasts
and loading states.
skill widgets return real data (or cached predictive insights) instead
of stub placeholders.
(migrate_analytics_reports.sql); Reports is registered in the app
catalog (accessible to all authenticated users); the widget position
type was aligned to the backend ({x, y, width, height}); and the
schedule-create client now sends report_id in the body to match the
API contract.
The CRM gains first-class sales pipelines, a dedicated Lead object with
conversion, and pipeline analytics — the pieces needed to run a real
sales workflow instead of hand-managing a status field.
CRMPipeline / CRMPipelineStage tables let a workspace define multiple named
pipelines per object, each with ordered stages carrying a color, win
probability, and open/won/lost type. Stages remain the source of
truth but are *projected* into the object's managed status
attribute, so the existing Kanban board renders them unchanged — the
board's "Add stage" button is now real (it previously showed a "coming
soon" alert). Stages can be added, renamed, recolored, reordered, and
deleted (with record reassignment) from a new stage manager, and every
object now offers a board view so a pipeline can be created on the
spot. Existing workspaces are backfilled: the seeded Deal "Stage"
attribute is adopted into a default "Sales Pipeline" with no record
data rewritten.
object (lead status, source, estimated value, owner) with its own
default pipeline, plus a one-click Convert action that creates the
linked Company, Contact, and Deal, back-links them onto the lead, and
marks it converted. Leads are routed to a rep automatically on
creation and when they reach *qualified*, wiring the existing lead
routing/SLA engine into the CRM record lifecycle.
stages now records queryable stage history, emits the stage.changed
automation trigger and webhook, and feeds a new Pipeline Analytics
page: weighted forecast, open/won value, value-by-stage, conversion
funnel, and average time-in-stage.
Three related fixes to public forms and the task description editor:
ticket-forms module and the newer Forms module publish under the
same /public/forms/{token} URL, and their two API routers each
registered GET /public/forms/{token}. The ticket-forms router is
mounted first, so it handled *every* request — any form built in the
Forms module missed the ticket_forms table and 404'd, regardless of
its Active/visibility toggle. The public endpoints (get, submit,
verify-email) now resolve a token against ticket-forms first and fall
back to the Forms module, so both systems are reachable through the
shared public page.
@opened the mention dropdown but then swallowed every subsequent
keystroke, leaving the field unusable. The Tiptap key handler was
reading stale state (the editor captures its options once) and
return true-ing on each character, which blocked the editor from
inserting text; the dropdown was also clipped by the container's
overflow-hidden. The handler now reads live values via refs, never
blocks typing, and the dropdown is no longer clipped.
public page, phone, url, and datetime now use the correct input
types, radio renders a proper option group, and hidden fields are
no longer shown as text boxes — their default_value is seeded and
submitted instead. Field default values are now applied on load in
general.
The homepage shipped almost no server-rendered content — it gated its
entire body behind a client-side isChecking spinner, so a crawler
(or any no-JS fetch) saw only the page title and zero links. The
content now renders unconditionally in the server response (real
headings, the FAQ, and the full internal link graph including the
GitHub repo); logged-in visitors are redirected to the app at the
edge (middleware, aexy_authed cookie) instead of behind a render
gate.
Alongside that:
app/robots.ts, app/sitemap.ts) — robots allows the public marketing routes and
disallows the authenticated app; the sitemap lists the real public
URLs.
`WebApplication` JSON-LD (with offers/featureList) were
added to the root metadata, so shared links render a card and
Google can build a rich result. The title now includes the ICP
keywords (engineering, CRM, HR, GTM) within the display limit and
the meta description fits in ~130 characters.
favicon.ico + icon.svg) and a dynamic **OpenGraph image** were added — all previously 404'd.
Strict-Transport-Security) is now emitted by the app, and responses are gzip-compressed (compress). Hashed
/_next/static/* chunks are served immutable so repeat visitors
stop re-downloading the JS bundles.
The nginx template also gained a www → apex redirect, immutable
static-asset caching, and a broader gzip type list. Note: www
still needs a DNS record to resolve, and the marketing HTML itself
remains dynamically rendered (a consequence of per-request i18n) —
tracked for a future locale-routing change.
Disabling a module for a workspace (App Access settings) only hid it
from the sidebar — the underlying API kept serving it, so a member
could still read and write a "disabled" module by calling it
directly. The toggle is now enforced server-side via a shared
require_app_access dependency (plus ensure_app_enabled for
endpoints that resolve their workspace from the request body or a
referenced entity).
Enforcement covers every router of a gated app, not just its primary
one: disabling Sprints now also blocks epics, stories, bugs,
releases, sprint analytics, planning poker and retrospectives;
Docs covers documents, templates, collaboration and document
spaces; Tickets covers ticket forms and escalation. Tracking is
enforced where the workspace is resolved server-side (standups,
blockers, time, dashboards) rather than via a query param that most
of its routes never receive. An unknown app id now fails at startup
instead of silently disabling enforcement, and the workspace's
app_settings are read through a short-lived in-process cache with
cross-process invalidation (Redis pub/sub) so a toggle takes effect
immediately across workers.
The frontend guard was aligned to the same rule: it now blocks a
route only on the workspace-level toggle (not on a member's role
bundle), so members are no longer redirected off modules the API
would happily serve.
New /my-work page listing everything assigned to the current user
across sprint tasks, bugs and stories in one view, with a "show
completed" toggle. Terminal bug statuses (verified, closed,
wont_fix, duplicate, cannot_reproduce) are excluded from the
default view via a shared constant, and finished bugs no longer show
as open work. Each query is capped so a long-tenured user can't pull
their entire history in one response.
Connecting Jira or Linear ran a full issue sync inline in the connect
request, so a large project could time the request out (and a retry
then hit "integration already exists"), and a mid-sync failure left
the request's DB session poisoned. The initial sync now runs in the
background on its own session, so connect returns immediately. The
"map the primary team to the first remote project when nothing
matches by name" fallback was removed — unmatched teams get no
mapping rather than silently importing an unrelated project's issues.
expired the eagerly-loaded connection relationships and then
lazy-loaded them during serialization; the update now re-fetches
with the relationships loaded.
heatmap, productivity trends and collaboration network endpoints
read request fields that didn't exist on their schemas; the
productivity/collaboration queries also mis-built their
date_trunc/cast expressions. All now work.
/slack/events with malformed JSON now returns 400, and /slack/commands with a
missing signature timestamp returns 401, instead of crashing.
instead of 500.
timezone-aware and naive timestamps; datetimes are normalized to
UTC before comparison.
modal under two query keys, so linking/unlinking left one list
stale; the section is now a single source of truth.
after the active-blockers endpoint began returning both; the
analytics page and dashboard count only unresolved blockers again.
the correct project/sprint board.
The backend test suite can now run against real Postgres (set
TEST_DATABASE_URL), catching pgvector/JSONB/UUID/date_trunc and
foreign-key behavior SQLite silently ignores; the full unit and
integration suites pass on both SQLite and Postgres.
A local-first macOS menu-bar app that captures lightweight semantic signals (frontmost app, window title, file/git context, dev/browser context, idle state) and uploads them as append-only, idempotent event batches. A downstream Temporal/LLM pipeline enriches, attributes, and narrates the activity so time tracking happens with no manual entry.
aexy-mac/, Swift): durable local buffer, batched idempotent upload, OAuth device-code onboarding, Keychain-persisted config, and best-effort nil-safe collectors. Events are removed from the buffer only after the server confirms them./tracker/*): device enrollment, partial-success batch ingest (idempotent on event_id), heartbeat/config pull, sync high-water mark, and evidence presign. Sliding-window rate limiting (fail-open) and a 30d-past/5m-future timestamp guard. category/attribution are server-derived only — never accepted from the client.TimeEntry rows that show in the existing tracking module. Fire-and-forget per-batch dispatch (time-bucketed workflow_id coalescing) plus a 5-min safety-net sweep.WorkLog upsert), and deterministic insight signals (context switching, meeting load, after-hours, focus fragmentation) surfaced as deduped in-app notifications./tracker/qa, /tracker/timesheet): individual-scoped natural-language Q&A over one's own journals + inferred time, and a day-grouped timesheet view with confidence badges. New /tracking/tracker UI page + useTrackerTimesheet hook.TaskSelect fed by GET /tracker/candidate-tasks), or dismiss it, via PATCH /tracker/timesheet/entries/{id} and a new attribution_status column. Dismissed entries drop out of totals. Page fully localized (tracking.tracker namespace, en/hi); Q&A now follows the selected date range and the date picker no longer shifts a day in non-UTC zones.GET /auth/device/login?provider=&port=, captures the developer JWT on a 127.0.0.1 loopback listener (RFC 8252), exchanges it for a long-lived aexy_… API token (POST /developers/me/api-tokens), and enrolls — no env vars or manual code entry. Replaces the dead device-code default that 404'd.docs/aexy-tracker.md (feature + macOS client + sign-in) and docs/api/tracker-ingest.md (ingest + device-login contract), linked into the handbook nav; code references repointed to them.aexy-mac/) is now a hybrid companion — web sign-in, native Today/Board(Kanban)/Table/Docs/Time/Standups, native notifications, and embedded web for everything else. The web app gains a chromeless `?embed=true` mode — AppShell hides its sidebar, and the docs layout + `DocumentEditor` hide the docs sidebar/title-header so the embedded editor is editor-only — letting the desktop app's native sidebar be the sole navigation with full web parity.OAUTH_EXTRA_REDIRECT_HOSTS, or a 127.0.0.1/localhost loopback (native apps). All provider login/connect entry points reject a disallowed redirect_url with 400, and every callback funnels through one guarded chokepoint, closing a token-exfiltration vector where an attacker-supplied redirect_url could capture a victim's token.FOR UPDATE SKIP LOCKED) and is backstopped by a partial unique index on inferred time_entries dedupe keys, so the per-batch dispatch and the periodic sweep can't double-attribute the same events into duplicate time entries.confidence values instead of crashing (and Temporal-retrying) the whole activity.end date (added the missing upper logged_at bound).accepted + duplicates + rejected reconciles to events sent; insight runs no longer overcount notifications suppressed by recipient preferences.1…600s range.Cross-project move (0.8.34) silently re-resolved the new task's
status to the destination's first "open" status. For sibling boards
that's fine; for cross-board moves (Product → Tech) the user
usually has a specific column in mind and the default was wrong.
MoveToProjectModal now fetches the destination project's status
set via the existing useTaskStatuses hook once a target is
picked, and renders a "Status on destination board" dropdown. The
default selection follows: same slug on the target → same name
(case-insensitive) → first active status by position. The picked
slug rides through as target_status_slug on both the single and
bulk move requests; the backend (SprintTaskService.move_to_project)
validates it against TaskConfigService.get_statuses_for_project
before any write, raising invalid_target_status (400) on
mismatch. Bulk move applies one status to every cloned task.
_clone_task_to_project now accepts an override_status_slug and
short-circuits the open-status resolver when supplied. Subtasks
under cascade still resolve their own open status — the picker is
parent-only, which matches the existing "subtasks inherit the
destination's defaults" semantics.
SprintTask.is_archived and the unarchive endpoint have existed
since the early sprint module, but no UI ever surfaced archived
rows. Once a task was archived (manually or as part of a cross-
project move), it disappeared.
Both /sprints/[projectId]/board and the workspace All-Tasks tab
get an Active | Archived segmented toggle (URL-synced via
?view=archived so reloads and link-shares round-trip). In
archived view:
TaskTableView — archived rows don'tbelong in status columns, and the table is the right surface for
a flat list. The Board/Table layout toggle, Sprints/Status
view-mode toggle, Add Task, Columns shortcut, Import button, and
priority/labels/epics filters are all hidden (search, assignee,
project, sprint stay). On the board page this is driven by a new
minimal flag on the existing FilterBar component.
the workspace tab gains an "Restore selected" entry that fires
parallel unarchives.
include_archived; both endpoints now also accept archived_only. list_project_tasks
was hard-coded to is_archived = false — that's been generalized
to the same flag pair. archived_only is strict regardless of
include_archived.
New useUnarchiveTask hook wraps projectTasksApi.unarchive and
reuses invalidateTaskCaches so the active view re-fetches
correctly when a row is restored.
POST /analytics/workload was crashing with
AttributeError: 'WorkloadRequest' object has no attribute 'days'
because the handler read request.days but the schema didn't
declare the field. The frontend has been sending days: 30 since
that endpoint shipped. Added days: int = 30 to the schema.
Cross-project moves (shipped in 0.8.34) already created a
task_dependencies row linking the new task back to the source —
but nothing in the UI rendered that linkage. Anyone opening either
side of the move saw a context-free task.
SprintTaskService.move_to_project now prepends a one-line
"Moved from <KEY> — <title>" breadcrumb to the new task's
description and a matching "Moved to" line on the source. The
breadcrumb is written into both description (plain text) and
description_json (a ProseMirror paragraph with a link mark
pointing at /sprints/<team>/board?task=<id>) so every surface
that renders descriptions shows it without any extra UI plumbing.
Cascade subtasks each get their own pair of breadcrumbs pointing
at the corresponding clone — the parent's pointer alone wouldn't
reach the children.
The existing task_dependencies row is still recorded as the
structured source of truth for any future banner work.
The flat "Apps" section in the docs sidebar (0.8.35) is replaced
with a sidebar that mirrors the main app sidebar's grouping —
Engineering / People / Business / AI / Compliance — plus a
"Recent" strip at the top tracking the user's last-visited apps.
Implementation:
recentAppsStore (Zustand + localStorage, cap 8) records each app visit. Mounted once in app/(app)/layout.tsx via
useRecentApps() so visits from any surface count.
NotionSidebar reads the main sidebar's GROUPED_LAYOUT, applies the same persona filter (useSidebarPersona) and
app-access filter (useAppAccess) the main sidebar uses, and
renders each section collapsed by default to keep the docs
surface focused.
sidebar IS the knowledge view; re-listing it would be
tautological). Docs and Drive are filtered out of the Recent
strip for the same reason.
SidebarAppGroup component renders apps with sub-items(Tracking → Standups/Blockers/Time, etc.) as expandable rows
inside the section, matching the main sidebar's depth.
Reported: "after typing the doc refreshes and the cursor becomes
deselected".
Root cause was on the page, not the editor. /docs/[documentId]/page.tsx
was passing isLoading={isUpdating} to DocumentEditor, where
isUpdating is the mutation-pending flag from useDocument's
updateContent mutation. DocumentEditor returns its loading skeleton
when isLoading is true — so every debounced autosave kicked off by
typing flipped isUpdating to true, the editor was replaced by the
skeleton, then isUpdating flipped back to false and the editor was
remounted — fresh TipTap instance, fresh selection, cursor lost.
Removed the prop. The page-level initial-load guard (above the
component) still shows a skeleton on first fetch; once the document
is loaded the editor stays mounted, and the in-editor "Saving… / Saved"
indicator reflects save state without tearing anything down.
0.8.35 gated BubbleMenu on editorMode === "rich" thinking the
crash was a mode-switch race. The user kept hitting the same
removeChild error while selecting text in rich mode — the gate
fixed the switch path but not the steady-state path. Re-diagnosis:
@tiptap/react's BubbleMenu wraps Tippy.js.document.body, outside theReact tree.
selectionchange causes BubbleMenu to remount its Tippyinstance, moving DOM nodes between body and the editor.
no longer owns it → `NotFoundError: Failed to execute 'removeChild'
on 'Node'` in the commit phase.
This is a known incompatibility between @tiptap/react's BubbleMenu
and React 18+ concurrent reconciliation
(ueberdosis/tiptap#3580, #2658).
Removed the BubbleMenu entirely. The top EditorToolbar already
exposes Bold / Italic / Underline / Code, so the affordance isn't
lost — only the floating bubble. If we want the bubble UX back, the
replacement should use @floating-ui/react (in-tree positioning)
rather than Tippy.
Two docs surface fixes.
The main app sidebar is hidden on /docs/* routes, so the docs sidebar
(NotionSidebar) was the only navigation chrome — but it had no path
to other modules. Users had to back out via browser nav or memorize
URLs to jump to Sprints, CRM, etc.
Added a collapsed-by-default "Apps" section at the bottom of the docs
sidebar (above the divider before "Add space"). It reuses
useAppAccess(workspaceId, developerId) to list only the apps the
current user can access, with each row linking to that app's
baseRoute from APP_CATALOG. Same access logic as the main sidebar
— no new permissions surface.
Reported: selecting text in /docs/[id] would intermittently throw
`NotFoundError: Failed to execute 'removeChild' on 'Node': The node to
be removed is not a child of this node` in the React commit phase.
Root cause: DocumentEditor's BubbleMenu was rendered when
editor && !readOnly, regardless of editorMode. In markdown mode
the EditorContent is replaced by a <textarea>, but the BubbleMenu
(and its Tippy.js portal) stayed mounted. Any subsequent selectionchange
would race React reconciliation — Tippy holds DOM references that React
no longer owns, the next reposition tries to removeChild a detached
node, and the commit phase throws.
Fix: gate BubbleMenu on editorMode === "rich" so it tears down
cleanly when the user switches modes. One-line conditional change in
frontend/src/components/docs/DocumentEditor.tsx.
New: cross-project task move (fork + link). A task can now be moved to
another project in the same workspace; a fresh task is created in the
destination, linked back to the source as a "duplicates" dependency,
and the source is either archived or marked done at the operator's
choice.
Moving the row in place would orphan the source's history, sprint
membership, comments, and attachments — and task_key is workspace-
scoped but tasks reference sprint/epic/story IDs that don't translate
across projects. A new task in the destination plus a task_dependencies
link preserves provenance while letting the destination start fresh.
source_action, subtask_strategy, actor_id) and a bulk_move_to_project`
variant that returns per-task results (one failure doesn't abort the
batch). See plan mutable-herding-flute.md for the full contract.
api/project_tasks.py: - POST /teams/{team_id}/tasks/{task_id}/move-to-project
- POST /teams/{team_id}/tasks/bulk-move-to-project
cross_workspace_move, same_project_move, target_project_not_found,
task_already_archived, task_has_subtasks,
source_task_not_found, invalid_source_action,
invalid_subtask_strategy.
- block (default, safest) — reject the move if subtasks exist.
- cascade — clone every active subtask into the destination under
the new parent; archive each original subtask.
- orphan — leave subtasks in place; their parent_task_id still
points at the archived/done source.
- archive — is_archived=True on the source.
- mark_done — set the source's status to its project's first
semantics="done" slug (workspace fallback, then canonical "done")
and set completed_at = now() if null.
member of the target project; otherwise cleared. Sprint, started_at,
completed_at, cycle/lead time, epic, story, and parent_task_id are
intentionally not copied — see the plan for the rationale.
moved_to_project on the source (carries new task's id/key and the chosen strategies in activity_metadata)
and created_from_move on the new task (carries source's id/key).
task_dependencies with dependency_type="duplicates" is the link mechanism.
MoveToProjectModal (components/planning/MoveToProjectModal.tsx)used by both single-task and bulk entry points. Project picker excludes
the source project and any archived project. Subtask-strategy radio
shows only on single-task moves when the task has subtasks.
useTaskMove hook (hooks/useTaskMove.ts) wrapping both the single and bulk mutations, with invalidateTaskCaches integration and
friendly toast messages mapped from each stable error code.
EditTaskModal sidebar (project board) gains a "Move to project…"button above "Archive Task".
button next to the existing "Move to Sprint" dropdown.
backend/tests/unit/test_task_move_to_project.py:happy path, mark-done variant, cross-workspace reject, same-project
reject, archived-source reject, subtask block / cascade / orphan,
assignee membership rule, sprint+timing fields not copied,
activity logged on both tasks, bulk-move continues on per-task
failure.
Follow-up sweep on the 0.8.32 status work — two production bugs and the
missing admin surface for editing categories themselves.
PATCH /teams/{id}/tasks/{id} was rejecting any non-canonical slug
with a Pydantic literal_error:
`
Input should be 'backlog', 'todo', 'in_progress', 'review' or 'done'
`
Root cause: TaskStatus was still a Literal[...] at the schema
layer, defeating the whole point of project-scoped custom statuses
from 0.8.32. Two-part fix:
TaskStatus = str in both backend/src/aexy/schemas/sprint.py and frontend/src/lib/api.ts. Any slug parses; validity is decided at
write time, not parse time.
SprintTaskService.validate_status_slug(task, slug) checks the slug exists in the task's scope (workspace_task_statuses rows for
the project OR workspace defaults). On miss → 400 unknown_status.
Wired into both update_task and update_task_status, on both
PATCH endpoints (/teams/.../tasks/... and
/sprints/.../tasks/...).
The canonical five seed slugs (backlog, todo, in_progress,
review, done) are accepted unconditionally so workspaces that
pre-date the status table aren't bricked by tasks carrying slugs
without matching rows.
Production was showing two columns titled On Hold on a kanban — the
admin had typed the name twice and create_status had silently
deduplicated only the *slug* (storing on_hold and on_hold_1).
Both rendered because the column title comes from name, not slug.
create_status and update_status now share an _assert_name_unique
helper that rejects case-insensitive name collisions within a scope
(workspace + project): error code status_name_exists, HTTP 400.
This prevents the future occurrence but does not clean up existing
duplicate rows in production data — admins need to delete one of the
duplicates via the new admin UI (below).
/settings/projects/{projectId}/statuses gains a "Categories" section
above the existing statuses list:
CategoryModal — create / edit a category with label, semantics(Open / Active / Done / Cancelled), and color. Slug is auto-derived
from the label on create and locked on edit (existing statuses
reference it as a string).
SortableCategoryItem — compact row with color swatch, semanticsbadge, edit/delete menu.
category) and server-side (category_in_use error, HTTP 400).
backend/tests/unit/test_task_status_validation.py (new, 4 tests) —canonical slug accepted, project-scoped custom slug accepted,
unknown slug rejected, slug scoped to a different project rejected.
backend/tests/unit/test_status_categories.py (+1) — test_create_status_rejects_duplicate_display_name pins the
case-insensitive name uniqueness check.
Two threads landing together:
1. DB-driven status categories. The category on each task status
was previously locked to three Literal values (todo,
in_progress, done). It's now a free-form slug validated
against a new workspace_status_categories table that ships six
canonical buckets per workspace (backlog, todo, in_progress,
in_review, done, cancelled) and is open to admin additions.
2. Project-scoped statuses actually reach the board. The
useTaskStatuses(workspaceId, projectId) hook + endpoint existed
since 0.8.29, but both the project board (sprints/[id]/board)
and the workspace All-Tasks tab were silently rendering hardcoded
5-status arrays. They now call the hook and render whichever
statuses the project (or workspace fallback) defines.
3. Board ↔ Table layout toggle. The orphaned Settings2 button
in the board toolbar is replaced with a LayoutGrid | Table2
pill; the workspace All-Tasks tab gains the same toggle. Layout
is persisted per scope via the new useTasksLayout hook.
backend/scripts/migrate_status_categories.sql creates workspace_status_categories and seeds the six canonical buckets
for every existing workspace. The unique index uses
COALESCE(project_id::text, '') so workspace defaults and project
overrides occupy separate uniqueness buckets, matching the pattern
already in use for workspace_task_statuses.
semantics field (one of open, active, done, cancelled). All business logic that needs to branch on
completion (burndown, velocity) should read semantics — slugs
are user-facing and renameable.
StatusCategory in backend/src/aexy/schemas/sprint.py becomes str; CategorySemantics is the new Literal. The frontend
mirror in lib/api.ts matches.
TaskConfigService: get_categories, get_categories_for_project,
create_category, update_category, delete_category,
reorder_categories, seed_default_categories.
/workspaces/{id}/status-categories with the same ?project_id= scope filter as /task-statuses.
create_status / update_status validate the category slugagainst the workspace's category set (with project fallback) and
raise TaskValidationError("unknown_category") on miss.
Workspaces created before the categories table existed get
lazy-seeded on first write so legacy data never trips.
StatusModal accepts a categories prop instead of a hardcodedarray. Each cell renders the category color, label, and a small
semantics chip; the title attribute carries the burndown hint.
Both consumers (project statuses page + workspace task-config
page) wire useStatusCategories and thread it through.
frontend/src/app/(app)/sprints/[projectId]/board/page.tsx calls useTaskStatuses(workspaceId, projectId) and renders status
columns from the resolved set (project rows or workspace
fallback). The hardcoded five-column STATUS_CONFIG is kept only
as a label/color fallback for the canonical slugs.
WorkspaceTasksTab.tsx does the same when exactly one project isfiltered in; otherwise it falls back to workspace defaults.
useProjectBoard.tasksByStatus and useWorkspaceTasks.tasksByStatus are now Record<string, _>
instead of Record<TaskStatus, _> so custom slugs bucket correctly.
frontend/src/hooks/useTasksLayout.ts — localStorage-backed "board" | "table" preference, scoped per surface
(board:<projectId> for each project, workspaceTasks for the
All-Tasks tab).
frontend/src/components/planning/TaskTableView.tsx — shareddense table view used by both pages. Columns: Key, Title, Status
(with the project-scoped color dot), Priority, Assignee, Sprint,
Pts, Updated. Sticky header, hover row, bulk-select column,
row-click opens the same detail surface as the kanban cards.
Settings2 button for a segmented Board/Table pill; WorkspaceTasksTab adds the same
pill in its toolbar alongside the existing project-statuses link.
tests/unit/test_status_categories.py (7 tests)covers: canonical seed, fallback resolver, project override,
unknown-category rejection on create + update, lazy-seed for
legacy workspaces, and refusal to delete a category in use.
- src/test/useTasksLayout.test.ts — persistence, hydration,
malformed-value guard, scope isolation.
- src/test/StatusModal.test.tsx — dynamic categories rendering,
submit payload uses the selected slug, empty-state hint.
e2e/tasks-view-toggle.spec.ts — a custom project status (design_review) surfaces as a kanban column on
the board; the Board ↔ Table toggle swaps content and persists
across reload via the scoped localStorage key.
CREATE TABLE IF NOT EXISTS + ON CONFLICT DO NOTHING for the
seed.
migration also retags the seeded "Backlog" status from
category=todo → backlog and "In Review" from in_progress →
in_review for workspaces that hadn't renamed those rows.
test_task_config_project_scope.py continue to pass against the
updated seed (it already used the in_review slug).
Moves the status admin to its semantic home: project-scoped statuses
now live at /settings/projects/<id>/statuses next to General /
Permissions / Repositories, instead of the workspace settings page
with a ?project= query param. The workspace task-config page keeps
its workspace-defaults mode; the project-scoped UI moves out.
frontend/src/app/(app)/settings/projects/[projectId]/statuses/page.tsxhosts the project status admin in the same shell as General /
Permissions: matching breadcrumb, project header chip, tab nav
including the new Statuses link.
useTaskStatuses(workspaceId, projectId), the DeleteStatusModal, and the auto-fork backend from 0.8.29-0.8.30 —
no new service or API.
position as the workspace settings page's version. Rows render
read-only with a Workspace default chip until the fork happens.
SortableStatusItem to frontend/src/components/settings/SortableStatusItem.tsx — the row component used by both task-config/page.tsx and the
new project statuses page. Includes the readOnly mode introduced
in 0.8.30.
StatusModal (the add/edit form) to frontend/src/components/settings/StatusModal.tsx. Both pages
import it; the workspace page's inline copy is gone.
/settings/projects/[projectId] and .../permissions) pages grow a Statuses tab link. Repositories sub-page keeps its
back-button layout untouched.
Columns deep link on the project board (/sprints/[projectId]/board) now points at
/settings/projects/<id>/statuses instead of
/settings/task-config?tab=statuses&project=<id>.
only renders when the user has filtered to a single project.
/settings/task-config keeps its existing project picker for now;it still works but the project deep links no longer point at it.
Once usage shifts to the new route the project-mode dropdown there
can be retired.
useProject(workspaceId, projectId) was already exporting isLoading — no hook changes needed for this PR.
Finishes the project-scoped statuses UX: tasks no longer get orphaned
when a column is deleted; the project board has a direct entry point
into status editing; fallback projects render their inherited columns
as visually read-only; and adding a project status from fallback now
snapshots the workspace defaults first so the project doesn't lose
its inherited columns.
TaskConfigService.delete_status(status_id, migrate_to_status_id=None)now optionally rewrites every task pointing at the source status
(sprint_tasks.status_id and the legacy status slug column) to
the chosen target before the soft delete. Validation refuses a
cross-workspace target, refuses a project-scoped target for a
workspace-default delete (tasks come from across the workspace),
refuses a different project's target for a project-scoped delete,
and refuses self-target.
GET /api/v1/workspaces/{ws}/task-statuses/{id}/usage returns { count } — powers the delete modal's "N tasks use this status"
copy.
DELETE /api/v1/workspaces/{ws}/task-statuses/{id} now accepts a ?migrate_to=<uuid> query param.
frontend/src/components/settings/DeleteStatusModal.tsx replaces the previous confirm() dialog. Renders the usage count, requires
a target status when count > 0, defaults the target to a same-
category sibling for sensible fallback, and surfaces the backend's
stable error codes inline.
create_status(project_id=...) for a project that's currently onfallback now clones the workspace defaults into that project before
inserting the new row. Without this, the resolver would flip from
"5 inherited statuses" to "1 manually-added status" the moment an
admin clicked Add Status from a per-project view — silent column
loss.
frontend/src/app/(app)/sprints/[projectId]/board/page.tsx gets a"Columns" link in the toolbar (next to Add Task) that deep-links
to /settings/task-config?tab=statuses&project=<projectId>.
frontend/src/components/planning/WorkspaceTasksTab.tsx shows thesame link in the All-Tasks header when filtered to a single project.
task-config/page.tsx reads ?project=<uuid> from the URL andpreselects the scope dropdown so the deep links land where they
promise.
SortableStatusItem gains a readOnly prop. When the page is in per-project mode and the project is in fallback (isUsingWorkspaceFallback),
rows render with a Workspace default chip and the drag-handle /
edit / delete affordances hide. The single primary action becomes
the existing "Customize for this project" CTA.
test_task_config_project_scope.py: - count_tasks_using_status returns the count.
- delete_status with a target rewrites both status_id and the
legacy status slug on every affected task.
- delete_status without a target leaves tasks pointing at the
now-inactive row (legacy slug still renders the card).
- Cross-workspace / cross-project migration targets are rejected
with migration_target_other_workspace / migration_target_other_project.
- create_status(project_id=...) on a fallback project copies the
workspace defaults in before adding.
exactly one row" was updated to match the new auto-snapshot
behavior; the invariant it now expresses is "the resolver returns
project-scoped rows once any exist", which is what the codebase
actually relies on.
Project statuses are now genuinely isolated from workspace edits. The
0.8.28 release introduced project-scoped statuses with a workspace
fallback; this release closes the gap where a fallback project would
still see workspace renames, deletions, and reorders flow through.
TaskConfigService._snapshot_fallback_projects(workspace_id)finds every project in the workspace that has no project-scoped
status row of its own and runs clone_workspace_statuses_to_project
for each, capturing the current workspace defaults.
update_status and delete_status now invoke the snapshot beforeapplying the change when the target row is a workspace default
(project_id IS NULL). Editing a project-scoped row is a no-op for
the snapshot — those projects already own their statuses.
reorder_statuses invokes the snapshot when any of the reorderedIDs is a workspace default; reordering changes a project's visual
workflow and counts as destructive for the same reason as a rename.
create_status (workspace) is intentionally not wrapped — addinga new status is additive, so fallback projects pick it up via the
resolver without being auto-forked into snowflakes.
db.commit is the last step in update_task_status / delete_task_status /
reorder_task_statuses), so a partial failure rolls back cleanly.
test_task_config_project_scope.py:- Workspace rename snapshots the fallback project (project keeps
the old name).
- Workspace add does not snapshot (project stays in fallback
and resolves the new status via the workspace defaults).
- Workspace delete snapshots the fallback project (project keeps
the deleted status as an active project override).
- Workspace reorder snapshots the fallback project (project keeps
the original order).
- Workspace edit with a mixed project set leaves the already-
customized project untouched and only forks the fallback one.
This release is backend-only. The discoverability work proposed
alongside this (kanban-header drawer, `/sprints/[projectId]/settings/
statuses` route, delete-with-task-migration modal, read-only
"Workspace default" preview, "reset to workspace defaults" undo)
will land in a follow-up PR. Operators editing statuses today still
use /settings/task-config with the project picker.
Workspace All-Tasks gains inline create, statuses become per-project
(with a workspace fallback), and the kanban picks up a round of
Linear-style polish. Backend tests now run against SQLite without
the previous ARRAY/JSONB schema-compile blocker.
WorkspaceTasksTab (/sprints?tab=tasks) was read-only. Adds a hover-only + button per column, a Trello-style dashed "+ New
task" row at the bottom of every column (Enter to submit, Esc to
cancel, refocus on success for rapid entry), and a global "+ Add
task" button in the filter bar.
AddWorkspaceTaskModal (components/planning/AddWorkspaceTaskModal.tsx)— compact, keyboard-first form with Project, Sprint, Status,
Priority, Assignee, Story points, dates, and Estimate. Status
renders as a locked chip when the modal is opened from a column,
so the new card lands in the column the user clicked.
POST /api/v1/workspaces/{ws_id}/tasks endpoint (api/workspace_tasks.py) backed by SprintTaskService.add_workspace_task.
Resolves team_id from project_teams, validates that the sprint
(if any) belongs to that team, and rejects a status_id that
belongs to a different project (returns one of the stable error
codes project_has_no_team / sprint_not_in_project /
status_belongs_to_other_project so the frontend can branch on
the detail string).
localStorage so successivequick-adds land on the same project without re-picking.
migrate_project_task_statuses.sql: adds a nullable project_id UUID column to workspace_task_statuses and replaces
the workspace+slug unique constraint with a scoped expression
index (workspace_id, COALESCE(project_id, ''), slug). Existing
rows keep project_id = NULL and continue to act as workspace
defaults; rows with project_id set are project overrides.
TaskConfigService.get_statuses_for_project(workspace_id, project_id)returns the project's own status rows when any exist, falling
back to workspace defaults otherwise. This is the single helper
the column UI, task-create validation, and the status admin API
all share.
clone_workspace_statuses_to_project service helper + POST /workspaces/{ws}/projects/{p}/task-statuses/clone-from-workspace
endpoint — idempotent fork-the-defaults action that powers the
new "Customize for this project" CTA on the Statuses settings
page.
GET /workspaces/{ws}/task-statuses now accepts ?project_id=<uuid>; POST /task-statuses accepts project_id
in the body. Response schema gains a project_id field.
useTaskStatuses(workspaceId, projectId?) switches scope, exposes cloneFromWorkspace and an
isUsingWorkspaceFallback flag for the CTA.
settings/task-config) gets a project picker; inper-project mode and using fallback statuses, an info banner
offers the one-click clone.
backend/scripts/backfill_project_task_statuses.py — operator CLIthat clones workspace defaults into existing projects. Flags
--workspace-id, --project-id, --all, --dry-run. Idempotent
(skips projects that already have overrides). The non-migrate*.sql
filename keeps it out of the migration runner so it only runs
when invoked explicitly.
selected via shift-click / per-card checkbox): bulk "Move to…"
status change plus Clear.
?q=, ?assignee=, ?priority=, ?team=, ?sprint= round-trip so refresh / back-button / link-sharing
reproduces the view.
n opens the new-task modal; / focuses thesearch input.
count stay visible while scrolling long lists.
column-shaped placeholders with staggered card animation delays.
md (full-width,no max-height) instead of forcing a horizontal scroll on phones.
page rendered "No tasks found" and hid the columns — making the
new inline quick-add unreachable. The full empty-state now only
appears when filters are active and matched nothing.
core/database.py registers SQLite dialect shims via @compiles(... "sqlite") for ARRAY → JSON, JSONB → JSON,
INET → VARCHAR(45). Models declared with PG-only types now
compile under sqlite+aiosqlite:///:memory: so the test suite
reaches the test bodies instead of failing in
Base.metadata.create_all(). 401 previously-blocked tests now
run; remaining failures are pre-existing fixture issues
unrelated to this PR.
'::jsonb' casts from four server_default literals in models/dashboard.py and models/crm.py so SQLite accepts the
DDL. PostgreSQL still parses the bare '[]' / '{}' defaults
into JSONB.
setupTaskBoardMocks now sets the aexy_authed presence cookie via page.context().addCookies(),
preventing the middleware from bouncing every spec to / and on
to /onboarding. Unblocks task-card-drag,
task-create-attachments, task-link-clickable,
task-over-estimate, task-attachment-ai-tags, and
task-overdue-badge in addition to the two new
workspace-tasks-create specs.
backend/tests/unit/test_task_config_project_scope.py — 5 unittests covering fallback to workspace defaults, project override
preference, no cross-workspace leak, clone copy fidelity, and
clone idempotency.
backend/tests/integration/test_workspace_tasks_api.py — 5 APItests covering the happy path, cross-project status rejection,
project-without-team rejection, status-list fallback, and clone
idempotency.
frontend/e2e/workspace-tasks-create.spec.ts — Playwright specexercising the inline quick-add row (asserts the wire shape:
title, project_id, status) and the global "Add task" modal.
lib/api.ts: new workspaceTasksApi.create(), taskConfigApi.getStatuses({ projectId }), and
taskConfigApi.cloneToProject().
addTask, newTaskPlaceholder, refreshed dropTasksHere copy in both en and hi.
Part B follow-ups: close the three loops Part B's commit message
flagged as "deferred". All three streams of AI-generated content
now route through the proposed-edits queue, the doc owner gets a
notification each time a proposal lands, and the stale-conflict
view exposes a Regenerate action to refresh against the current
base.
DocumentSyncService.regenerate_document and process_queue were referenced by the Temporal regenerate_document /
process_document_sync_queue activities but didn't exist on the
service — the whole sync regen path was dead. Implemented both,
routing through ProposedEditsService.create_proposal with
source=code_change_sync.
_trigger_real_time_sync no longer marks the doc pending_regeneration and forgets about it — it generates fresh
docs and creates a proposal via a new shared _generate_and_propose
helper.
POST /workspaces/{ws}/documents/{doc_id}/suggest-improvements/apply. Takes a suggestion_summary query string (copy/pasted from the
improvements[].suggestion field returned by the existing
suggest-improvements endpoint), runs it through
DocumentGenerationService.update_documentation, and lands the
result as a pending proposal with source=suggest_improvements.
The legacy GET-style suggest-improvements keeps its
"return-suggestions-list" contract; the new endpoint is the
"apply this one" action.
ProposedEditSource lifecycle now fires a DocumentNotification to the document's created_by_id with the new AI_PROPOSAL
type. Self-notifications (proposer == owner, e.g. owner-triggered
manual regenerate) are suppressed. Best-effort: if the doc has no
created_by_id, the notification step is a no-op (legacy fixture
safety).
DocumentNotificationType.AI_PROPOSAL enum value (backend/src/aexy/models/documentation.py).
ProposedEditReview gets a new optional onRegenerate prop. Whenthe proposal is stale AND a handler is wired, the merge-conflict
view renders a third action between Reject and "Apply anyway":
Regenerate.
ProposedEditsBanner wires this to a new regenerate mutation that calls documentApi.generate(workspaceId, documentId) — the
new proposal supersedes the stale one server-side via
create_proposal's supersede sweep, so we just invalidate the
query cache afterwards.
asserts this).
test_proposed_edits_service.py extended with TestNotificationOnCreate (3 specs): notification fired for
owner, no self-notification, no notification when owner is
missing.
docs-proposed-edits.spec.ts extended with twospecs: stale conflict renders Regenerate + clicking it calls
POST /generate; non-stale proposals don't show the button.
Total docs E2E: 29 specs, ~60 s.
Bumped both backend/pyproject.toml and frontend/package.json
to 0.8.27.
Part B of the AI documentation initiative: the **proposed-edits
review queue**. AI-generated content no longer overwrites
document.content directly — it lands in a pending queue the user
approves or rejects through a banner above the editor.
(backend/scripts/migrate_document_proposed_edits.sql). Columns:
`id, document_id, source, proposed_content (jsonb),
base_content_sha, diff_summary (jsonb), status, proposed_by_id,
proposed_at, reviewed_by_id, reviewed_at, reason`. Indexed on
(document_id, status) for the banner's hot read path and on
(document_id, base_content_sha) for stale-detection lookups.
aexy.models.documentation + ProposedEditSource and
ProposedEditStatus enums. Wired into models/__init__.py's
__all__.
ProposedEditCreate, ProposedEditResponse (carries computed is_stale), ProposedEditReject.
backend/src/aexy/services/proposed_edits_service.py) - create_proposal snapshots the current content_sha if the
caller didn't supply one, then auto-supersedes prior pending
proposals on the same document. The new row is flushed before
the supersede UPDATE runs, so the new proposal's id can be
referenced in the supersede reason without a null-id race.
- approve routes through DocumentService.update_document
which creates a DocumentVersion automatically — every approved
proposal lands as a versioned change.
- reject records an optional human-readable reason.
- is_stale compares the proposal's base_content_sha against
the document's current SHA; rows without a base are never
flagged (legacy / migration safety).
- compute_content_sha is key-order invariant (sort_keys=True)
so JS round-trips that re-serialize equivalent content don't
spuriously trigger the stale badge.
changed: now creates a pending proposed_edit instead of writing
to document.content. Legacy overwrite behaviour is preserved
behind ?apply=true for scripted / migration callers.
list pending (default), or ?status=approved|rejected|superseded|all.
transitions; bumps the version chain via DocumentService.
pending proposals exist. Groups by source (regenerate,
code_change_sync, suggest_improvements, manual_ai_edit)
with distinct icons/labels per group. Click a proposal to expand
the review inline.
- Summary (default): sections added / removed / headings
changed, scannable, no scrolling.
- Unified: full JSON view in a scroll container.
- Side-by-side: current vs proposed columns.
Approve / Reject actions live in the footer; Reject opens an
inline reason input. When proposal.is_stale is true, the
banner shows the merge-conflict UX and the Approve button copy
flips to "Apply anyway".
editor. The component self-hides when there are no pending
proposals — no layout shift on docs that don't have AI edits.
rejectProposedEdit}** added to lib/api.ts plus ProposedEdit`,
ProposedEditSource, ProposedEditStatus types.
tests/unit/test_proposed_edits_service.py — 10 unit tests covering compute_content_sha invariants
(deterministic, key-order invariant, None == {}), create_proposal
(SHA snapshotting, flush-before-supersede ordering, string-source
acceptance), and is_stale (no-base / matching / diverged).
e2e/docs-proposed-edits.spec.ts — 5 specs coveringbanner rendering, all three diff modes (summary / unified /
side-by-side toggle), approve flow, reject-with-reason flow, and
the stale conflict UX.
Full backend unit suite for docs: 10 specs pass.
Full docs E2E: 27 specs, ~58 s.
Run python scripts/run_migrations.py (the new
migrate_document_proposed_edits.sql is the only pending change).
No backfill needed — proposals only land going forward, legacy
generate callers that pass ?apply=true keep working unchanged.
Part A of the AI documentation testing initiative: TDD coverage for
autogenerate flows + the autoupdate plumbing. The audit had flagged
that the entire docs-AI surface had zero tests; this commit closes
that with 11 specs and surfaces three bugs along the way, two of
which are fixed in the same change.
Part B (proposed_edits model + approval UX) lands separately.
(backend/src/aexy/services/document_sync_service.py:68).
Line referenced PlanTier.TEAM.value but the enum has no TEAM
member. Every free-tier or pro-tier-without-realtime developer
hit AttributeError when get_sync_type_for_developer was called.
Fixed to PlanTier.ENTERPRISE.value, matching the convention used
in api/knowledge_graph.py, api/notifications.py,
api/app_access.py. Caught by test_document_sync_service.py.
DocumentGenerationService.suggest_improvements claims to return
`{quality_score, improvements[], missing_sections[],
overall_assessment}` but was returning generic code-analysis JSON
(languages, frameworks, code_quality, summary) because:
1. lmstudio_provider._build_analysis_prompts had no branch for
AnalysisType.DOC_* types — they fell through to
CODE_ANALYSIS_PROMPT, dropping the service's custom prompt.
Fixed by adding a DOC_* branch that honours
request.context["system_prompt"] + uses the pre-formatted
request.content verbatim.
2. The service's json.loads(result.raw_response) blew up on
markdown-fenced LLM output. Extracted _parse_llm_json helper
that strips `json fences before parsing. Applied to all four
raw_response parse sites in the service.
3. Tightened DOC_IMPROVEMENT_SYSTEM_PROMPT to say "Respond ONLY
with valid JSON … No preamble, no analysis, no markdown fences".
4. Bumped lmstudio_config max_tokens in the AI test conftest
from 2048 → 8192 so Qwen "thinking" models don't run out of
budget before producing JSON.
Caught by test_suggest_improvements.py::test_returns_documented_contract_shape.
frontend/src/components/docs/SyncStatusPanel.tsx).221 LOC of pending-changes UI implemented but never mounted in
any page. Wired into app/(app)/docs/[documentId]/page.tsx:
uses useDocumentCodeLinks to compute the pending count, renders
above the editor when the doc has any code links, exposes a
manual-sync button that calls documentApi.generate. Caught while
writing the FE pending-banner spec.
| File | What it covers |
| --- | --- |
| backend/tests/ai/services/test_document_generation_paste.py | generate_from_code returns TipTap doc shape with heading + paragraph + matching identifier (real LLM) |
| backend/tests/ai/services/test_document_generation_repo.py | generate_from_repository forwards to GitHubService correctly; missing file raises ValueError (mocked GH, real LLM) |
| backend/tests/ai/services/test_document_regenerate_from_link.py | The orchestration the {doc_id}/generate endpoint runs: load doc, load links, generate, write content back, flip generation_status, clear has_pending_changes |
| backend/tests/ai/services/test_suggest_improvements.py | Contract shape (quality_score, improvements[], missing_sections[], overall_assessment); locks in the fix for the schema drift above |
| backend/tests/unit/test_document_sync_service.py | Plan-tier routing in get_sync_type_for_developer: REAL_TIME / DAILY_BATCH / MANUAL for premium / pro+enterprise / free; the previously-dead enterprise branch now reaches DAILY_BATCH |
| File | What it covers |
| --- | --- |
| docs-autogenerate-paste.spec.ts | Full live flow: paste TS function, click Generate, real LLM round-trip, lands on new doc with editor visible |
| docs-autogenerate-repo.spec.ts | From Repository tab opens; either repo list or empty state renders; Generate disabled in empty state |
| docs-autogenerate-repo-full.spec.ts | End-to-end repo orchestration with mocked repo/branch/contents APIs; user picks repo → root dir → click Generate → mocked content lands as a new doc |
| docs-pending-changes-banner.spec.ts | SyncStatusPanel renders pending count + manual-sync label when a code-link is dirty (mocked code-links, live doc) |
| (orphan SyncStatusPanel finding informs this) | — |
pytest, pytest-asyncio, pytest-cov, aiosqlite into the aexy-backend image (they weren't there before, blocking any
attempt to run the backend test suite via docker exec).
Docs UI/UX follow-up sweep: the five items the 0.8.23 commit
deliberately left as "out of cluster scope" — visual gradient
heroes, ring-spinner duplication, Drive IA confusion, hardcoded
colour refs, mobile responsiveness on Drive/Files/Knowledge-Graph.
5 new E2E specs lock the changes in (18 total docs E2E specs now,
~32 s full pass).
from-primary-500/20 to-purple-500/20 rounded-2xl icon hero in two places** (DocsLayoutClient.tsx, page.tsx)
with a typography-first treatment: small tracked eyebrow label,
semibold tracking-tight headline, one line of supporting copy.
The audit called this gradient pattern the strongest "AI-slop"
tell in the surface — docs-no-gradient-hero.spec.ts regression-
guards both heroes.
and auto-generate documentation from your code" to an inviting
"What do you want to write today?" with shorter supporting copy.
xs|sm|md|lg size variants, role="status", data-testid="aexy-spinner", and an sr-only
label. Replaces four near-identical inline implementations:
DocsLayoutClient.tsx:81 (lg), [documentId]/page.tsx:45 (md),
CollaborativeEditor.tsx:319 (sm), TemplateSelector.tsx:140 (xs).
inline pattern accumulated four variants of the same idea across
the surface.
SidebarNavigation.tsx pointing at /docs/drive. Drive was previously reachable only by URL.
drive.page.title in messages/en/drive.json + messages/hi/drive.json) with a new
subtitle: "Workspace files, task attachments, and compliance
documents — separate from your written docs." Makes the relationship
to docs explicit.
docs-drive-ia.spec.ts asserts the sidebar Files link is present,click lands on /docs/drive, and the renamed heading + subtitle
render correctly.
destructive/success states replaced with semantic tokens:
text-red-{300,400} → text-destructive, bg-red-50 dark:bg-red-900/20
→ bg-destructive/10, text-emerald-400 (saved indicator) → text-success.
Touched: DocumentItem.tsx (Delete menu item), [documentId]/page.tsx
(error state), CodeLinksDisplay.tsx, CodeLinkPanel.tsx,
CreateSpaceModal.tsx, GenerationPanel.tsx (error+success banners),
DocumentEditor.tsx (Saved indicator). 15 refs collapsed.
CollaborationAwareness.tsx (dead code), VersionHistoryPanel.tsx (diff visualization where red specifically
means "removed"), SyncStatusPanel/GitHubSyncPanel (domain-specific
status palettes), and DocumentItem.tsx's yellow favorite-star.
/docs/drive, /docs/files, and /docs/knowledge-graph. All three render usable content on
mobile after the Cluster 1 fixes (Drive already had lg:flex-row
+ lg:w-56 responsive utilities; KnowledgeGraph paywall is
naturally vertically-flowed; /docs/files redirects to /docs/drive).
docs-mobile-sub-routes.spec.ts locks in the regression: eachroute's primary content is visible at 390 px and the CTAs/headings
don't overflow the viewport.
5 new E2E specs (frontend/e2e/docs-*.spec.ts):
docs-no-gradient-hero — regression guarddocs-drive-ia — sidebar link + renamed heading + subtitledocs-mobile-sub-routes — 3 routes × 390 px content reachabilityTotal docs E2E: 18 specs, ~32 s full pass.
In-app docs UX bug-fix sweep across three clusters (shell, editor,
a11y), TDD against 13 new E2E specs. Captures every fix in a failing-
then-passing test so the regressions can't sneak back. Cmd+K now
actually searches docs, mobile is no longer unusable, the editor
gets a real reading measure plus bullets + a floating BubbleMenu,
and the sidebar exposes tree semantics to assistive tech.
Cmd+K in /docs opens the doc-scoped SearchModal, not the global CommandPalette.** Two keydown listeners on document were
racing — the app-shell global was mounted earlier and won. The docs
layout now installs its listener in capture phase and calls
stopImmediatePropagation(), so the global never sees the event
on docs routes. (DocsLayoutClient.tsx)
w-60 flex-shrink-0 was eating ~62 % of a 390 px viewport. Sidebar
now slides off-screen via -translate-x-full md:translate-x-0,
with a data-testid="docs-mobile-menu-trigger" hamburger in a new
mobile top bar (pl-14 so it doesn't collide with the app-shell's
fixed-position trigger) and a backdrop that closes on tap.
Drawer auto-closes on route change.
NotionSidebar.tsx now opens the existing ConfirmDialog from
components/ui/confirm-dialog.tsx with tone="danger" and a
"Delete" primary action. The native browser dialog (which broke
visual consistency with the dark theme) is gone.
"Manage Space" were console.log("…") TODOs surfaced as live
affordances. NotionSidebar no longer passes the onDuplicate /
onManageSpace props, so DocumentItem's existing
{onDuplicate && (…)} guards collapse the rows. Real handlers
can be wired later without changing markup.
The bare prefix matched the [documentId] catch-all with
documentId="files" and loaded forever. A new
app/(app)/docs/files/page.tsx redirects to /docs/drive.
prose ... max-w-none (which ran ~140cpl on 1440 px viewports) replaced with
prose ... max-w-3xl mx-auto (~672 px / ~65 cpl). Editor
spec asserts ≤ 900 px at 1440 desktop.
(DocumentEditor.tsx:181)
reset was stripping bullets off bare <ul>/<ol> inside the
ProseMirror because typography-plugin prose-ul: modifiers
weren't resolving in the cascade. Switched to arbitrary-variant
utilities (`[&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal
[&_ol]:pl-6 [&_li]:my-1`) which carry enough specificity.
staying open across three intermediate actions. Added a
scoped keydown listener while the picker is mounted; on
Escape it sets showEmojiPicker(false).
autoSave is on by defaultwith a 1 s debounce; the duplicate Save button created
"is autosave actually working?" doubt. Drop the onSave prop
passed to EditorToolbar — the {onSave && (…)} guard already
collapses the row. handleManualSave callback also removed.
BubbleMenu only existed in CollaborativeEditor.tsx, which is
hard-disabled by collaborationEnabled = false. DocumentEditor
now mounts its own BubbleMenu with Bold/Italic/Underline/Code
controls. data-testid="docs-bubble-menu" lives on an inner
wrapper because @tiptap/react@2.27.1 BubbleMenu only forwards
className to the rendered div (verified by reading
node_modules/@tiptap/react/dist/index.cjs).
role="dialog" + aria-modal="true" + aria-label="Search documents" on the
modal root. Screen-reader users can now identify the overlay.
gets role="tree" + aria-label="Documents". Each
DocumentItem row gets role="treeitem" + aria-selected
(driven by isSelected) + aria-expanded when it has children.
Active document is aria-selected="true".
13 new E2E specs under frontend/e2e/docs-*.spec.ts, all live-
backend, no LLM (use backendOnlyReady + setupAiLiveAuth).
Spec-first per cluster: write specs → run them red → implement
fixes → run them green. Files:
docs-cmdk-doc-search, docs-mobile-sidebar (×2), docs-styled-confirm-dialog, docs-todo-menu-items-hidden,
docs-files-route-redirect
docs-editor-reading-measure, docs-editor-list-bullets, docs-editor-emoji-picker-escape, docs-editor-no-save-button,
docs-editor-bubble-menu
docs-a11y-search-modal, docs-a11y-doc-treeFull suite passes in ~22 s.
AI/automation E2E coverage expansion: the workflow builder now has a
schema-driven test fixture, 35 new Playwright specs across nodes,
triggers, actions, templates and end-to-end runs, plus tighter
assertions on the live-LLM tests so the suite actually catches
provider drift and prompt regressions instead of greenlighting them.
WorkflowNodeType in backend/src/aexy/schemas/workflow.py; the
canvas's JoinNode was already wired up but the schema literal
was missing, so nodes: [..., { type: "join" }] round-trips
through validation now instead of being silently coerced.
data-testid="palette-category-${kind}",
palette-subtype-${kind}-${value} on every entry, plus
data-testid="node-config-panel" + role="dialog" on the config
drawer. One helper change updates every spec instead of 200.
A bare row gave no visual hint that clicking does anything;
drag-first UX stays primary, the icon is subtle by design.
backend's validate_workflow rejects email actions without
email_body, so the "follow-up sequence" and "welcome sequence"
templates were silently failing the save with HTTP 400 and the
user saw an empty canvas after "saving" (automationTemplates.ts).
NodeConfigPanel writes action fields flat (data.email_body,
data.duration_value) and the backend reads them flat too;
nesting under data.config meant the validator never saw the
required fields. No remaining node.data.config.* readers
anywhere in the frontend.
trigger/action registry to
frontend/e2e/fixtures/automation-schema.generated.json. The
per-subtype specs (ai-automation-triggers-*,
ai-automation-actions-*) parametrise from this fixture so
adding a new trigger on the backend forces a matching test entry.
docker exec aexy-backend .... `npm run schema:automation:check`
is the CI drift gate. Both now precheck that aexy-backend is
running and exit with a clear message ("Start it with:
docker-compose up -d backend") instead of leaving devs to parse
a raw docker exec error.
1. Per-node CRUD (`ai-automation-node-{trigger,action,
condition,wait,agent,branch,join}.spec.ts`) — palette add,
config-panel render, click-to-select, delete.
2. Per-subtype parametrised loops —
ai-automation-{triggers,actions}-{module}.spec.ts covering
every trigger and action in every module's registry, all
driven by the generated fixture above.
3. End-to-end — canvas-wire (6-node save/reload
round-trip), templates (every gallery template lands a
usable graph), generate-workflow-per-module (LLM generator
across all 10 modules), run-agent and end-to-end
(record-created trigger → seeded LLM agent → workspace
state mutation, with marker-envelope assertions).
— openCanvas, addNodeFromPalette, canvasNodes,
openNodeConfig, connectNodes, saveWorkflow,
fetchWorkflow, deleteAutomation. Roughly 35 specs share one
contract; testid drift breaks one helper, not the whole suite.
ai-automation-run-agent and ai-automation-end-to-end now pass a per-test
echo_token in trigger_data and instruct the agent (via its
system prompt) to wrap it in a literal [ECHO:<token>]
envelope. The envelope shape can't appear from stub providers,
cached responses, or a passthrough copy of input data — only
from an LLM that actually read and reshaped the payload.
generate-workflow-per-module now hard-fails on unknown trigger_type.** A console.warn previously demoted LLM
hallucinations like record.modified (instead of
record.updated) to log noise nobody reads — exactly the
prompt-regression class this spec exists to catch. Now an
unknown trigger fails the test with the known-trigger list in
the failure message.
run-agent workflow status check tightened from ["completed", "running", "failed"] to strictly "completed".**
dry_run=true is synchronous so anything else means the
executor bailed before producing the node_results we go on to
assert against.
splits the LM Studio probe out of aiLiveReady. Structural
tests that don't invoke any LLM (canvas wiring, palette
interaction, save round-trip) no longer skip the entire spec
file when LM Studio happens to be down.
setupAiLiveAuth now sets the aexy_authed=1 cookie beforethe first navigation.** Middleware redirects every protected
route to /?next=... when the cookie is missing — and the
cookie is normally set client-side by useAuth AFTER mount, so
without this fix the very first goto bounced through the login
page and dropped any query params we'd set.
PLAYWRIGHT_BASE_URL instead of hard-coding http://localhost:3000, so the suite can run
against a non-default host (CI runner, remote box).
LMSTUDIO_BASE_URL=${LMSTUDIO_BASE_URL:-http://host.docker.internal:1234/v1}
into the backend. localhost inside the container was the
container, not the host — the agent action couldn't reach the
developer's LM Studio during E2E and dev runs.
AI surface hardening: the /automations canvas no longer crashes on
LLM-generated workflows, the agent provider list catches up with the
backend, the frontend dev container has the headroom to run the new
live AI E2E suite, and a small layout bug in the workflow generator
is fixed before it ships.
POST /automations/generate-workflow response had no position
on its nodes, so ReactFlow crashed the /automations canvas and
bounced the user to the route's error boundary. Backend now
assigns {x, y} to every generated node via a one-shot
auto-layout pass before responding
(backend/src/aexy/services/workflow_generator.py).
fan-in graphs (A→B→C→D plus A→D) now place the merge node at
the depth of the longer path, with descendants cascading correctly.
The earlier BFS variant settled the merge node at the shallower
depth if the short edge was walked first. Five new unit tests in
tests/unit/test_workflow_generator.py pin the contract: every
node gets a position, linear chains cascade right, the diamond
case settles on longest-path depth, existing positions are
preserved, and cycles render rather than crash.
backend has accepted "deepseek" and "lmstudio" as
AgentCreate.llm_provider values for a while; the frontend
selector only knew the four originals, so any agent created with
one of the new providers crashed the agent detail page when
LLMConfigDisplay tried PROVIDERS[provider].models.find(...).
Selector now lists DeepSeek (Chat + Reasoner) and LM Studio
(Qwen 3.5 9B), and LLMConfigDisplay falls back to a generic
render for any future unknown provider rather than throwing.
(frontend/src/components/agents/shared/LLMProviderSelector.tsx)
lazy compilation across /agents, /automations, /chat,
/compliance, … in quick succession was exhausting the default
Node heap and getting SIGKILL'd by Docker. Frontend service now
sets NODE_OPTIONS=--max-old-space-size=6144 (6 GiB V8 heap)
with a matching mem_limit: 7g so Docker doesn't kill the
process before V8 has a chance to GC (docker-compose.yml).
surface (agent chat + conversation create + prompt preview +
test run, /ask, workflow generation, automation test run, code
analysis, developer insights, email draft, file
metadata/sidecar, file search, hiring re-evaluate, learning
path, review-cycle generate) against the live stack — real
frontend, real backend, real LM Studio. Mocked AI responses
defeat the point of this tier; the existing *.spec.ts files
cover UI-only behaviour.
like the backend tests/ai/ suite.
frontend/e2e/fixtures/ai-env.ts (env +LM Studio probe + auth bootstrap) and
frontend/e2e/fixtures/ai-helpers.ts (seeders, long-timeout
response waiters, fatal-error collectors).
AI_E2E_LLM_WAIT_MS).A spec that times out is signalling that the model is genuinely
slow, not flaky — don't lower it. See the new
"AI E2E tests" section in CLAUDE.md for setup.
/reviews surface UX overhaul, prod-bug fixes, and a tighter
contract between the frontend and the manager-review backend. One
hard 422 (manager Save Draft) is fixed via a backend schema relax
+ matching client change; the rest is i18n parity, draft-hydration
correctness, and accessibility nits.
overall_rating: 0 as a sentinel against ManagerReviewSubmission
which is Field(ge=1, le=5) — every draft save before the manager
had settled on a rating was rejected. overall_rating is now
Optional[float] on the submission schema (the hard constraint
stays on FinalReviewData where it actually matters), the service
preserves any prior rating when None is passed, and the client
drops the ?? 0 fallback. Three new regression tests pin the
contract: null accepted, missing accepted, finalize still rejects
out of range (backend/tests/unit/test_reviews_prod_bugs.py).
hydration useEffect on /reviews/manage only wrote
discardedIds when the new workspace key had data; switching to a
workspace with no entry kept the previous team's discard list in
state. Now always resets (manage/page.tsx).
— manager review composer, self-review form, peer decline reason —
used a boolean hydratedRef that stayed true across client-side
nav, so visiting a second review/request id never hydrated its
draft. Now keyed by id (hydratedKeyRef === currentKey), with an
explicit reset when the new id has no stored draft.
advance on /reviews/cycles used to surface failures as a toast
that sat hidden behind the open ConfirmDialog; the detail page
rendered an inline red block inside the dialog. The list page now
uses the same inline block — same place users see the failure
matches the action that produced it.
en and hi (paritypreserved at 550 keys each). Sweep covers: cycles list ConfirmDialog
+ toasts + status filter + breadcrumb + error panel; goal complete
dialog; manage status filter; manage detail "Back to Reviews" +
"Invite Peer Reviewers"; peer-requests error title.
cycle, etc.) in English per the project convention.
/reviews/cycles/[cycleId] now has an explicit aria-label alongside title= — screen readers don't
reliably announce title, and the trigger needed a stable
accessible name.
next-env.d.ts and tsconfig.tsbuildinfo are now gitignored — the former is rewritten by Next between dev (.next/dev/...) and
prod (.next/...) builds, the latter is per-machine.
UX overhaul of the agents + automations surface, plus a four-week
accessibility sweep across the workspace shell. Nineteen commits since
0.8.01 consolidate three workstreams: a unified Operations IA, an
inbox triage rewrite, and a long polish tail that migrates the last raw
modals/drawers off ad-hoc divs onto Radix Dialog / Sheet primitives.
Closes with four follow-ups from the PR #148 review.
/operations, new). Single entry for agents *and* automations — replaces the two separate /agents and
/automations landings, which the audit flagged as the #1 user
confusion ("am I building an agent, or wiring a workflow?"). New
frontend/src/app/(app)/operations/page.tsx (534 lines) plus
sidebar layout updates and messages/{en,hi}/operations.json
translations.
/agents/[id]/inbox). Multi-select withshift-click range, bulk-action toolbar (approve / dismiss / mark
read), and full keyboard navigation (j/k row movement,
x = toggle-select, enter = open). Inbox detail polish adds five
follow-up wins (HTML email rendering via DOMPurify, sender chip,
read-state indicator, optimistic toggles, skeleton during refetch).
/agents/[id]/edit). Replaces the prior single hasChanges boolean — each of the seven
tabs (General / LLM / Tools / Behavior / Prompts / Escalation /
Email) reports its own dirty bit so users switching tabs see which
sections still have pending edits. Help text and the
system-agent-locks-non-LLM-tabs disable are part of the same pass.
frontend/src/hooks/useRouteGuard.ts, new). Anchor-click intercept + beforeunload for unsaved-changes
prompting; companion requestConfirm(href) API for programmatic
navigations (toolbar shortcuts, form-success redirects). Wired into
the edit page; ready for reuse on automation builder and CRM detail
forms.
agent detail page so executions and inbox counts refresh without a
manual reload. Pauses on hidden tabs (default RQ behavior); no extra
socket plumbing.
frontend/src/components/automations/TemplateGallery.tsx and
frontend/src/lib/automationTemplates.ts — the automation /new
page now opens to a curated gallery (standup digest, blocker
escalation, sprint kickoff, etc.) instead of a blank canvas.
<div role="dialog">surfaces (delete-agent confirm, email-disable confirm, multi-select
bulk confirm, automation-version pick) to components/ui/dialog.tsx
(Radix DialogPrimitive — focus trap, escape, restored focus on
close). Drawers (workflow Test Results, Execution History, Version
History) moved to components/ui/sheet.tsx. New
components/ui/confirm-dialog.tsx for the destructive-action pattern.
MessageBubble with safe link handling, aria-live="polite" execution-status region in
workflow nodes, prefers-reduced-motion respected on the chat
thinking-indicator and the workflow canvas pan/zoom transitions.
icon-only button across agents/automations/inbox; focus-visible
outlines added to all interactive surfaces; light-theme contrast
bumps on placeholder text and disabled-state buttons.
read/unread now flip instantly with rollback on error; inbox shows
skeleton rows during the first fetch instead of an empty state.
date helpers; replaced ~20 ad-hoc Intl.DateTimeFormat callsites.
next-intl ICU patterns so the Hindi locale gets correct plural
forms without per-callsite branching.
automations, inbox, insights, operations namespaces (full parity between locales).
tab carries its own dirtyByTab[id] so the tab strip can dot-mark
which sections have unsaved edits. Form-init effect skips re-sync
when the user has local changes (UX-EDT-021) — a refetch from
background polling or another mutation won't clobber in-flight
typing.
inflight tagging into a shared OAuthInflightTagger component;
callback page no longer touches localStorage directly.
AUTH_REQUIRED_PREFIXES matched /docs/ but not bare /docs, leaving the docs root unprotected by the auth
gate. Now matches both, consistent with every other entry in the
list.
_load_template_for_workspace helper. update_member_access and apply_template_to_member had
inlined the identical "template belongs to this workspace (or is a
system template)" check; both now call the helper.
new URL(anchor.href, ...) intry/catch. A page with a malformed anchor href would have thrown
inside the captured click handler.
the react-hooks/exhaustive-deps suppression: hasChanges and
name are read inside the form-init effect but intentionally
excluded from deps to avoid re-syncing the form mid-edit.
/agents/[id]/chat/...). New AgentService.stream_message emits tokens, tool-call markers,
and citations as Server-Sent Events; the frontend useAgentChatStream
hook wires them into the message bubble incrementally with an
optimistic placeholder, mid-stream stop, and a token-cost meter.
Migration migrate_agent_message_streaming.sql adds the supporting
columns on agent_messages (stream state, token deltas, citations).
class gained a stream() co-routine alongside the existing
request/response shape; the service routes streaming-capable agents
through it and falls back to a single-shot completion for the rest.
the cited tool-call output; renders even after the stream completes.
parent_message_id, so the detail pane renders the full back-and-
forth (incoming → agent reply → reply-to-reply, etc.) instead of a
flat list. New test_inbox_thread_chain.py (316 lines) pins the
resolver against forked threads and missing parents.
/new page cannow seed a workflow from a natural-language description. New
services/workflow_generator.py calls the LLM, validates the
produced node graph, and hands it to the existing builder. Wired
into TemplateGallery as a "Describe your workflow" entry.
api/email_webhooks.py (Postmark's MessageStream field was being dropped on rebound
events, breaking attribution for unarchived items).
GET /agents/defaults) returns the systemprompt / tools / behavior defaults for a given agent type so the
wizard and edit page render preview state without hardcoding.
Backed by useAgentDefaults on the frontend.
{{variable}} payload through the system prompt and renders the
result inline so users see what the agent will actually see at
runtime.
agent_drafts table (migrate_agent_drafts.sql), AgentDraftService,
GET/PUT/DELETE /agents/drafts endpoints, and the useAgentDraft
hook. Replaces the localStorage-only draft that vanished on
cross-device switches; drafts auto-restore on wizard re-entry and
garbage-collect on completion.
Sentry when NEXT_PUBLIC_SENTRY_DSN is set, falls back to a
structured console log otherwise. ModuleError.tsx boundary now
reports through it instead of swallowing. 156-line test suite covers
both branches.
Save button (aria-busy during inflight, error-region announcement
on failure), email-cancel resets the form to persisted values
instead of leaving stale local edits, NodeConfigPanel layout fix.
- reportError.test.ts (156 lines) — Sentry / console branches.
- useAgentDraft.test.tsx (326 lines), useAgentChatStream.test.tsx
(430 lines) — hook lifecycle, abort, error paths.
- test_agent_stream_message.py (510 lines) — five SSE flows
including mid-stream cancellation and tool-call interleaving.
- test_agent_draft_service.py (226 lines) — CRUD + workspace-
scope assertions.
- test_workflow_generator.py (233 lines) — graph validation +
LLM error fallback.
- test_agent_cost_estimation.py (125 lines), test_agent_preview_prompt.py
(340 lines), test_inbox_thread_chain.py (316 lines),
test_inbox_unarchive.py (193 lines),
test_email_webhook_parse.py (117 lines).
Post-merge audit of the streaming-chat + agent-runtime branch surfaced
one Critical cross-workspace gap on the new SSE endpoint plus a
cluster of Highs around partial state, citation XSS, and an SSE chunk-
buffering blind spot. Fixed in place; tests added for each.
conversations/{cid}/messages/stream` now calls
_assert_agent_in_workspace and rejects conversations whose
workspace_id doesn't match the URL. Previously the endpoint only
checked conversation.agent_id == agent_id, so a developer in
workspace A who knew a foreign workspace's (agent_id, conversation_id)
pair could stream user messages into that foreign conversation.
in a single transaction so a flush failure can't strand a user
message without a paired execution row. Inbox thread forward walk
now queries only the new frontier per round (was O(n²) on long
threads); capped at 50 rounds matching the backward walk. Workflow
generator caps generated graphs at 100 nodes / 200 edges so a runaway
LLM response can't spawn thousands of canvas nodes.
save_draft now uses attributes.flag_modified(...) to force the JSONB UPDATE (previously
relied on assigning a new dict, which worked but was fragile under
in-place mutation). Documented the pattern on the model field.
back to plain text for non-http(s) schemes, blocking
javascript: / data: URL XSS at the source. Live token meter +
per-message meter + "Sources" + "Processing…" + generate-prompt
placeholder all flow through useTranslations (messages/en/agents.json,
messages/hi/agents.json, messages/{en,hi}/automations.json). Per-
message meter stacks under the timestamp on narrow screens. Optimistic
message ids use crypto.randomUUID() instead of Date.now() so two
sends in the same millisecond can't collide React keys.
useAgentChatStream awaits refetchQueries then clears pending in the same tick (was
invalidateQueries + 80 ms setTimeout, which caused a one-paint
flicker when the refetch resolved fast). useAgentDraft tracks a
save-sequence + mountedRef so a slow in-flight save can't overwrite
newer state and unmount races don't trigger React's "set state on
unmounted component" warning. Inbox thread strip drives selection
through a state callback instead of document.querySelector(...).click().
cases to test_agent_cost_estimation.py (the longest-prefix-wins
sort would silently bill the wrong rate if reversed). Added a
useAgentChatStream test that tears a frame across two stream
chunks (mid-JSON + across \n\n) to lock in the buffer-reassembly
behavior. 77 backend + 82 frontend tests passing.
Post-review hardening of the 0.8.0 workspace-scope authz pass. Four
parallel reviewers audited the branch and flagged five Criticals plus
several Mediums that were missed in the original sweep; this release
closes all of them.
_filter_task_ids_to_workspace ended with a stray return sprint
(undefined name), so bulk_assign_tasks, bulk_update_status, and
bulk_move_tasks raised NameError for every in-workspace call
instead of authorizing them. Removed the dead return.
api/reviews.py — submit/finalize routes missed caller-identity checks**. submit_self_review, submit_manager_review, and
finalize_review accepted any authenticated caller. Added
_require_reviewee (caller must equal review.developer_id) and
_require_review_manager_or_admin (caller must equal
review.manager_id or hold workspace admin); both return 404 to
avoid existence oracles.
api/dependencies.py — story/task dependency mutations had no workspace scope**. update_story_dependency, delete_story_dependency,
resolve_story_dependency and the three task-dependency twins
loaded by id with db.get() and mutated without any tenancy check.
Added _load_story_dependency_authorized and
_load_task_dependency_authorized helpers that resolve the
dependent resource's workspace, assert active membership, and 404
on mismatch. Wired into all six routes.
api/email_webhooks.py — SES SNS Notification path skipped signature verification** (WS-082). Only the TopicArn was checked
against the allowlist; the field is attacker-controlled in the body,
so anyone who knew or guessed an allow-listed ARN could POST forged
Bounce/Complaint events. Added verify_sns_message_signature that
builds the canonical AWS SNS string-to-sign, validates
SigningCertURL against the AWS SNS host pattern (no SSRF), fetches
the cert (cached by URL), and RSA-verifies the message envelope.
Supports SignatureVersion 1 (SHA-1) and 2 (SHA-256).
services/email_webhook_verify.py — no replay window on SendGrid /Mailgun verifiers** (WS-082). A captured signed payload could be
replayed indefinitely. Added a 300s skew check on both providers,
matching the mailagent internal-auth middleware.
services/github_task_sync_service.py — cross-workspace [slug:task-key] auto-link** (WS-083). _find_aexy_task resolved by
workspace slug alone, so a malicious PR body in repo X (owned by
workspace A) containing [victim-workspace:42] could create a
TaskGitHubLink row pointing at workspace B's task. The lookup now
requires the resolved task's workspace to have actively adopted the
mentioning repo (WorkspaceRepository.is_active).
(WS-084). submit_standup, create_work_log, log_time, and
report_blocker accepted task_id/sprint_id/team_id from the
request body without scoping; the row was stamped with the caller's
first team's workspace. Replaced with _resolve_tracking_workspace
which derives the workspace from the supplied refs (in
task → sprint → team priority), rejects bodies that mix refs across
workspaces, and asserts the caller is an active member of the
resolved workspace.
api/developer_insights.py — non-admins received author_email PII** (WS-085). list_developer_commits returned the raw email
field for every active workspace member. Added _is_workspace_admin
helper that gates the field on owner/admin role; non-admins receive
null.
(WS-086). When the shared secret was missing, the middleware silently
passed every request through to handlers. Mailagent now raises
RuntimeError at boot when environment in {production, staging}
and the secret is empty; dev/test continue to pass through with the
existing warning.
The OAuth callback hung onto ?token=… in the address bar until the
next navigation. Now scrubbed via history.replaceState before any
token use, mirroring the /p/[publicSlug] flow.
/p/[publicSlug]/page.tsx — public-slug login didn't sync the presence cookie**. The page wrote token to localStorage but skipped
setAuthPresenceCookie(), reintroducing the redirect-loop class that
5895c1da had fixed for the landing page. Cookie now set inline.
Secure attribute on HTTPS so the flag isn't sent in cleartext if a
proxy ever downgrades the connection.
AnalyticsDetailsModal.tsx — external commit links missing noopener**. rel="noreferrer" only; added noopener for explicit
tabnabbing defense (modern browsers imply it, but the codebase
convention is to set both).
rule that all user-facing strings in new components must use
useTranslations(), the modal's ~30 hardcoded English strings (tab
labels, table headers, loading/empty states, etc.) are now driven
by the new insights.details namespace in messages/en +
messages/hi. The same pass i18n'd three new strings in
insights/page.tsx (Sources / Profile / Show inactive / "still
loading" toast).
the story- and task-dependency loader helpers: active member passes,
cross-workspace caller gets 404, missing id gets 404, removed-status
member is rejected.
tests (attacker cert URL rejected, valid sig accepted, tampered
payload rejected, dev-mode short-circuit) plus replay-window tests
for SendGrid and Mailgun. Refreshed the Mailgun happy-path fixtures
to use current timestamps.
_adopt_repo fixture that wires Repository + WorkspaceRepository for the test
workspace; new test_cross_workspace_slug_injection_is_blocked
exercising the WS-083 fix, plus test_shared_adoption_still_links
pinning that shared-repo adoption still resolves correctly to the
workspace whose slug was used.
Code review cleanup of work that originated on the long-running
agent-upgrade branch (compliance/tracking/automation/assessment
modules). Three reviewers audited the code as it currently sits on
main; this release fixes the verified Critical and High findings.
GET /channels, POST /channels, PATCH /channels/{config_id}, DELETE /channels/{config_id}
now verify the caller is a member of the target workspace (viewer for
read, member for write). Without it, an authenticated user in
workspace A could enumerate, create, edit, or delete channel configs
in workspace B.
GET /standups/team/{team_id} now fetches the team and asserts
workspace membership; GET /standups/summary/{sprint_id} does the
same via the sprint's team. Previously any authed user could read any
team or sprint's standup aggregate by guessing IDs.
GET /logs/task/{task_id} and GET /time/task/{task_id} now fetch the task and verify the
caller is a member of the task's workspace before returning logs or
time entries.
PATCH /blockers/{id}/resolve and PATCH /blockers/{id}/escalate now require workspace
membership (member role) before allowing state transitions.
Previously any authed user could resolve or escalate any blocker by
guessing its UUID.
team_id, the endpoint was returning blockers across all
workspaces. It now scopes the query to workspaces the caller is a
member of (WorkspaceService.list_user_workspaces); if team_id
is supplied, it verifies workspace membership for that team first.
api/assessments.py — workspace-scope authz across all authedendpoints**. Added two helpers:
- _assert_workspace_access(db, organization_id, developer_id, role)
for endpoints that take an organization_id directly
(POST /, GET /, GET /organization/{id}/metrics).
- _assert_assessment_access(db, assessment_id, developer_id, role)
that fetches the assessment and asserts workspace membership,
returning the loaded Assessment.
Applied to: create_assessment, list_assessments, get_assessment,
update_assessment, delete_assessment, clone_assessment,
get_wizard_status, all five step/N endpoints, list_topics,
suggest_topics, list_questions, create_question, update_question,
delete_question, generate_questions, list_candidates,
add_candidate, import_candidates, remove_candidate,
resend_candidate_invite, get_email_template, update_email_template,
pre_publish_check, publish_assessment, get_assessment_metrics,
get_organization_metrics, reevaluate_candidate,
get_candidate_details. Public-token endpoints
(/public/{public_token}/*) are out of scope (intentionally
unauthenticated). Previously any authed developer could read or mutate
assessments in any organization by guessing UUIDs.
(backend/src/aexy/api/tracking.py). The per-member developer fetch
loop was issuing one SELECT Developer WHERE id = ? per team member;
it now batch-loads all developers in a single IN query and indexes
by id.
timeout**. temporal/dispatch.py ACTIVITY_CONFIG now declares:
check_missed_standups, check_time_entry_thresholds,
check_stale_blockers, detect_blocker_patterns,
check_time_anomalies, check_standup_participation,
check_approaching_due_assignments, check_overdue_assignments,
check_expiring_certifications, check_expired_certifications,
check_bulk_compliance_rates — each with STANDARD_RETRY and a
10-minute timeout to accommodate scheduled detection activities that
loop over active workspaces.
backend/src/aexy/api/tracking.py: from typing import Any and
from aexy.services.automation_service import dispatch_automation_event
(dispatch is routed through services/tracking_events.py helpers).
WorkspaceService is now imported at module scope.
standup.streak and training.bulk_overdue — need product/design input on thresholds
before implementing.
NodePalette.tsx and the reminder/trackingpages — separate, larger effort that needs translator coordination.
tracking_events.py, tracking_compliance_config.py, compliance_service.py,
hiring_intelligence.py, assessment_service.py.
Replace manual GitHub issue/PR linking with mention-based auto-linking
via [workspace-slug:task-key] in PR or issue title/body.
api/webhooks.py routes issues events (opened/reopened/edited/closed) through
GitHubTaskSyncService.process_issue, which parses the issue title +
body for [slug:key] mentions and upserts a TaskGitHubLink row per
match with is_auto_linked=True. Works from any repo — the slug
resolves against Workspace.slug, the number against the
workspace-wide task_key.
pull_request.edited/synchronize and issues.edited, auto-links whose mention is no longer present in the
fresh body are deleted. Manual edits to the GitHub source are now the
way to add or remove links.
(task_id, repo, number), its cached github_issue_title/state/url
refresh when fresher values arrive (issue renamed on GitHub →
link metadata updates).
[slug:task_key]inline help so users know what to paste into a PR/issue body.
api/sprint_tasks.py and api/project_tasks.py:
POST /github-links/pull-requests and POST /github-links/issues.
GET /github/pull-requests, GET /github/issues,
GET /{task_id}/github-links/issue-repositories (both scopes).
board/page.tsx — the PR + issue search dropdowns, the manual owner/repo#123 entry, and ~300 lines
of supporting state/queries/mutations.
linkPullRequest, linkGitHubIssue, searchPullRequests, searchGitHubIssues, and
getGitHubIssueRepositoryContext from lib/api.ts (sprint and team
scopes). getTaskGitHubLinks and unlinkGitHubLink retained.
tests/unit/test_github_issue_auto_link.py — process_issue createsone auto-linked row per mention, case-insensitive slug match,
hyphens in slug, edit-then-remove drops the stale row, edit refreshes
cached title/state, closed/reopened refresh state without
pruning (only edited is allowed to remove mentions).
Fix duplicate developer rows in team insights, plus auto-hide
zero-contribution members.
compute_team_distribution now takes a member_ids list distinct from the activity-expanded developer_ids, so
_build_developer_alias_map can actually map ghost ids onto their
canonical workspace-member rows. The prior code passed the same
list as both args, which made the NOT IN filter exclude the
ghosts we wanted to bridge — producing two rows for "Ritesh
Biswas" (active vs ghost-with-personal-email) on the team insights
endpoint.
GitHubConnection:
1. Pull Commit.author_github_login (most-frequent value per
developer) and use it as the github login key.
2. Parse <id>+<login>@users.noreply.github.com out of the
developer's email. Together these collapse the two Mobashir
ghost rows that shared the same GitHub login but were never
linked to a Connection row.
_rollup_by_identity never sees a ghost+canonical pair — fewer
reliances on the identity_key tie-breaker.
compute_team_distribution(..., hide_zero_contribution=False)optionally filters out members whose four counters (commits, PRs
merged, lines changed, reviews given) are all zero in the window.
GET /workspaces/{id}/insights/team?include_inactive=false (default) — applies the filter. ?include_inactive=true restores
the full roster.
(insights/page.tsx) wired through useTeamInsights and the
generated getTeamInsights client.
author-github-login collapse, and zero-contribution filter.
GitHubConnection norany name/email overlap with their ghost rows cannot be linked
automatically. The three "Mobashir" rows in the original example
collapse from 3 → 2 (two ghosts merge), but the active member
mobashir.r@bimaplan.co stays separate until either an admin
links their GitHub login, or a manual "merge identities" action
is added.
Post-review hardening for the 0.7.82-0.7.88 workspace-scope leak audit.
The fixes were correct but a code review surfaced residual fail-open
edges and missing test coverage; this release closes those.
(services/email_webhook_verify.py). A new
webhooks_require_signing setting (default True) replaces the
prior behavior where each provider returned True when its env var
was missing. SES, SendGrid, Mailgun, and Postmark all reject events
outright when the required key isn't configured. Local development
can flip the flag off to fall back to the old accept-with-warning
behavior; production must keep the default.
mailagent/middleware.py:44). _is_public_path previously OR'd in path.startswith(p) (no
trailing slash), so /healthcheck-evil could skip HMAC auth on the
way to a route named with a public-prefix prefix. Tightened to
exact-match OR startswith(p + "/").
(frontend/src/lib/oauth.ts). The 0.7.85 implementation only
listened on mousedown, breaking OAuth login for keyboard users
(Tab + Enter on a focused login link) and any JS-driven navigation
(window.location.assign("/auth/github/login")). Now also installs
a capture-phase keydown listener and patches
window.location.{assign,replace} + the href setter so the
inflight marker is set on every navigation vector.
(api/booking/public.py). The 0.7.86 fix only guarded the workspace
lookup endpoint; the teams/team-by-id/event-type/slots endpoints
inherit the same throttle now via router-level Depends.
frontend/next.config.js). Negative-lookahead now anchored to embed/ so /embedded-* paths
still receive X-Frame-Options: DENY and
frame-ancestors 'none' instead of falling through both rules.
core/workspace_auth.py — centralizes the assert_active_member(db, workspace_id, developer_id) and
assert_resource_in_workspace(db, model, id, workspace_id)
helpers used across the 0.7.82-0.7.88 fixes. Call sites in
app_access.py and manager_learning.py switched to the helpers;
remaining inline copies will migrate opportunistically.
- backend/tests/unit/test_email_webhook_verify.py — pins the
fail-closed default for all four providers and the SubscribeURL
SSRF guard.
- backend/tests/unit/test_workspace_auth.py — pins membership
checks (active vs pending/suspended/removed) and the
resource-in-workspace mismatch case.
- mailagent/tests/test_internal_auth_middleware.py — pins the
public-path matcher against prefix-bypass paths and the HMAC
sign/verify wire-format round-trip between backend and mailagent.
- frontend/src/test/oauth.test.ts — pins safeInternalPath
against open-redirect inputs and round-trips
stashPostLoginRedirect.
/?next=... is now consumed. frontend/src/app/page.tsx stashes the (validated) next path in
sessionStorage for the OAuth flow, and useSetToken honours it
after onboarding completes. Open-redirect protection enforced by
safeInternalPath.
Closes the last 9 suspect rows in the workspace-scope leak tracker.
Five close as fixed with concrete patches; four close as verified-ok
or covered by prior fixes. Tracker is now zero open across every
severity.
update_member_access and apply_template_to_member (api/app_access.py) now verify the
target developer_id is an active WorkspaceMember of the route's
workspace, and that the applied_template_id belongs to that
workspace (or is a system template with workspace_id NULL).
create_learning_goal (api/manager_learning.py) verifies data.developer_id is an
active WorkspaceMember of current_workspace_id before stamping
a goal. Approval/budget routes follow the existing-goal chain so
they inherit the same scope.
ReportBuilderService.list_reports no longer surfaces is_public=True reports cross-tenant in the
default listing. Public reports now require an explicit matching
organization_id filter to appear. The reports route doesn't
pass organization_id today, so the default listing returns the
caller's own reports only.
get_developer_team (api/tracking.py) now accepts an optional workspace_id and
constrains the team join via Team.workspace_id. Existing call
sites keep historical "first team found" semantics; workspace-
prefixed routes can opt in.
(sprint analytics), WS-018 (public renderers), WS-019 (learning
services) closed as verified-ok or covered by prior fixes
(WS-009, WS-039, WS-041, WS-051, WS-055, WS-060, WS-061, WS-066,
WS-067, WS-068, WS-074). Each row now records the evidence used to
close it.
Closes the seven Medium/Low confirmed rows in the workspace-scope
leak tracker (WS-013, WS-065, WS-069, WS-070, WS-075, WS-082, WS-083).
LeaveRequestService._find_approver now joins Team and constrains
Team.workspace_id == workspace_id, so a developer's team lead in
another workspace can no longer become the approver on this
workspace's leave requests.
_check_roadmap_rate_limit(Redis sliding window: 10 creates / 50 votes per developer per
hour) on public_projects.create_roadmap_request and
vote_roadmap_request. Caps the spam vector while keeping the
public roadmap open to any authenticated developer.
/u/{token} now serves aconfirmation page on GET and only mutates subscriber state on POST.
Email prefetchers and link-checkers no longer trigger unsubscribes
while mail clients implementing RFC 8058's List-Unsubscribe-Post
still work.
_record_click_event resolves the ?r=<recipient_id> query parameter and drops the attribution
if recipient.campaign_id != link.campaign_id. The click is still
recorded at the link level; only the forged per-recipient
attribution is rejected.
_enforce_webhook_rate_limit (Redis sliding window) applied to /webhooks/github (600 per IP
per minute) and /webhooks/automations/{id}/trigger (60 per
automation per minute). Caps Temporal workflow / LLM token spam.
/webhooks/automations/{id}/trigger now records source_ip via
the shared get_client_ip helper instead of request.client.host,
so the captured IP honours X-Forwarded-For behind a load
balancer.
queryClient.clear() before the isResolved && !isAuthenticated redirect fires, eliminating
the brief window during a cross-tab logout where ghost-cached
React Query workspace data could be visible. The workspace-scoped
providers (ChatWebSocketProvider, WorkspaceSearchPalette,
FloatingChatWidget) were already gated on
isResolved && isAuthenticated.
Closes the remaining High rows in the workspace-scope leak tracker
(WS-060, WS-061, WS-067, WS-068) plus seven related Medium/Low rows on
the public/embed surface. Tracker now has zero open Critical or High
items.
booking/public.py) — get_workspace_teams, get_team_info, and the booking confirmation
response no longer leak member emails. Only id/name/avatar_url
is exposed. A new Redis-backed per-IP rate limit (30/min) gates
GET /public/book/{workspace_slug} to make slug enumeration costly.
Closes WS-060, WS-064.
public_projects.py) — added _project_team_ids helper. Backlog, board, stories, goals, roadmap,
sprints, and timeline endpoints now intersect with ProjectTeam /
GoalProject so a public project never leaks data from the other
projects in the same workspace. _fetch_sprints_with_stats accepts
a team_ids parameter; all callers now pass it. No schema migration
required. Closes WS-061.
booking/calendars.py) — start_oauth signs settings.frontend_url into state instead of the request Origin
header. Callback always redirects to settings.frontend_url,
ignoring any legacy signed value. Open-redirect via OAuth state is
closed. Closes WS-063.
booking/webhooks.py) — added _require_workspace_admin helper applied to every route
(list/create/get/secret/update/delete/test). An authenticated user
from workspace A can no longer read/modify webhooks (or their HMAC
secrets) for workspace B. Closes WS-062.
public_tables.py, models/crm.py) — added TableShareLink.allowed_origins column
(migration `backend/scripts/migrate_table_share_link_allowed_origins.
sql) plus _origin_matches helper. Every /public/tables/{token}*`
route now rejects requests whose Origin header isn't in the link's
allowlist (NULL/empty preserves legacy behaviour). Closes WS-066,
WS-074.
assessment_take.py) — get_assessment_by_public_token_or_id no longer accepts the
assessment UUID as a fallback for the public token; only
public_token matches. Candidate creation in start_assessment
goes through a Redis sliding-window rate limit
(_check_candidate_create_rate_limit): 5 candidates per IP per hour
and 50 per assessment per hour. Email-verification flow remains
backlog. Closes WS-067 fully and WS-068 partial.
booking/booking_service.py) — respond_to_rsvp is nowsingle-shot: refuses to process an attendee that already has
responded_at set, and rotates response_token after the first
use. A leaked email link can no longer be replayed to flip the
response later. Closes WS-076.
Closes the remaining four Critical and most of the High rows in the
workspace-scope leak tracker: frontend OAuth + framing hardening,
mailagent isolation, automation webhook signing, and per-provider email
webhook signature verification.
{id}/trigger now requires X-Aexy-Signature: sha256=<hex>` over the
raw body, verified with a per-automation HMAC secret derived as
HMAC(settings.secret_key, "automation:" + automation_id). Lets us
ship signature verification without a webhook_secret column
migration on CRMAutomation; the UI surfaces this derived value as
the automation's webhook secret. record_id is now constrained to
CRMRecord.workspace_id == automation.workspace_id before loading.
services/email_webhook_verify.py implements:
- SendGrid: ECDSA over timestamp + body against the configured
public key (X-Twilio-Email-Event-Webhook-Signature).
- Mailgun: HMAC over timestamp + token with the signing key.
- Postmark: HTTP Basic Auth against the configured user:pass.
- SES (via SNS): topic-ARN allowlist plus a hostname check on the
SNS SubscribeURL that restricts auto-confirmation to
sns.<region>.amazonaws.com (fixes the prior blind-SSRF).
Each provider handler now resolves the workspace from the
signature-verified sender via SendingDomain.domain lookup first,
and only falls back to the legacy message_id lookup when no
matching sending domain exists. New settings:
sendgrid_webhook_public_key, mailgun_webhook_signing_key,
postmark_webhook_basic_auth, ses_sns_topic_arn_allowlist.
mailagent/middleware.py InternalAuthMiddleware requires
`X-Mailagent-Signature: HMAC-SHA256(internal_secret, timestamp + "." +
body)` on every non-public route with a ±5min replay window. The
Aexy backend's mailagent_client._request signs every outbound call
when settings.mailagent_signing_secret is configured. CORS now
only mounts when cors_allowed_origins is set (default empty —
server-to-server only), and allow_credentials is False. `/send/
email validates from_address.domain` against the verified
mailagent_domains catalog and strips arbitrary headers down to a
whitelist of threading/unsubscribe ones. Per-workspace
EmailProvider isolation (full WS-079) is parked as a backlog
item — the unauthenticated-access vector is now closed.
/auth/callback now calls consumeOAuthInflight() and rejects the URL token (redirects to
/?error=oauth_state_missing) when the marker isn't present. A new
document-level OAuthInflightTagger (mounted in providers.tsx)
watches mousedown events for any <a href> matching
/auth/<provider>/(login|connect|connect-crm) and sets the marker
just before navigation. Catches the inline anchor login buttons in
app/page.tsx and LandingHeader.tsx without modifying every
callsite. The matching /p/[publicSlug] handler (WS-071) is
refactored to use the same shared lib/oauth.ts helper.
middleware.ts now redirects auth-required path prefixes to /?next=<path> when the
aexy_authed presence cookie is absent. The cookie is mirrored from
localStorage["token"] by useAuth on mount and at
setToken/logout. The JWT itself remains in localStorage and is
still validated by the API; the cookie just prevents the SSR app
shell from leaking placeholders to logged-out users.
next.config.js now configures headers(): X-Frame-Options: DENY + CSP
frame-ancestors 'none' everywhere except /embed/* (which gets
frame-ancestors * until per-link origin allowlisting moves to the
API side under WS-074). Also adds `Referrer-Policy:
strict-origin-when-cross-origin and X-Content-Type-Options:
nosniff` site-wide.
Closes 24 High and Medium ID-forgery rows in the workspace-scope leak
tracker (WS-010..014, WS-027..041, WS-044..047, WS-050..052, WS-054).
Each fix follows the same shape: load the referenced resource by id and
assert its workspace_id matches the route's workspace before delegating
to the service.
crm.py) — note CRUD and per-record activity list now verify CRMRecord.workspace_id == workspace_id
before exposing sub-resources. Stops `POST /workspaces/A/crm/records/
<B_record_id>/notes`. Closes WS-027, WS-028.
tables.py, forms.py) — list_fields now 404s on cross-workspace tables; delete_field and
reorder_fields verify form-in-workspace and field-in-form before
mutating. Closes WS-029, WS-030.
agents.py, agent_policies.py, automation_agents.py) — added _assert_agent_in_workspace helper
applied to all inbox actions (get/reply/escalate/archive/process),
routing-rule delete, agent-policy create, and automation-agent
trigger config. Routing-rule delete additionally verifies the rule
belongs to the agent. Closes WS-031..034.
cross-resource link operation now verifies the target shares the
workspace: link_project, link_epic, add_tasks_to_epic,
add_tasks_to_story, add_sprint_to_release,
add_stories_to_release, and sprint-task bulk_assign/status/move.
Sprint-task bulk_move also requires the target sprint to share
the workspace. The get_sprint_and_check_permission helper now
returns the sprint object so call-sites can scope queries to it.
Closes WS-035..039.
oncall.py) — verify_workspace_access now accepts team_id and asserts Team.workspace_id == workspace_id. All call
sites updated. Closes WS-040.
sprints.py) — list_sprints and get_active_sprint verify Team.workspace_id == workspace_id.
Closes WS-041.
team_calendar.py) — three GET endpoints now require workspace viewer-role membership and (when team_id is
supplied) verify the team's workspace. Closes WS-010.
tracking.py) — get_team_tracking_dashboard now resolves the team's workspace and
requires caller viewer-role before reading standups/blockers/time
logs. Closes WS-011.
dependencies.py) — added _require_member_ofhelper. Caller must be a member of the dependent story/task's
workspace before creating or listing dependencies. Also fixed a
pre-existing session.add(...) NameError on both `create_story_
dependency and create_task_dependency`. Closes WS-012.
chat_service.py, chat.py) — update_message and delete_message now accept workspace_id and constrain the lookup
via a ChatChannel.workspace_id join. A sender who is a member of
multiple workspaces can no longer edit a message in workspace B by
hitting workspace A's route. Closes WS-014.
leave.py) — added generic _assert_resource_in_workspace helper. Applied to update/delete of
LeaveType (admin-only), LeavePolicy (admin-only), Holiday
(admin-only), and leave-request approve/reject/cancel/withdraw.
get_developer_balance requires admin and verifies target is a
workspace member; get_team_balances verifies Team.workspace_id.
Closes WS-044..047.
google_integration.py) — link_email_to_record now verifies the CRM record belongs to the
caller's workspace before inserting the link. Closes WS-050.
entity_activity.py) — added _entity_model mapping plus _assert_entity_in_workspace helper
applied to both create_activity and add_comment. Validates the
10 most common workspace-scoped entity types (task/story/epic/
release/goal/crm_record/project/sprint/form/leave_request);
remaining types continue to be stamped pending follow-up. Closes
WS-051 (partial — see helper note).
reminders.py) — control-owner update/delete and domain-team-mapping delete now verify the target's workspace_id
matches the route. Closes WS-052.
planning_poker.py) — get_poker_session_state and the WebSocket entrypoint now resolve
the sprint and require viewer-role membership of
sprint.workspace_id. WebSocket rejects with 4003/4004 on miss.
Closes WS-054.
Continues the workspace-scope leak audit by closing four more Critical
rows from the tracker: three legacy unauthenticated APIs and the GitHub
webhook fail-open.
/analytics/*) — every endpoint now binds current_user_id (was discarded as _) and runs each request's
developer_ids (or path developer_id) through a
_require_developers_visible check that requires every target to
share an active workspace with the caller. Rejects (403) the whole
request rather than silently dropping invisible developers. Closes
WS-007.
/hiring/* section 1) — added get_current_developer to every route in the unauth section
(team-gaps, bus-factor, roadmap-skills, requirements list/create/get
/jd/rubric/scorecard/status). Helpers _resolve_team_workspace_or_403,
_require_developers_visible, _require_requirement_workspace_member
enforce workspace membership for the supplied team_id /
organization_id / requirement_id. JD generation, rubric
generation, requirement create/status update now require workspace
admin role. Closes WS-008.
/learning/*) — all 16 endpoints requireauthentication. Personal endpoints (list paths, generate path,
stretch tasks) require the caller to be the target developer or
hold admin role in a workspace the developer is a member of.
Path-scoped endpoints (get/regenerate/progress/milestones/activities
/recommended courses) use _require_path_access to resolve owner
via the path itself. Pause/resume/abandon are owner-only.
Team-scoped overview and recommendations require active membership
in the team's workspace. Closes WS-009.
/webhooks/github) — fail-closed when a webhook secret is configured: the X-Hub-Signature-256 header is now
mandatory (401 if missing) and verified. When no secret is
configured the route returns 503 unless settings.debug is True;
prevents an empty/typoed env-var from turning ingestion into an
open endpoint. Closes WS-059.
This release closes nine Critical authentication-bypass issues uncovered
by a platform-wide workspace-scope leak audit. A third pass added 28 new
tracker rows (WS-056..WS-083) covering the frontend, public/embed
surfaces, mailagent, and webhook ingress, with one same-day fix applied
to a frontend session-hijack vector.
/notifications/*) now binds the developer identity to the JWT via Depends(get_current_developer_id) on every
one of its 19 endpoints. The previous developer_id: str = Query(...)
parameter (used as authentication by every list/preference/push/admin
route) is removed. Closes WS-042.
/slack/*) — every admin-surface route nowrequires authentication and verifies the caller is an active
owner/admin of the integration's workspace via a shared
require_integration_admin helper. OAuth /install and /connect
derive the installer id from the current user, not a query
parameter. The signed webhook routes (/commands, /events,
/interactions) and the OAuth /callback remain public as
intended. Closes WS-043.
/reviews/*) — the entire surface (~28 endpointscovering cycles, individual reviews, work goals, peer requests,
contribution summaries) now requires Depends(get_current_developer)
and enforces resource-appropriate authorization: cycle CRUD requires
workspace admin; individual-review reads require reviewee / manager
/ peer-reviewer / workspace-admin; goal edits require ownership; peer
request actions require the actual party. Closes WS-021 through
WS-026.
/predictions/*) now binds current_user_id (was discarded as _) and requires the caller to share an
active workspace with the target developer at admin role for
attrition / burnout / trajectory / insights endpoints. Team-health
POST verifies admin permission in the supplied team_id's
workspace, or falls back to per-developer visibility. Closes WS-048.
/p/[publicSlug]) no longer silently writes a URL ?token= query parameter into localStorage["token"].
Token consumption now requires a one-shot oauthInflight
sessionStorage marker set by the page's own OAuth login button
immediately before navigating to the provider. Without that marker
the token is stripped from the URL and discarded. Closes WS-071; the
residual /auth/callback variant is tracked as WS-071b.
docs/workspace-scope-leak-tracker.md with 28 new findings(WS-056..WS-083) covering: cross-workspace CRMRecord pumping through
the unauthenticated automation webhook (WS-056), every email
provider webhook lacking signature verification (WS-057), an SSRF
in the SES SubscribeURL auto-confirm flow (WS-058), GitHub
webhook fail-open when no secret configured (WS-059), public
project endpoints returning entire workspace's data rather than
project-scoped data (WS-061), assessment public-token bypass
(WS-067), Candidate fan-out without verification (WS-068),
mailagent's zero-auth admin surface (WS-077), and cross-tenant
event injection through message_id lookup (WS-081). Each existing
fixed row was relabelled with file:line evidence pointing at the
patch.
This release hardens analytics authorization, scopes repository insights
strictly to adopted workspace repos, and adds an evidence drill-down on
the team insights page.
AnalyticsDetailsModal on the team insights page withSummary / Sources / Commits tabs surfacing the rows behind each
aggregate. A workspace-admin-only Raw tab exposes the underlying
JSON for debugging.
commits_synced, prs_synced, reviews_synced to theworkspace repository response, overlayed from the adopter's
DeveloperRepository row so the catalog and analytics agree on sync
state during the sync-pipeline migration.
against the workspace's adopted-repo allow-list, so a member's
personal or open-source contributions no longer leak into team-level
insights.
Removed and suspended members keep their historical attribution but
cannot keep calling analytics endpoints.
by WorkspaceRepository.workspace_id, making the cross-workspace
guarantee a query invariant instead of relying on data invariants.
/intelligence/team/{workspace_id}endpoints (burnout, expertise, collaboration, collaboration graph,
complexity, technology) that previously returned data when the caller
was not a workspace member.
author emails are not exposed to non-admin viewers.
membership. A teammate marked as "left" keeps their historical
attribution but can no longer read workspace notification settings,
AI code insights, role-gated resources via is_owner, billing
fallback workspaces, or per-app permission paths. Affects
notifications.py, code_insights.py, workspace_service.is_owner,
billing.py workspace selection, and app_access_service member
lookup (which protects four downstream config callsites).
NameError in the project PR search endpoint where the teamvariable was bound in the wrong function.
This release improves developer identity handling in insights and adds
soft member offboarding for workspaces.
contributors into canonical workspace members after safe dry-run review.
left and restore them later without deleting membership history.
email, GitHub login, avatar, identity key, and membership status.
compute per-member averages from the rolled-up contributor set.
search across identity fields, and hides past or external contributors
behind explicit toggles.
below active teammates.
This release improves the employee-facing review experience and reuses
the peer-reviewer invitation flow across manager and self-nomination
surfaces.
/reviews/my-reviews/[reviewId] so employees can open their ownreview, submit self-review notes, nominate peer reviewers when allowed,
track peer-review request status, and acknowledge completed manager
reviews.
InvitePeerReviewersModal that supports both managerassignment and employee self-nomination modes while preventing duplicate
active reviewer invites.
review cycle detail page when the current user is enrolled in the
active cycle.
review component.
actionable review surface instead of the admin-oriented cycle view.
This release resolves frontend TypeScript drift across app surfaces and
centralizes repeated marketing-page icon tuple types.
cards so AI Company OS, AI Agents, CRM, and GTM Intelligence pages can
reuse one typed tuple shape.
workspaces, plans, reviews, OKRs, campaigns, tables, agents, GTM,
planning poker, chat, and analytics payloads.
signatures, cloneElement icon typing, and fixture annotations so
TypeScript can validate without local casts.
aligned sprint backlog deletion with the existing archive task action.
onboarding, sprint, GTM, insights, e2e fixtures, and marketing pages.
This release improves performance review workflows with peer-review
detail pages, manager assignment tools, phase controls, and automated
deadline reminders.
decline, and submit focused feedback from a notification link.
with templates and delivery helpers.
reminders, plus a migration to track sent reminders per cycle.
actions with refreshed table/menu behavior.
cycle opens.
This release makes AI token usage visible and billable at the workspace
level, and adds raw commit detail behind developer insights.
overage cost tracking, and an idempotent migration for the new workspace
usage columns.
GET /workspaces/{workspace_id}/llm-usage so any workspacemember can inspect current AI token consumption and reset timing.
show the underlying synced commits behind aggregate metrics.
every workspace that has adopted the analyzed repository.
to a workspace, while preserving legacy developer counters as fallback.
This release tightens the AI insights experience after the initial
code-insights rollout, with better contributor-claim flows, more resilient
LLM execution, and clearer loading states.
reclaim orphaned GitHub commit, PR, and review activity without leaving
the context where missing activity is visible.
health panels stable while AI snapshots load.
commits, PRs, and reviews.
attribution, not only email-null contributor rows.
rate-limit waits so Temporal activities are less likely to burn retries
during LLM concurrency spikes.
connection failures fast.
states and contributor-claim entry points.
AI code insights now run across GitHub commits, pull requests, reviews,
and sprint task links, with workspace controls for enabling analysis and
new UI surfaces for reading the results.
similar-PR, reviewer-suggestion, task-PR alignment, and snapshot
retrieval workflows.
developer digests, repository health summaries, active PR refreshes,
task-to-PR alignment, and performance-review summaries.
embeddings, AI settings, and migration scripts for the new storage
columns and snapshot tables.
messages, and cards/panels for AI summaries in developer, repository,
review, sprint board, and settings pages.
organization/settings area.
supports branch-aware commit collection, and fans out AI analysis after
repository sync.
activity into the authenticated GitHub developer profile.
normal commits.
Tasks now have a copyable per-workspace identifier and a short
shareable link. Format is [{workspace_slug}:{task_key}] (e.g.
[aexy:42]); the bracketed form doubles as an auto-link token in
GitHub PR/issue titles. The kanban task card surfaces two icon-only
copy actions on hover — full link / full identifier shown on hover,
copied on click.
A new monotonic per-workspace counter assigns a task_key to every
new task. Combined with workspace.slug it forms the displayed
identifier [slug:N], rendered as a subtle monospace prefix on the
kanban card title and used as the body of two new copy actions in
the card's hover quick-actions bar. Existing tasks are backfilled
in created_at order per workspace.
sprint_tasks.task_key (int, unique per workspace) and workspaces.next_task_key (counter). Migration
migrate_task_keys.sql adds them, backfills existing tasks, and
seeds each workspace counter to MAX(task_key) + 1.
before_insert event on SprintTask — one UPDATE ... RETURNING consumes the next key
and serializes concurrent inserts. Covers all task-creation paths
(manual, GitHub import, Jira, Linear, workflows, templates,
planning poker) without touching their call sites.
SprintTaskResponse exposes task_key, workspace_slug, identifier, and public_url so the frontend can render and
copy without recomposing the string.
A short URL at /t/{workspace_slug}/{task_key} resolves to the
sprint kanban for the task, with the task drawer auto-opened.
GET /api/v1/tasks/by-key/{slug}/{key}returns the task UUID plus the sprint and project IDs needed to
build the redirect. Auth-gated on workspace membership.
frontend/src/app/(app)/t/[workspaceSlug]/[taskKey]/page.tsx calls the resolver and router.replaces to
/sprints/{project_id}/{sprint_id}?task={uuid} (or the project
backlog when the task has no sprint).
?task=<uuid> on mount, opens thetask drawer for that task, and strips the param so refresh
doesn't re-open it.
The task reference parser learns a new pattern for the native
[workspace-slug:N] form. When a PR or issue is ingested with that
bracket in its title, GitHubTaskSyncService resolves the matching
task by (workspace.slug, task_key) and creates a TaskGitHubLink
with is_auto_linked=True.
AEXY_BRACKETED_PATTERN regex \[([a-z0-9][a-z0-9-]*):(\d+)\] in task_reference_parser.py,
exposed as TaskReferenceSource.AEXY. Distinct from the existing
[PROJ-123] Jira/Linear pattern (the colon separator avoids the
collision).
(/webhooks/github) for both PRs and commits — no behavior change
for past PRs that didn't use this format, future ones link
automatically.
TaskCardPremium's hover quick-actions bar: Link2 copies the public URL, Hash copies the identifier.
Full string in the title= tooltip; Sonner toast on click.
[slug:N] prefix on the card title so theidentifier is visible at a glance without hovering.
Project-level (sprint-less) tasks reach feature parity with sprint
tasks. Backlog tasks can now carry attachments, attach GitHub PRs and
issues, accept comments, and surface a full activity history; several
silently-dropped fields on create/update across both routes are
plugged; the History tab now logs every meaningful task mutation
including archives, sprint moves, and planning-poker estimates; and
repository connection moves from per-developer to workspace-scoped.
Repositories are connected at the workspace level now, with
projects picking subsets. New tables workspace_repositories (the
workspace's adopted catalog) and team_repositories (the project's
selection) replace DeveloperRepository.is_enabled as the source of
truth for "which repos are tracked here." Migration
migrate_workspace_team_repositories.sql backfills both from
existing per-developer enables so nothing in scope today disappears.
GET/POST/DELETE /workspaces/{id}/repositories (admin), GET/POST/DELETE /teams/{id}/repositories, plus
POST /workspaces/{id}/repositories/{wr_id}/reclaim for the
former-member adoption flow.
WorkspaceRepositoryService exposes the adopt / unadopt /reclaim / link-team / unlink-team / pick_installation_developer
surface; the canonical sync state (sync_status, last_sync_at,
webhook bookkeeping, incremental cursors) lives on
workspace_repositories since sync is workspace-owned now.
LimitsService.can_adopt_repository(workspace_id) counts active
rows against the workspace's effective plan and gates the adopt
endpoint. Removes the per-developer counter from the gating path
(still used as a display-only roll-up on the limits widget).
search/import, the auto-sync Temporal scheduler, developer
insights, sync-status. Per-developer enable/disable endpoints
are removed; the column DeveloperRepository.is_enabled stays
as a discovery cache and gets cleaned up in a follow-up.
/settings/projects/{projectId}/repositories for picking which
workspace repos a project tracks.
/settings/repositories lists workspace_repositories whose
adopter is no longer an active workspace member, with a one-click
"Reclaim" action that re-binds the row to the active member who
clicked it (or any active member with reach as a fallback).
WorkspaceRepository.sync_status='no_credentials' is set
automatically when the auto-sync scheduler can't get a token,
surfacing the same banner.
handleRepoToggle on /settings/repositories to call workspaceRepositoriesApi.adopt / unadopt instead of
the removed per-developer endpoints; existing UI keeps working,
the toggle now adopts into the current workspace.
Sprint-less project tasks had attachment upload gated behind a "Move
this task into a sprint to upload attachments" banner because the
only attachment routes lived under /sprints/{sprint_id}/tasks/....
Added parallel endpoints under /teams/{team_id}/tasks/{task_id}/attachments
(POST / GET / DELETE) authorised via team membership. Both routers now
share the same upload, list, and delete logic via a new
backend/src/aexy/services/task_attachment_service.py (S3 put,
storage-quota assertion, AI metadata pipeline dispatch, S3 delete,
quota-cache invalidation — all in one place). The frontend picks the
right endpoint based on task.sprint_id; the gate banner is gone.
The PR linking section in the task modal now works for project-level
tasks — new endpoints GET /teams/{team_id}/tasks/github/pull-requests
and POST /teams/{team_id}/tasks/{task_id}/github-links/pull-requests
mirror the sprint-scoped equivalents (workspace-membership check on
the PR author preserved). The list endpoint at
/teams/{team_id}/tasks/{task_id}/github-links now returns both issue
and PR links (previously filtered to github_issue only). The
EditTaskModal dispatches search and link mutations to either endpoint
based on whether the task has a sprint_id.
New POST /teams/{team_id}/tasks/import (with
projectTasksApi.importTasks on the frontend) imports GitHub issues
into the team's backlog without requiring a sprint, populating the
"Select issue" dropdown across every task in the team. New service
helpers add_project_task and _import_project_task_items keep the
import dedup keyed on (team_id, source_type, source_id).
The History tab previously rendered "Move this task into a sprint to
view its full activity history" for sprint-less tasks because the only
activities + comments routes were sprint-scoped. Added the matching
team-scoped routes (GET /teams/{team_id}/tasks/{task_id}/activities
and POST /teams/{team_id}/tasks/{task_id}/comments) and updated
AssignmentHistoryPanel to dispatch by task.sprint_id vs
task.team_id. Activity rows are keyed on task_id only on the model
side, so existing per-task creation / status / assignment / field-change
events surface for backlog tasks without any data backfill.
Audit pass on every place that mutates a SprintTask. Previously
silent paths now write per-task TaskActivity rows:
SprintTaskService.update_taskinstead of duplicating field assignments, so backlog edits get the
same per-field timeline (title_changed, priority_changed, etc.)
that sprint tasks have.
status_changed row in addition to the workspace EntityActivity it already emitted.
attachment_added / attachment_removed rows attributed to the actor; affects sprint
AND project tasks (this was missing for both).
archived / unarchived rows; actor_id threaded through archive_task, unarchive_task,
and remove_task on the service.
sprint_id, the dedicated move-to-sprint endpoint, and bulk_move_to_sprint) write
sprint_changed with prior and new sprint IDs.
points_changed row when theestimate it stamps onto each task differs from the prior value.
created row so backlogtimelines start with "X created this task" instead of empty.
TaskActivityAction extended with attachment_added,
attachment_removed, archived, unarchived, and sprint_changed,
with renderer cases in both task modals.
POST /teams/{team_id}/tasks accepted start_date, end_date, and
estimated_hours in ProjectTaskCreate but the handler instantiated
SprintTask(...) without passing them through, so a fresh task always
saved with NULL dates and NULL hours regardless of the form. The
frontend create path mirrored the drop —
useProjectBoard.addTaskMutation explicitly listed each forwarded
field and the dates/hours weren't in the list. Wired all three fields
through every layer (SprintTask kwargs in the backend, mutationFn type
and forwarding, and the create and addTask API client signatures).
The same route accepted start_date, end_date, estimated_hours,
and contributes_to_goal in SprintTaskUpdate but the inline
update in project_tasks.py:update_task only handled
title/description/story_points/priority/status/labels/epic_id/sprint_id/
assignee_id/mentions. Editing dates or hours on a backlog task looked
successful but nothing persisted. Added the four missing assignments
with model_fields_set semantics on the date and hours fields so
callers can clear them by sending explicit null; contributes_to_goal
is non-nullable on the model and stays "set when explicitly provided."
task_to_response was duplicated across sprint_tasks.py and
project_tasks.py and the project-tasks copy was missing
attachments, work_started_at, cycle_time_hours,
lead_time_hours, contributes_to_goal, start_date, end_date,
and estimated_hours. Result: uploading an attachment to a backlog
task succeeded server-side, but when the UI re-fetched the task via
the project-task list/get/update endpoints, the response serialized
attachments: [] and stale nulls for dates/hours. Extracted the
canonical builder into a new
backend/src/aexy/services/sprint_task_response.py and pointed both
routers at it, so the response shape stays in lockstep going forward.
The mirror bug on the sprint-scoped route: data.description_json
came in via Pydantic but task_service.update_task had no parameter
for it, so the rich-text representation never updated even when the
plain description did. Added a sentinel-typed description_json
parameter to SprintTaskService.update_task (with no activity-log
entry — description_changed already covers that), and pass it
through from the sprint-tasks PATCH handler.
sprintApi.updateTask, projectTasksApi.update, and
useProjectBoard.updateTaskMutation had TypeScript signatures that
omitted start_date, end_date, estimated_hours, and
contributes_to_goal. The runtime axios call still sent them
(JavaScript is permissive), but the types misled callers. Added the
missing fields so the contract matches the backend.
Patch release on top of 0.7.7. Fixes a production-only file-upload
outage, light-mode contrast on the task-create form, and brings the
deployment docs in line with the real stack.
docker-compose.prod.yml had no rustfs (or any S3-compatible) service
and no S3_ENDPOINT_URL / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
env vars on backend or temporal-worker, even though the dev compose
ships rustfs and points the backend at it. Result: in production
StorageService.is_configured() returned False and every file upload —
task attachments, recording uploads, compliance docs — returned `503
File storage is not configured on this deployment. Added a rustfs`
service to the prod compose (internal-network only, with healthcheck),
wired the S3 env vars on backend and temporal-worker, added
rustfs_data and rustfs_logs volumes, added an /storage/ proxy
location to nginx/nginx.conf so uploaded URLs are reachable from the
browser, and seeded RUSTFS_ROOT_USER / RUSTFS_ROOT_PASSWORD /
S3_PUBLIC_ENDPOINT_URL in .env.prod.example. Existing operators
need to set those three values in .env.prod and re-run
docker compose -f docker-compose.prod.yml up -d.
The native <input type="file"> "Choose files" button on the new-task
form and the secondary "Link issue" button on the GitHub Issues panel
both used bg-primary-*/10 + text-primary-200/300 — both very light
blue, which collapses to barely-visible against the form background in
light mode. Reskinned all three controls (two file inputs + the link
button) to the solid bg-primary-600 + text-white style already used
by the primary "+ Link" button, so they pass contrast in both light
and dark mode.
A new docs/guides/database-operations.md is now the canonical
reference for everything that touches PostgreSQL: the custom SQL
migration system at backend/scripts/migrate_*.sql, manual and
automated backups (the production aexy-backup sidecar at 02:00 UTC),
restore from sql dump, restore from volume snapshot, the safe
postgres image-rebuild flow (data on the postgres_data named
volume is independent of the image — down -v is what kills it),
the major-version upgrade dump-and-reload procedure, and pgvector
specifics. Linked from docs/README.md, DEPLOY.md, and the
deployment guide.
DEPLOY.md and docs/guides/deployment.md were brought in line with
the actual stack: the alembic upgrade head references became
python scripts/run_migrations.py, the Celery / Celery beat /
Flower references became Temporal worker / Temporal UI / Temporal
schedules, the postgres prerequisite is now PG 18 with pgvector
(the bundled aexy-postgres:18-alpine-pgvector image) instead of
PG 14/16, and the deployment example compose now includes the
temporal, temporal-ui, and temporal-worker services. The
backup/restore quick-references in both docs now point at the new
Database Operations guide for full procedures.
Workspace owners/admins now have a dedicated breakdown page at
/settings/billing/breakdown answering "what am I being charged this
period and why." Platform admins get the same view across every
workspace at /admin/billing with a margin column and a click-to-drill
drawer. Both reuse a single BillingBreakdownView component so the
shape and behavior stay consistent.
BillingBreakdownService (backend/src/aexy/services/billing_breakdown_service.py) composes LimitsService, UsageService, PostpaidBillingService,
and StorageQuotaService into one typed BillingBreakdown. Line
items: base subscription fee, active seats (with included vs
billable split), LLM usage per provider (tokens, request count,
rate display), storage usage (informational), plus info counters
for plan-included free tokens and postpaid accruals. The service
reads period bounds from WorkspaceSubscription.current_period_*
and falls back to the current calendar month.
GET /api/v1/billing/breakdown?workspace_id=…&period=current|previous|YYYY-MM and GET /api/v1/billing/breakdown/history?workspace_id=…&months=6,
gated by verify_workspace_admin. Margin information is never
exposed via these routes.
/api/v1/platform-admin/billing/*: breakdown, breakdown/history, summary (paginated, filterable
workspace table), and totals (revenue, margin, top workspaces,
plan-tier and billing-model splits). Margin (base_cost_cents
vs charged_cents from the snapshotted UsageRecord rows) is
exposed only here.
BillingBreakdownView renders the period header, total/delta cards,a category-grouped line-item table with per-item drilldown (provider,
request counts, base cost when admin), info counters, invoices for
the period, and a 6-period sparkline. The delta_cents /
delta_pct are computed against the prior month's
usage_aggregates row, falling back to live SQL over
usage_records when the aggregate is missing.
Billing Breakdown (adminOnly) under Account in settingsNavigation.ts, and Billing in the platform-admin sidebar
in (admin)/layout.tsx.
New aggregate_billing_usage activity (analysis.py) wired into
worker.py and scheduled in schedules.py to run every 24h. It
calls UsageService.update_usage_aggregate for every active
customer subscription's current period, plus the current and prior
calendar month for every workspace that has any usage. Without this
job the historical breakdown view stays empty in production —
nothing else writes to usage_aggregates.
Added settings.billing.breakdownPage and settings.platformBilling
translation namespaces in messages/en/settings.json and
messages/hi/settings.json. Every user-facing string in the new
pages and the shared BillingBreakdownView component goes through
useTranslations(). Plan tier and billing-model labels stay in
English in the Hindi translations per project convention.
The breakdown previously emitted a synthetic free_credit line item
with a negative subtotal, dropping total_cents by an estimated
allowance. The Stripe billing pipeline
(UsageService.report_workspace_usage_to_stripe) reports the raw
sum of UsageRecord.total_cost_cents with no such deduction —
per-member free quotas live on Developer.llm_overage_cost_cents
and never reduce the workspace invoice. The result was that the UI
showed a lower bill than what Stripe charged. The synthetic credit
is now surfaced as free_tokens_per_member_per_month and
llm_tokens_used info counters plus a computation note explaining
the per-developer scope, so total_cents always equals what the
billing pipeline reports.
GET /platform-admin/billing/summary was paginating on the workspace
query first, then dropping rows whose computed plan_tier or
billing_model didn't match. A filtered request could return an
empty first page even when matches existed on later pages, and the
total count reflected only the search filter. The filters are now
pushed into SQL: plan_tier joins Workspace.plan_id → Plan.tier,
billing_model joins `WorkspaceSubscription.workspace_id →
WorkspaceSubscription.billing_model. total` reflects the filtered
set, and pagination operates on the filtered query. Workspaces with
no active subscription row are excluded when billing_model is set
(they have no canonical workspace-level billing model to filter on);
plan-tier filtering uses the source plan tier and does not consider
workspace plan overrides.
The History tab on the task modal now shows every change to a task —
not just assignment and status — and every change is attributed to the
user who made it. A reviewer can see who created the task, who renamed
it, who shifted the dates, who edited the description, who reassigned
it, and who dragged it across the board, top-to-bottom in the order
events actually happened.
SprintTaskService.update_task now snapshots each field before mutation and writes a per-task TaskActivity row (title_changed,
description_changed, points_changed, priority_changed,
status_changed, labels_changed, epic_changed,
start_date_changed, end_date_changed,
estimated_hours_changed) for every value that actually changed.
Description bodies are not stringified into old_value/new_value
— only the fact that the description changed is recorded — to keep
the activity row small for rich-text edits.
update_task_status and bulk_update_status now accept an actor_id and write a per-task activity row attributing the status
change to the user who dragged the card or clicked the pill.
Previously the workspace-wide EntityActivity feed had this but
the modal's History tab did not.
create_task records the creator on the created activity row, sothe History tab opens with a "X created this task" line instead of
silently starting at the first edit.
TaskActivityAction union extended in frontend/src/lib/api.tswith the six new field-change actions, and the renderer in
AssignmentHistoryPanel (board page) and ActivityItem (single
sprint page) now switches on every action with human-readable
copy: "renamed to X", "set due date to Y", "cleared estimate", etc.
it shows everything, with the actor name on every line.
Dropping a task into a new column updates the cache before the
network round-trip, so the card stays where the user dropped it
instead of snapping back to its original column for ~100 ms before
re-rendering. Both useSprintTasks (sprint board) and
useProjectBoard (workspace tasks) gained onMutate /
onError / onSettled handlers that snapshot the prior cache,
apply the new status optimistically, roll back on failure, and
invalidate on settle. The "snap back, then move" flicker that
made dnd-kit feel laggy is gone.
TipTap's Link extension was switched to openOnClick: false in
edit mode (when readOnly is false), so single-clicking a link
inside the editor now lands the cursor on it for editing instead
of opening it in a new tab. Cmd/Ctrl+click still opens the link.
In read-only renders (description preview, comment view) plain
clicks open the link as before.
DELETE /sprints/{sprint_id}/tasks/{task_id}/attachments/{id} was
removing the task_attachments row but leaving the underlying S3
object in RustFS forever, so deleted files kept counting against
the workspace's storage quota. The endpoint now derives the storage
key from the attachment URL via the new
StorageService.key_from_url (handles both path-style and the R2
virtual-hosted style), calls delete_object, and invalidates the
workspace usage cache via StorageQuotaService so the quota meter
catches up immediately.
Reassigning a task by sending PATCH /sprint-tasks/{id} with a new
assignee_id updated the row and wrote the assignment activity, but
never dispatched the task.assigned automation trigger — only the
dedicated /assign endpoint did. So workspace automations subscribed
to task.assigned (Slack DMs, Linear sync, etc.) silently missed
every reassignment performed through the task modal's edit flow.
update_task now mirrors assign_task's dispatch_automation_event
call when the assignee changes.
_stringify_field in sprint_task_service renders TaskActivity field values consistently — None stays None (so
the History tab can render "—"), datetimes go through .isoformat(),
and lists join with , . Avoids the "None" string showing up
in old/new value cells.
hasattr(task, "attachments") guard in task_to_response — the attachments relationship is always
present on SprintTask since the v0.7.4 schema migration.
A workspace-wide Drive backed by S3-compatible storage (RustFS in dev),
enriched by an AI metadata pipeline that captions images, tags documents,
and annotates videos with timecoded events from a vision-language model.
drive_files table with folder hierarchy, soft delete, and per-kindrendering hints (file / folder / image / video / audio / pdf / doc).
Smart Views are filter overlays — they don't move files, they translate
a JSONB filter to a file_metadata join. Migration
migrate_drive_v1.sql is idempotent and adds covering partial indexes.
/workspaces/{ws}/drive/files, /folders, /files, /files/{id}, /smart-views, /files/{id}/annotations, /files/{id}/reannotate,
and /usage endpoints. Multipart upload caps at 500 MB per file and
2 GB per batch before the plan-level quota check, protecting worker
memory.
/docs/drive: file grid, smart-view sidebar, hybridsearch bar, multi-file dropzone, quota banner, and a video player that
overlays Qwen-VL annotations on the timeline.
max_storage_gb (with -1 for unlimited),workspace-level overrides, and a Redis-cached usage rollup spanning
drive_files, task_attachments, and compliance_documents. Concurrent
uploads are serialised per-workspace via a Postgres advisory lock so
two simultaneous uploads can't overshoot the cap.
A single file_metadata row per file regardless of where the file lives.
(source_type, source_id) is unique across drive_file,
task_attachment, and compliance_document. file_embeddings and
video_annotations foreign-key to file_metadata.id, so a non-Drive
video (e.g. a task attachment) can carry annotations through the same
machinery. Adding a fourth source type is one resolver registration —
no schema change.
migrate_file_metadata_v1.sql creates the schema in a single transaction with a GIN index on ai_tags/ai_categories and an
ivfflat cosine index on the 1024-dim embedding column.
/workspaces/{ws}/files/{source_type}/{source_id}/metadata and .../reannotate endpoints — the frontend's universal "Reannotate"
button posts here regardless of source.
/workspaces/{ws}/search/files?q=…&kinds=… workspace-wide hybrid search: pgvector cosine over file_embeddings plus an ILIKE pass over
ai_summary and per-source file names. Cmd+K palette
(WorkspaceSearchPalette) is the user-facing surface.
/workspaces/{ws}/source-files?source_type=… browse endpointreturns a unified file row for any source. The Drive sidebar uses it
to render virtual cross-source views ("Task attachments",
"Compliance documents") in the same grid as drive files.
The gateway grows lazy vision and embeddings properties selected via
settings.llm.vision_provider / embeddings_provider. Provider keys
are tracked separately from chat-LLM usage so vision + embedding spend
shows up distinctly in the rate limiter.
qwen/qwen2.5-vl-72b-instruct by default) and local Ollama (any Qwen-VL tag). Both implement analyze_image and
analyze_video_frames.
text-embedding-3-large@1024) and Ollama (bge-m3). Both produce pgvector-compatible 1024-dim vectors
so the two backends are interchangeable.
gateway.embed_batch_limited, vision_image_limited, and vision_video_frames_limited helpers gate every call through the
Redis rate limiter. Provider keys: qwen-openrouter, qwen-ollama,
embeddings-openrouter, embeddings-ollama.
asyncio.to_thread, so a multi-minute video doesn't block the worker
event loop.
A super-admin UI under /admin/plans to inspect plans, edit
per-workspace overrides, and kick off the AI metadata backfill for
existing rows.
scans uncovered drive_files, task_attachments, and compliance_docs
and dispatches the AI pipeline at the configured rate. The button
is idempotent — re-clicking finds the running workflow rather than
starting a parallel one.
vision_provider, vision_model, embeddings_provider,
embeddings_model, and embeddings_dim now live under the LLMSettings
group instead of the root Settings. Existing VISION_PROVIDER /
EMBEDDINGS_* env vars continue to work.
Added to both frontend/src/config/appDefinitions.ts and
backend/src/aexy/models/app_definitions.py so it shows up in app-bundle
permission templates and the sidebar layout filter.
ai_status, ai_summary, ai_tags, ai_categories, and
ai_processed_at were removed from drive_files and the DriveFile
TypeScript interface. AI metadata is now read from file_metadata via
the polymorphic endpoint or the useFileMetadata hook. FileCard fetches
its own AI metadata per row, which means task_attachment and
compliance_document files render with the same AI badges in the Drive
grid.
GET /workspaces/{ws}/drive/search and driveApi.search are gone.
Callers use the workspace-wide /search/files?kinds=drive_file endpoint
(via useDriveSearch, which adapts the response to the legacy hit
shape so the UI didn't have to change).
imports survived the polymorphic-metadata refactor — DriveFileEmbedding
in drive_search_service, VideoAnnotation.file_id in drive_service,
and a max_storage_gb default placed before required dataclass fields
in EffectivePlan. Each one raised at module-import time, taking down
the entire FastAPI app on startup. All cleaned up; drive_search_service
was removed entirely (replaced by the cross-source file_search_service).
gateway was reading settings.vision_provider etc. off the root
Settings, but those fields had been moved to LLMSettings. First
call to gateway.vision or gateway.embeddings crashed.
_scan helper's select(FileMetadata).join(FileMetadata, …) re-joined
FileMetadata onto itself; the source table was never in the FROM
clause. Now starts from the source table and joins file_metadata
correctly.
folder A under one of its own descendants (A → … → D → A) silently
succeeded and corrupted the tree. Now walks the parent ancestry and
rejects on collision.
get_llm_gateway() returned None (misconfigured or no API keys), FileSearchService and the Drive
search route called gateway.embeddings and crashed. Both now accept
Optional[LLMGateway] and degrade to keyword-only search.
route replaced with Body(default_factory=BackfillStartRequest).
match an allowlisted host suffix (.amazonaws.com, .cloudfront.net,
.r2.cloudflarestorage.com, .aexy.io) or the configured
s3_endpoint_url. After DNS resolution, every returned IP is checked
against private / loopback / link-local / multicast / reserved /
unspecified ranges, defending against DNS rebinding attacks where a
"public" hostname resolves to 169.254.169.254 or RFC1918. Storage
endpoints matched verbatim skip the IP check by design (ops controls
those names; they often resolve privately). follow_redirects=False
prevents 30x bypass.
/workspaces/{ws}/files/{source_type}/{source_id}/reannotate endpoint
used to dispatch the LLM pipeline without verifying that source_id
belonged to workspace_id. Any workspace member could trigger
reprocessing of any file in any workspace by guessing a UUID, charging
the LLM bill to the wrong tenant. Now resolves the source row and
rejects with 404 when the workspace doesn't match.
workspace could both pass the cached usage check and overshoot the
cap by ~2× the incoming bytes. assert_storage_available now wraps
the check in pg_advisory_xact_lock(hashtextextended(workspace_id, 0))
and reads the used-bytes total fresh from the DB inside the lock.
migrate_source_files_idx_v1.sql):
- idx_drive_files_workspace_uploaded on
(workspace_id, uploaded_at DESC) partial
WHERE deleted_at IS NULL AND kind <> 'folder' — covers the exact
scan the endpoint runs and skips the sort step.
- idx_task_attachments_task_uploaded on (task_id, uploaded_at DESC)
— speeds the join-then-sort pattern when listing all task
attachments in a workspace.
- compliance_documents already had (workspace_id, created_at DESC)
from migrate_compliance_documents.sql — no new index needed.
messages/en/drive.json and messages/hi/drive.json cover the Drive UI: ~65 keys across drive.page, drive.fileCard,
drive.upload, drive.quota, drive.smartView, drive.video,
drive.aiBadges, drive.metadataPopover, drive.metadataSidecar,
and drive.search. ICU placeholders ({count}, {percent}, {used},
{limit}, {incoming}) match across both locales.
drive-quota.spec.ts, drive-smart-views.spec.ts, drive-upload.spec.ts,
compliance-doc-ai-sidecar.spec.ts, task-attachment-ai-tags.spec.ts,
workspace-search-palette.spec.ts, admin-backfill.spec.ts,
admin-plans-edit.spec.ts. Shared e2e/fixtures/drive-mock-data.ts
fixture seeds files, smart views, AI metadata, and quota state.
.gitignore extended for frontend/playwright-report/, frontend/test-results/, frontend/e2e/debug-screenshot*.png, and
REVIEW_*.md. The previously-tracked playwright-report/index.html
was removed from the index.
Sprint tasks now carry a scheduled timeline and uploaded files, and the
board surfaces when work has slipped.
start_date, end_date, and estimated_hours columns to sprint_tasks, plus a new task_attachments table with cascade delete.
Migration migrate_sprint_tasks_v3.sql is idempotent and indexes
end_date and task_id.
POST/GET/DELETE /sprints/{sprint_id}/tasks/{task_id}/attachmentsendpoints. Multipart uploads stream through the existing S3-compatible
storage service (RustFS).
hours field, and a multi-file uploader. Files are uploaded after the
task is created so cascade delete cleans up cancelled flows.
with download links and delete actions.
Overdue badge when end_date has passed and the task is not done, and an Over estimate badge when actual cycle
time exceeds estimated_hours. Both are pure-frontend computations.
The EditTaskModal grows a History tab showing the full reassignment
chain so reviewers can see who originally assigned a task and every
hand-off in between.
assign_task, unassign_task, and the assignee branch of update_task now write both old and new assignee IDs into the
per-task TaskActivity stream and the workspace-wide
EntityActivity feed.
resolves participant names from workspace members, and renders them
oldest-first so the chain reads in the order it actually happened.
Drag-and-drop listeners moved from the small GripVertical handle onto
the TaskCardPremium root, so the entire card body initiates a drag.
The grip icon remains as a visual affordance. Interactive children
(menu, checkbox, quick-status, archive, quick-edit) stop pointer-down
propagation so clicks on them no longer initiate a drag.
The TipTap Link extension now uses openOnClick: true with
target="_blank" and rel="noopener noreferrer nofollow", so URLs
typed into a task description open in a new tab on click instead of
being inert.
task creation with start/end dates and estimated hours; the Overdue
badge; the Over estimate badge; the assignment history chain; the
whole-card drag affordance; and clickable links in saved
descriptions. A shared task-test-helpers.ts fixture sets up the
board mocks for all of them.
Task modals now link to real synced GitHub pull requests instead of the
old placeholder pr_references field.
task GitHub links, manually link a PR, and unlink an existing PR.
state, and outbound GitHub links.
loading/error feedback through React Query mutations.
link, displaying existing PR links, linking a synced PR, and unlinking
an existing PR.
Tasks can now connect to GitHub issues from the project board.
task_github_links with repository,issue number, title, state, and URL.
backlog tasks.
which repo will be used for bare #123 references.
owner/repo#123 references and GitHub issue URLs. Bare #123 links only when the
project has a single imported GitHub issue repository.
supports manual issue linking from imported GitHub issues.
links using owner/repo, #123, owner/repo#123, or full GitHub
issue URLs.
linking, cross-repo issue override, and issue unlinking.
Closing a task modal opened from /sprints/{projectId}/board?task=...
now removes only the task query parameter and prevents the modal from
immediately reopening while the route updates. The same modal path is
used from the board and deep-link entry points.
Refined the task modal into a wider, more deliberate editing surface:
status changes are saved explicitly, unsaved edits prompt before closing,
dialog accessibility metadata was added, and the GitHub PR section now
lives in the main task content area.
Added direct Microsoft 365 / Entra ID sign-in alongside the existing
Google flow. Tenant defaults to common so both personal (@outlook.com,
@hotmail.com) and work/school accounts can sign in.
GET /api/v1/auth/microsoft/login (basic profile + email), /auth/microsoft/connect-crm (adds Mail + Calendar via Graph), and
/auth/microsoft/callback. Two-scope split mirrors Google.
MicrosoftConnection SQLAlchemy model and migration (migrate_2026_04_14_microsoft_connections.sql), parallel to
GoogleConnection.
DeveloperService.get_or_create_by_microsoft with scope-merge rule:a subsequent basic login never clobbers tokens that already hold
Mail.Read / Calendars.ReadWrite.
/me user info uses mail with userPrincipalName fallback (personal accounts return mail: null).
user signs in, so Azure AD changes propagate.
two CTA blocks on the landing page.
state validation, happy-path callback with mocked Graph responses,
and the personal-account userPrincipalName fallback.
New aexy.services.oauth_token_service centralises refresh-token
behaviour for every OAuth-holding row type (developer connections,
workspace Google integrations, booking calendar connections). Three
ad-hoc copies of the refresh flow (gmail_sync_service,
calendar_sync_service, booking/calendar_sync_service, and
api/chat.py) have been retired — they each had the same two bugs:
rotated refresh tokens were silently dropped, and every non-200
response was treated as "please reconnect" without distinguishing
invalid_grant from a transient 5xx.
ensure_valid_google_token(db, GoogleConnection), ensure_valid_microsoft_token(db, MicrosoftConnection),
ensure_valid_google_integration_token(db, GoogleIntegration), and
ensure_valid_calendar_connection_token(db, CalendarConnection) all
share two primitives (_refresh_google, _refresh_microsoft).
- Nullable refresh_token columns are cleared (raises
RefreshTokenRevokedError).
- GoogleIntegration.refresh_token is NOT NULL, so it's marked
is_active=False + last_error="refresh_token_revoked".
- Booking CalendarConnection additionally flips sync_enabled=False.
and the narrow Calendars.ReadWrite offline_access pair for booking
calendars.
invalid_grantclearing, transient 5xx preserving state, scope propagation, and
the CalendarConnection dispatch-by-provider behaviour.
The persona/preset selector that filters sidebar sections and chooses
dashboard widgets was previously reachable only via the Dashboard
"Customize" modal. It now also lives at /settings/appearance, wired
to the same useDashboardPreferences hook so Dashboard and Settings
stay in sync.
The /sprints empty-state and top action bar now open an inline
project creation modal instead of redirecting to
/settings/projects. On create, the user lands directly on
/sprints/{newProjectId}/board. The shared
CreateProjectModal component is used by both pages.
Next 16 made params in [projectId]/board/page.tsx (and siblings) an
async Promise. Fixed across 12 dynamic routes under /sprints and
/crm/agents: client components use React.use(params), server
components await params.
"Create workspace" link in the sidebar (WorkspaceSwitcher) routed to
/onboarding/workspace, which the OnboardingGuard redirected back to
/dashboard for already-onboarded users — making workspace creation
impossible. The guard now lets /onboarding/workspace through, stale
localStorage state is cleared on visit, and the newly created
workspace is auto-selected via switchWorkspace() so the sidebar
updates immediately.
Added suppressHydrationWarning on <html> in the root layout — the
Redeviation DevTools extension injects data-redeviation-bs-uid onto
the tag before React hydrates.
/settings/projects; it creates the project in-place and jumps to
the new board.
docker-compose.yml and docker-compose.dev.yml no longer set
LLM_PROVIDER, LLM_MODEL, or any *_API_KEY — pydantic reads them
from backend/.env by itself. Previously compose set empty strings
that silently shadowed .env, so switching providers required editing
compose instead of .env. Production compose keeps the injected-via-
shell pattern it was designed for.
npm audit fix cleared the 8 non-breaking advisories (critical axios,
high next/rollup/picomatch, moderate brace-expansion/follow-redirects/
markdown-it/next-intl open-redirect). Upgraded vitest 1.2.1 → 4.1.4
to clear the remaining vite path-traversal + esbuild dev-server
issues; tightened vitest.config.ts include/exclude so vitest 4's
stricter scanner doesn't pull in Playwright e2e specs from
.next/standalone/. Pinned node-fetch ^2.7.0 via overrides
rather than downgrading face-api.js (which npm audit fix --force
wanted to do to no actual security benefit).
Added direct DeepSeek API support alongside Claude, Gemini, Ollama, and OpenRouter. DeepSeek uses an OpenAI-compatible endpoint (https://api.deepseek.com/chat/completions) with models deepseek-chat (non-thinking DeepSeek-V3.2) and deepseek-reasoner (thinking DeepSeek-V3.2).
DeepSeekProvider with model fallback, 429 retry-after handling, usage extractionLLMGateway factory + get_llm_gateway() bootstrapDEEPSEEK_API_KEY and DEEPSEEK_FALLBACK_MODELS env vars (defaults to deepseek-reasoner)DEEPSEEK_REQUESTS_PER_MINUTE, DEEPSEEK_REQUESTS_PER_DAY, DEEPSEEK_TOKENS_PER_MINUTEdeepseek in llm_provider_accesstests/unit/test_deepseek_provider.py (12 tests, mocked HTTP)scripts/check_llm_provider.py — provider-agnostic; runs health_check → call_llm → analyze(CODE) → extract_task_signals and reports pass/fail. Use any time a provider or model is swapped.The sidebar "Create workspace" link routes to /onboarding/workspace, but the OnboardingGuard was redirecting already-onboarded users back to /dashboard — making workspace creation impossible post-onboarding.
OnboardingGuard now allows /onboarding/workspace (and /onboarding/complete) through for existing users/dashboard (instead of /onboarding/connect) and the new workspace is auto-selected via useWorkspace.switchWorkspace() so the sidebar updates immediately<html> caused by the Redeviation browser extension injecting data-redeviation-bs-uid — added suppressHydrationWarning to the root layoutdocker-compose.yml and docker-compose.dev.yml no longer hardcode LLM_PROVIDER, LLM_MODEL, or any *_API_KEY. LLM config is read from backend/.env by pydantic settings — single source of truth. Previously empty-string values in compose silently shadowed .env, breaking provider selection. Prod compose (docker-compose.prod.yml) continues to inject secrets from the host shell env as designed.Comprehensive UX/UI audit of the Performance Reviews feature with screenshot-driven TDD fixes.
confirm()), ARIA tab attributes (role=tablist/tab/tabpanel), breadcrumb navigation consistency, mobile card view for cycles DataTable, user-facing error toasts on API failureshtmlFor/id), live goal card preview on create form, aria-label on icon-only buttons, cycle timeline preview with phase markers, aria-live regions for screen readers, unified loading spinners to primary-500review-screenshots/REVIEW_AI_UX_AUDIT.md with before/after screenshotsnext from 14.1.0 to 16.2.1, react/react-dom to 19.xCustomFieldTypeManager.tsx (stricter parser)@tiptap/suggestion dependencyuseAppAccess.tsFull i18n infrastructure with English + Hindi support across all modules.
npm run i18n:merge (auto-runs on prebuild)useTranslations() — remaining pages can adopt incrementallydocker-compose.dev.yml added with non-conflicting ports for parallel developmentserver_default syntax fix in dashboard and CRM modelsOpenRouter is now available as a first-class LLM provider, giving access to 100+ models (Claude, GPT-4o, Llama, Gemini, DeepSeek, etc.) through a single API key.
LLMProvider implementation using the OpenAI-compatible chat completions API (POST /chat/completions) with Bearer auth, rate limit handling (429 with retry-after), and health checks via /modelsOPENROUTER_FALLBACK_MODELS (comma-separated) to customize the fallback orderOPENROUTER_API_KEY, OPENROUTER_MODEL (default: anthropic/claude-sonnet-4), OPENROUTER_FALLBACK_MODELS (default: google/gemini-2.0-flash,openai/gpt-4o,deepseek/deepseek-chat-v3,meta-llama/llama-3.1-70b-instruct) env varsOPENROUTER_REQUESTS_PER_MINUTE, OPENROUTER_REQUESTS_PER_DAY, OPENROUTER_TOKENS_PER_MINUTE)OPENROUTER_INPUT_PRICE_PER_MILLION, OPENROUTER_OUTPUT_PRICE_PER_MILLION)OPENROUTER_API_KEY passed through in both docker-compose.yml and docker-compose.prod.ymlAuto-CRM contact creation and onboarding drip email sequences triggered on user signup.
PLATFORM_ORG_ID is configuredplatform_on_signup activity dispatched from the signup flow for async processingPLATFORM_ORG_ID env var — set to a workspace UUID to enablePostmark is now available as an email provider across all three sending paths — notification emails, campaign/workflow emails, and mailagent domain-aware sending.
_send_via_postmark() method, is_postmark_configured property, and Postmark routing in _send_email() — set EMAIL_PROVIDER=postmark to use for all notification emailsEmailProvider implementation with send(), verify_credentials(), and native send_batch() (up to 500 per call via /email/batch)create, delete, list) and domains (verify, get) using the Account API tokenPOSTMARK_TRANSACTIONAL_STREAM, default outbound) and broadcast (POSTMARK_BROADCAST_STREAM, default broadcast) stream support — notification emails use transactional, campaigns use broadcastPOSTMARK_SERVER_TOKEN, POSTMARK_ACCOUNT_TOKEN, POSTMARK_SENDER_EMAIL, POSTMARK_SENDER_NAME, POSTMARK_TRANSACTIONAL_STREAM, POSTMARK_BROADCAST_STREAM env vars in backend; POSTMARK_SERVER_TOKEN in mailagentZulip-inspired real-time team chat with channels, topics, and threaded messages, accessible from a dedicated /chat page and a floating widget on every page.
ChatWebSocketProvider (no duplicate connections)Integrated AI chat assistant with multi-provider LLM support, server-side tool execution, and streaming responses.
chat_mention and ai_conversation_shared event typesApiToken model with aexy_ prefixed tokens, CRUD endpoints, create/validate/revoke service methods_check_workspace membership guard to every chat API endpoint (channels, topics, messages, presence, file upload)_check_channel_access helper enforcing membership checks on topic listing, creation, message listing, and message sending for private channelsmax_length constraints on all chat message and channel inputsonline, away, offline allowed)LIMIT 200 to prevent unbounded topic queriesdb.commit() in ChatService with db.flush(); explicit await db.commit() in all mutating API endpointsmessage_count updates; correlated subqueries for list_conversations in Ask AIIntegrityError handling for concurrent topic/message creationReact.memo on MessageItem, memoized WebSocket context value, deduplicated markTopicRead callsget_current_developer_id now uses the injected DB session instead of creating a separate one via get_async_session()NEXT_PUBLIC_API_URL env var instead of hardcoded localhostpermission fields in share schemas use Literal["read", "write"] instead of strAskAIChatPanel now uses useChatWebSocketContext() instead of creating a second useChatWebSocket() connectionaction_url is a relative path (starts with /, not //)http:/https: only) before rendering user-provided URLs as <img> or <a> elementsuseStreamMessage accepts override conversationId parameter, eliminating unreliable setTimeout in widget first-message flowuseStreamMessage uses useAskStore.getState() for mutations during streaming, preventing cascading re-rendersAskShareDialog wraps participantIds Set in useMemo for stable dependency trackingMessageThread queue-flush effect uses ref for sendMessage to prevent infinite re-render loops/chat pageswindow.confirm() before proceedingcomponents/ui/copy-buttonconfirm()migrate_ask_collaborative.sql — ask_conversation_participants and ask_share_links tables for collaborative AI conversationsFull multi-channel notification infrastructure with 4 delivery channels (in-app, email, Slack, web push) and workspace-wide event coverage.
slack_sent/slack_sent_at tracking columnssend_notification_web_push Temporal activitymention:user:{uuid} links from ticket comments, CRM notes, and sprint task comments; deliver in-app notifications respecting preferences (self-mentions skipped)Governance layer that evaluates agent tool calls before execution, with audit trail and billing integration.
tool_block, tool_require_approval, field_restriction, rate_limit, token_budget — workspace-scoped, priority-ordered, per-agent or globalBaseAgent._process_tools — blocked calls return [BLOCKED] reason as ToolMessage so the LLM can adjustagent_policy_decisions table with confidence contextagent_config_audits table tracks agent create/update/delete/toggle with old/new field diffsUsageService.record_usage() with analysis_type="agent_execution"/workspaces/{ws}/crm/agent-policies with admin-only mutations and workspace permission checksCross-module activity logging surfaced in a dedicated /activity page with filtering and infinite scroll.
log_activity() helper using begin_nested() savepoints so logging failures never roll back parent transactionsuseActivityFeed hook with useInfiniteQuery and IntersectionObserver-based paginationActivityFeedService.get_entity_url() resolves entity-specific deep linksis_archived) replaces hard delete for sprint tasksuser_id/user_name query params with JWT token verificationasyncio.Lock for WebSocket connect/disconnect to prevent race conditionsis_(False) instead of == Falseconfirm()/alert() with proper modal dialogs and toast notificationsPlanningPokerVote, PlanningPokerState, etc.)organization_id (Assessment model doesn't have workspace_id)log_activity block that created 2 entries per commentlog_activity calls where service layer already logs the same operationscurrent_user dependency and actor_id to submit_self_review, submit_manager_review, finalize_reviewNotificationService instead of bypassing itsprint_goals table for sprint goal trackingmigrate_notification_slack_sent.sql — slack_sent tracking columns on notificationsmigrate_notification_events.sql — 22 new event types and category preferencesmigrate_notification_providers.sql — web push subscription storage and VAPID configmigrate_agent_policies.sql — agent_policies, agent_policy_decisions, agent_config_audits tables with updated_at triggermigrate_app_access_requests.sql — app access request/approval workflowmigrate_sprint_goals.sql — sprint goals tableFull AI-powered go-to-market automation system for outreach, lead scoring, visitor tracking, competitor intelligence, and account-based marketing.
Phase 2A — Scoring Feedback Loop & Foundation
score_lead activities, linking engagement to CRM records/providers/available, displays configured providers with "Coming Soon" for unimplemented onesreply_received when routing replies to sales; Temporal workflows finalize with exit_reason="replied"Phase 2B — Outreach Excellence & Warmup
variant_index tracking on step executionsthread_id forwarding for conversation continuity across outreach stepsincrement_send_count naming, can_send() missing workspace_id, warming metrics field mismatchPhase 2C — Intelligence Layer & LLM Integration
Phase 2D — Scale & Ops
useSidebarPersona hook with server-persisted preferences+ buttonusePageVisitTracker hook records page visits for smart favoritesCATEGORY_LABELS and PERSONA_LABELS to appDefinitions.tssanitize_for_llm() strips injection patterns from external content before LLM promptsaexy-track.js with data-consent attribute, GPC signal support, and blocked identify() without consentsetattr with explicit allowlists in update_provider, update_template, update_competitorstr.format(**event_data) with string.Template.safe_substitute() in alert templatingrequired_role="admin" to 44 write/delete GTM endpointsapi/gtm.py (2844 lines) into 20 focused sub-modules under api/gtm/ packagetemporal/activities/gtm.py (1616 lines) into 9 domain modules under activities/gtm/purge_behavioral_events activity with 365-day configurable retentionmigrate_sidebar_preferences.sql — sidebar_pinned_items and sidebar_page_visits preferences/tables route for creating and managing standalone data tables, independent of CRM objects/api/v1/workspaces/{id}/tables) with listing, detail, field CRUD, record CRUD, and bulk operationsuseTables, useTableFields, useTableRecords, useTableAccess hooks for frontend data fetching/api/v1/public/tables endpoints for unauthenticated shared accesstable_audit_log table and TableAuditService for tracking all table mutationscrm_lists with entity_type for shared views across entity types%, _) in filter inputs to prevent filter injectionX-Share-Password headerWorkspaceMember queries into 1 in resolve_access__import__ hack, return type annotations, and ip_address type mismatches in backend_strip_hidden_columns methoduseMemo unstable dependency array in frontend componentsupdate_table and create_share_link endpointsDataTableServicemigrate_data_tables.sql — Core tables for data table supportmigrate_data_tables_phase3_7.sql — Audit log and share link tablesaria-current="page" supportg then X navigation pattern (like GitHub/Linear) for 19 modules? key opens categorized shortcut referenceticket.reopened, ticket.priority_changed, ticket.escalated, response.sent, response.received, sla.breachedcandidate.rejected, candidate.hired, assessment.score_above, assessment.score_belowsprint.velocity_calculated, sprint.burndown_off_trackmonitor.ssl_expiring, monitor.repeated_failurescampaign.sentstatusColors.ts, migrated 34 filesassessment.workspace_id did not exist on the Assessment model — changed to assessment.organization_id so score_above/score_below triggers actually fireTicketStatus.OPEN did not exist in the enum — changed to TicketStatus.ACKNOWLEDGEDid: "nav-templates" causing React key collision — renamed second to nav-automation-templatesonSuccess always showed success even when WebhookTestResult.success was false — now checks the resultuseMemoloadConfig, handleToggle, handleDelete used try/finally with no catch — added error handling with toast notificationsuseEffect missing loadConfig in dependency array — wrapped in useCallbackgetConfiguration caught everything and returned null — now only catches 404createExport(data as any) — replaced with proper type assertioncurrentWorkspaceId! non-null assertion could produce /workspaces/null/ API calls — added guardcandidate.rejected/candidate.hired dispatch calls in try/except for consistencyconfirm() with styled confirmation modalworkspaceId prop from CommandPalette, unused useAuth import from SSO pagerole="dialog", aria-modal, role="combobox" on search input, role="listbox" on resultsaria-sort on sortable headers, tabIndex and keyboard handlers (Enter/Space) for sortable headers and clickable rowsrole="dialog", aria-modal, aria-labelledby, aria-label="Close" on close buttonrole="dialog", aria-modal, aria-labelrole="alert" on error containeraria-label="Clear search" on clear buttonaria-current="page" on last breadcrumb itemaria-label="Dismiss banner" on dismiss buttonsComprehensive improvements to the automation workflow builder across all 10 modules.
TRIGGER_REGISTRY and ACTION_REGISTRY now return {id, description} objects instead of plain strings, with backward-compatible helper functions (get_trigger_ids, get_action_ids)standup.streak, time_entry.anomaly, blocker.pattern_detected, training.bulk_overdue, certification.prerequisite_unmet, etc.) and actions across all modulesid and description fieldsstarts_with, ends_with, not_contains, and between operators in CRMAutomationService._check_condition() which previously fell through to return Trueprint() calls in AutomationService.process_module_trigger() with proper logger.info/debug/error callsReplaced all "Coming Soon" placeholder widgets with full implementations using live data from existing hooks.
MyGoalsWidget, GrowthTrajectoryWidget, PeerBenchmarkWidget, LearningPathWidget, SkillGapsWidgetStandupStatusWidget, TimeTrackingWidget, UpcomingDeadlinesWidgetSLAOverviewWidget, RecentTicketsWidget, TicketsByPriorityWidget, FormSubmissionsWidget, RecentFormsWidgetRecentDocsWidget, DocActivityWidgetPerformanceReviewsWidget, PendingReviewsWidget, ReviewCycleWidgetHiringPipelineWidget, CandidateStatsWidget, OpenPositionsWidget, InterviewScheduleWidgetDealStatsWidget, RecentDealsWidget, CRMQuickViewWidgetTeamOverviewWidget, TeamActivityWidget, OrgMetricsWidget, SystemHealthWidgetTeamStatsSummaryWidget to use correct nested aggregate property pathsTicketChartWidget to use theme-aware colors instead of hardcoded dark-mode hex valuesTicketPipelineWidget to remove unnecessary as any castPeerBenchmarkWidget ordinal suffixes (1st, 2nd, 3rd instead of always "th")TicketsByPriorityWidget (unreachable priority breakdown branch)UpcomingDeadlinesWidget to use sprint end date and incomplete tasks instead of nonexistent due_date fieldFull leave management system with request/approval workflows, balance tracking, and holiday calendar management.
LeaveTypeService, LeavePolicyService, LeaveRequestService, LeaveBalanceService, HolidayServiceLeaveRequestForm, LeaveRequestCard, LeaveApprovalCard, LeaveBalanceCard, LeavePolicySettings, LeaveTypeSettings, HolidaySettings, TeamLeaveTableUnified calendar view showing leave, holidays, and team availability.
TeamCalendar, CalendarFilters, EventDetailModal, WhoIsOutPanelTemporal-powered automation for compliance monitoring and developer activity tracking.
standup.streak, time_entry.anomaly, blocker.pattern_detected, training.bulk_overdue, certification.prerequisite_unmetBacklogOverviewWidget, BlockersOverviewWidget, SprintBurndownWidget, TasksCompletedChartWidget, TeamStatsSummaryWidget, TicketChartWidget, TicketPipelineWidget, VelocityTrendWidget, WorkloadDistributionWidgetLeaveBalanceWidget, PendingLeaveApprovalsWidget, TeamAvailabilityWidget, TeamCalendarWidgetCampaign open/click tracking endpoints for email marketing analytics.
Dynamic app/module registration via AppDefinitions model and frontend config.
Temporal activity for periodic AI-powered insights generation with scheduled execution.
ghu_) using stored refresh tokens — tokens no longer silently expire after 8 hoursGitHubNotFoundError exception with non-retryable Temporal retry policyauth_status="error") instead of flooding Temporal with failing workflows@github_username and repo full name instead of opaque UUIDsSettingsShell, SettingsSidebar, and SettingsSearch componentsfix_subscription_plans.py script for correcting plan dataNodePalette expanded with compliance and tracking trigger/action nodesuseNotifications and useReminders hooksdocker-compose.prod.yml with additional service configurationtest:e2e / test:e2e:ui npm scriptsmigrate_leave_management.sql, migrate_github_auth_status.sql, migrate_developer_email_nullable.sql, migrate_repo_sync_settings.sqlReplaced the hardcoded dashboard layout with a fully dynamic, preference-driven widget rendering system. Widgets now render from widget_order and visible_widgets stored in user preferences, with drag-and-drop reordering support.
Widget Extraction (9 new components):
WelcomeWidget — greeting, GitHub connection status, quick action linksQuickStatsWidget — language count, framework count, avg PR size, work styleLanguageProficiencyWidget — language bars with proficiency scores, commit counts, trendsWorkPatternsWidget — complexity preference, peak hours, review turnaroundDomainExpertiseWidget — domain tags with confidence scoresFrameworksToolsWidget — framework/tool tags with proficiency scoresAIInsightsWidget — composite widget wrapping InsightsCard, SoftSkillsCard, GrowthTrajectory, PeerBenchmarkSoftSkillsWidget — Reviews & Goals section with My Goals and Performance ReviewsComingSoonWidget — placeholder for unimplemented widget IDsWidget Registry (`widgetRegistry.tsx`):
getWidgetComponent() helper with ComingSoonWidget fallbackisWidgetImplemented() check for registry membershipDashboard Page Rewrite (`page.tsx`):
orderedVisibleWidgets computed via widget_order intersected with visible_widgetsgetWidgetProps() switch maps widget IDs to their specific data propsgetWidgetGridClass() maps widget sizes to CSS grid column spansrenderWidget() skips composite children and renders from registry or ComingSoonWidgetSortableWidgetGrid Updates:
space-y-6 vertical stack to CSS grid: grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6renderableWidgets filter to skip null renders from composite childrentop-2 right-2Customize Modal — Reorder Tab:
DashboardCustomizeModalWidgetReorderList component — dnd-kit vertical list showing widget icon, name, size badge, and drag handleEnriched Non-Developer Presets:
aiAgents, upcomingDeadlines, recentDocsaiInsights, aiAgentsquickStats, aiAgents, upcomingDeadlines, myGoalsquickStats, aiAgents, teamOverview, myGoalsquickStats, aiAgents, teamOverview, upcomingDeadlinesquickStats, aiAgents, myGoals, upcomingDeadlines, recentDocsAdded end-to-end testing infrastructure for the dashboard.
playwright.config.ts — Chromium project, baseURL localhost:3000, auto-start dev servere2e/fixtures/mock-data.ts — mock user, preferences, insights, soft skills fixturese2e/dashboard.spec.ts — 18 tests across 6 describe blocks:- Widget Rendering (7 tests): welcome, quickStats, languageProficiency, workPatterns, domainExpertise, frameworksTools, ComingSoon
- Widget Ordering (2 tests): order from preferences, only visible widgets rendered
- Edit Layout Toggle (2 tests): button toggle, drag handles in edit mode
- Customize Modal (4 tests): three tabs, tab switching, reorder tab content, close
- Manager Preset (1 test): cross-cutting widgets present
- Grid Layout (2 tests): CSS grid container, full-span widgets
0.5.5 to 0.5.6@playwright/test dev dependencytest:e2e and test:e2e:ui npm scriptsExtended GitHub sync to capture all contributors' commits, PRs, and reviews — not just the connecting user. External contributors are auto-created as "ghost" Developer records.
Backend:
author_github_login and author_email on Commit for preserving original author identity_resolve_developer_for_commit() and _resolve_developer_for_pr() in SyncService to match or auto-create Developer records by GitHub ID or emailauthor=github_username filter from _sync_commits_with_session() — now fetches all commitslogin != github_username filter from _sync_pull_requests_with_session() and _sync_reviews_with_session()migrate_commit_author_fields.sql — adds author_github_login, author_email columns with indexesGhost Developer Support Across Insights:
_get_all_contributor_ids() in developer_insights.py — discovers external contributors by querying commits/PRs/reviews in workspace reposAdded hover tooltips with explanations across all insights pages.
Compare Page (`/insights/compare`):
<title> elementRadarDataPoint interface with optional desc fieldCustomAngleTick component in MetricsRadar.tsx for tooltip-enabled axis labelsRADAR_METRICS config includes desc for each metricExecutive Dashboard (`/insights/executive`):
Multiple insights pages displayed truncated UUIDs (e.g., 8f983e00-386...) instead of developer names.
devNameMap lookupdeveloper_name from APIdeveloper_name from APIdeveloper_name field to backend responses: compute_executive_summary(), estimate_sprint_capacity()ExecutiveSummaryResponse, SprintCapacityDeveloperFixed /insights/developers/[id] crashing on gaming flags section due to API schema mismatch.
{type, severity, description, evidence(object)} but frontend expected {pattern, severity: "low"|"medium"|"high", evidence: string}Record<string, unknown> type and proper field fallbacks (flag.type || flag.pattern, severity includes "warning")flag.pattern?.replace() to prevent TypeErrorFixed analytics_dashboard.py using stale CodeReview.pull_request_id column (renamed to pull_request_github_id).
CodeReview.pull_request_github_id == PullRequest.github_idconftest.py test fixture using the same stale field name_resolve_developer_for_pr() now auto-creates ghost Developer records (by GitHub login) when no existing developer matches, consistent with _resolve_developer_for_commit() behavior.
0.5.4 to 0.5.5from sqlalchemy import or_ to top-level import in developer_insights.pyComprehensive developer productivity analytics platform with AI-powered insights, alerting, and forecasting.
Backend:
DeveloperMetricsSnapshot, TeamMetricsSnapshot, InsightSettings, DeveloperWorkingSchedule, InsightAlertRule, InsightAlertHistory, InsightReportSchedule, SavedInsightDashboardapi/developer_insights.py - 25+ endpoints for individual developer metrics, team insights, leaderboard, executive summary, sprint capacity, bus factor, rotation impact, project insights, alert rules, and AI narrativesservices/developer_insights_service.py - Metric computation across 6 dimensions (velocity, efficiency, quality, sustainability, collaboration, sprint productivity), forecasting, gaming detection, health scoring, percentile rankings, role benchmarking, and executive summariesservices/insights_ai_service.py - LLM-powered narrative generation for team/developer performance, anomaly detection, root cause analysis, 1:1 prep notes, sprint retro insights, trajectory forecasting, team composition recommendations, and hiring timeline estimationcache/insights_cache.py - Redis caching with 5-min TTL, deterministic key generation, and pattern-based invalidationschemas/developer_insights.py - Complete Pydantic schemas for all metrics, responses, settings, and alertsmigrate_developer_insights.sql, migrate_developer_insights_v2.sql, migrate_developer_insights_v3.sqltests/integration/test_developer_insights_api.pytests/unit/test_developer_insights_service.pyMetrics Computed:
Advanced Features:
Alert System:
INSIGHT_ALERT_WARNING, INSIGHT_ALERT_CRITICALFrontend:
- /insights - Team overview with stat cards and workload distribution chart
- /insights/leaderboard - Ranked developer metrics
- /insights/developers/[developerId] - Individual developer drill-down
- /insights/compare - Side-by-side developer comparison
- /insights/allocations - Resource allocation view
- /insights/alerts - Alert management
- /insights/executive - Executive dashboard
- /insights/sprint-capacity - Sprint planning with capacity estimation
- /insights/ai - AI-powered insights (narratives, anomalies, recommendations)
- /insights/me - Personal insights
- /settings/insights - Insights configuration (working hours, metric weights, snapshot frequency)
useInsights hook - React Query integration with 10+ hooks for metrics, trends, leaderboard, alerts, and AI narrativesActivityHeatmap, MetricsRadarINSIGHTS with can_view_insights and can_manage_insightsinsights in app catalog with team_overview, leaderboard, and developer_drilldown modulesfull_access bundleteamInsights, developerInsights, insightsLeaderboard, workloadDistributioncelery_app.py) - all background processing now uses Temporal; celery_app set to None with deprecation warningget_celery_stats to get_temporal_stats)use_celery to use_backgrounddeveloper to user in auth hook (useAuth) - updated AppAccessGuard and SidebarGoogleIcon export from named to local function in landing page (moved to dedicated components/icons/GoogleIcon.tsx)formatRelativeTime utility function to lib/utils.ts0.5.3 to 0.5.4New top-level Compliance module for managing regulatory compliance, documents, reminders, training, and certifications.
New Routes:
/compliance - Compliance dashboard with overview stats, upcoming reminders, and category breakdown/compliance/reminders - Recurring compliance reminder management with list and calendar views/compliance/reminders/new - Multi-step reminder creation wizard (basic info, schedule, assignment, review)/compliance/reminders/[reminderId] - Reminder detail and instance history/compliance/reminders/calendar - Calendar view of upcoming reminder instances/compliance/reminders/compliance - Questionnaire import and analysis/compliance/documents - Document Center with folder tree, search, filtering, and upload/compliance/documents/[documentId] - Document detail with metadata, tags, and entity linking/compliance/training - Mandatory training management with assignment tracking/compliance/certifications - Certification tracking with developer enrollment and progress/compliance/calendar - Unified compliance calendarFull-featured recurring reminder engine for compliance tasks with escalation, assignment, and scheduling.
Backend:
Reminder, ReminderInstance, ReminderEscalation, ControlOwner, DomainTeamMapping, AssignmentRule, ReminderSuggestionapi/reminders.py - 30+ endpoints for reminders, instances, control owners, assignment rules, domain mappings, suggestions, dashboard stats, calendar, and bulk operationsservices/reminder_service.py - Reminder CRUD, instance generation, acknowledgment, completion, skip, reassignment, escalation, and dashboard statisticsschemas/reminder.py - Complete Pydantic schemas for all reminder operationsmigrate_reminders.sql - 7 tables with proper indexes, triggers, and constraintsTemporal Activities (temporal/activities/reminders.py):
generate_reminder_instances - Daily task to generate upcoming instances from recurrence rulescheck_overdue_reminders - Hourly check for overdue instances with automatic escalationsend_reminder_notifications - Sends due/upcoming reminder notificationssend_weekly_slack_summary - Weekly compliance status summary (logging only for now)check_evidence_freshness - Daily check for stale evidence on completed instancesFeatures:
Frontend:
useReminders hook - React Query integration with 10+ hooks for all reminder operationsReminderCard, ReminderInstanceCard, ReminderStatusBadge, ReminderPriorityBadge, ReminderCategoryBadge, InstanceStatusBadge, RecurrenceDisplayReminderCreationWizard - 4-step wizard with validation and team/owner assignmentImport compliance questionnaires from Excel/CSV with AI-powered column detection and automatic reminder generation.
Backend:
QuestionnaireResponse, QuestionnaireQuestion with status trackingapi/questionnaires.py - Upload, analyze, accept/reject suggestions, list responsesservices/questionnaire_service.py - 3-tier column detection (exact alias match, fuzzy substring, LLM fallback), cross-questionnaire deduplication, and automatic reminder suggestion generationmigrate_questionnaire.sql - Questionnaire tables with proper indexingFrontend:
useQuestionnaires hook - Upload, analysis, and suggestion managementUpload, organize, and manage compliance documents with folder hierarchy, tagging, and entity linking.
Backend:
ComplianceFolder, ComplianceDocument, ComplianceDocumentTag, ComplianceDocumentLinkapi/compliance_documents.py - Document CRUD, folder management, tag operations, entity linking, search with filteringservices/compliance_document_service.py - Document upload, folder tree management, tag operations, entity linkingmigrate_compliance_documents.sql - Document and folder tables with S3 key storageFrontend:
useComplianceDocuments hook - React Query integration for documents, folders, tags, and entity linksDocumentCard, FolderTree, CreateFolderModal, UploadModal, DocumentFilters, DocumentLinkPanelReplaced R2-specific storage with a generic S3-compatible StorageService supporting RustFS (dev) and any S3-compatible provider (production).
Backend:
services/storage_service.py - Generic S3 client with presigned URL generation, direct upload, multipart upload, and downloadr2_upload_service.py re-exports StorageService as R2UploadServiceS3_ENDPOINT_URL, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET_NAME, S3_REGION, S3_PUBLIC_ENDPOINT_URL, S3_RECORDINGS_PREFIX, S3_COMPLIANCE_PREFIX, COMPLIANCE_MAX_FILE_SIZE_MBDocker:
aexy-storage bucket on startup via rustfs-init helper containerCOMPLIANCE with can_view_compliance and can_manage_compliancecompliance in app catalog with reminders, document_center, training, and certifications modulespeople and full_access bundles, disabled in engineering and sales_marketingREMINDER_DUE, REMINDER_ACKNOWLEDGED, REMINDER_COMPLETED, REMINDER_ESCALATED, REMINDER_OVERDUE, REMINDER_ASSIGNEDcomplianceOverview, complianceDocumentsR2UploadService into generic StorageService with S3-compatible backend supportf4e79d9)73e7641)croniter>=2.0.0 for cron expression parsinggithub_app_install_url to production GitHub App URL in config.py instead of empty stringGITHUB_APP_INSTALL_URL environment variable to docker-compose.prod.yml backend serviceReplaced Celery 5.3+ task queue with Temporal Python SDK for all background processing, workflow orchestration, and scheduled tasks.
Infrastructure:
Activities & Workflows:
dispatch() function replacing Celery .delay() for fire-and-forget executionSingleActivityWorkflow wrapper for dispatching individual activitiesTask Queues:
analysis - Developer profiling, code analysis, LLM taskssync - GitHub sync, Google sync, external dataworkflows - CRM automations, workflow executionemail - Campaigns, onboarding, transactional emailintegrations - Webhooks, Slack, external servicesoperations - Stats aggregation, cleanup, maintenanceRetry Policies:
STANDARD_RETRY - General tasks with exponential backoffLLM_RETRY - AI/LLM calls with longer timeoutsWEBHOOK_RETRY - External webhook deliveryEmailCampaignService - 9 async methods for email campaign management, extracted from Celery tasksOnboardingService.check_due_steps() - Checks and dispatches due onboarding step processingOnboardingService API signaturesUpdateWarmingMetricsInput dataclass instead of raw dictMigrated automations from CRM-specific to a platform-wide automation framework accessible from /automations.
New Routes:
/automations - List all automations with module filtering (CRM, Tickets, Hiring, Email, etc.)/automations/new - Create new automation with module selector/automations/[automationId] - Edit automation with workflow builderModule Support:
record.created, record.updated, field.changed, stage.changedticket.created, ticket.status_changed, sla.breached, ticket.assignedcandidate.created, candidate.stage_changed, interview.scheduledcampaign.sent, email.opened, email.bouncedmonitor.down, incident.createdtask.status_changed, sprint.completedform.submittedbooking.confirmed, booking.cancelledBackend:
api/automations.py at /workspaces/{id}/automations/*schemas/automation.py with AutomationModule enumservices/automation_service.py for generic automation handlingmigrate_platform_automations.sql adds module column to automationsCRM Routes Redirected:
/crm/automations → /automations?module=crm/crm/automations/new → /automations/new?module=crm/crm/automations/[id] → /automations/[id]Agents can now have dedicated email addresses and manage their own inboxes.
Email Address Allocation:
support@workspace.aexy.emailAgent Inbox: frontend/src/app/(app)/agents/[agentId]/inbox/page.tsx
pending, processing, responded, escalated, archivedBackend:
models/agent_inbox.py - AgentInboxMessage for storing received emailsservices/agent_email_service.py - Email allocation, routing, and processingapi/email_webhooks.py - Inbound email webhook handlersmigrate_agent_email.sql - Agent email fields and inbox tableAgent Model Extensions:
email_address - Unique email address for the agentemail_enabled - Toggle email processingauto_reply_enabled - Enable automatic responsesemail_signature - Custom signature for outgoing emailsNew conversational interface for interacting with AI agents.
New Routes:
/agents/[agentId]/chat - Start new conversation with agent/agents/[agentId]/chat/[conversationId] - Continue existing conversationFeatures:
Backend:
migrate_agent_conversations.sql - Conversation and message tablesapi/agents.py with conversation endpointsuser, assistant, system, tool_call, tool_resultConnect AI agents to workflow automations for intelligent task handling.
New Model: models/automation_agent.py
AutomationAgent - Links agents to automation workflowsAutomationAgentExecution - Tracks agent executions within workflowsAutomationAgentConfig - Stores agent-specific workflow configurationNew API: api/automation_agents.py
POST /automations/{id}/agents - Add agent to automationDELETE /automations/{id}/agents/{agent_id} - Remove agentGET /automations/{id}/agents - List agents in automationPOST /automations/{id}/agents/{agent_id}/execute - Manually trigger agentWorkflow Actions: services/workflow_actions.py
run_agent action type for workflow nodesMigration: migrate_automation_agents.sql
Client for communicating with the mailagent microservice.
New Integration: integrations/mailagent_client.py
Configuration:
MAILAGENT_URL environment variable (default: http://mailagent:8001)Agent Detail Page: /agents/[agentId]
Agent Edit Page: /agents/[agentId]/edit
Agents List Page: /agents
/agents routes/automations routessend_email, create_draft, get_email_history, get_writing_styleSendingDomainResponse.provider_id is now optional (nullable)metadata → decision_metadata)LLMConfig export in mailagent LLM modulerun_migrations.py)roadmap_voting model and related codepublic_projects API (consolidated into projects API)A new standalone microservice for email administration, AI agent processing, and domain management.
Core Service: mailagent/
Email Provider Support: mailagent/src/mailagent/providers/
Domain Management: mailagent/src/mailagent/api/domains.py
Agent System: mailagent/src/mailagent/agents/
support, sales, scheduling, onboarding, recruiting, newsletter, customreply, forward, escalate, schedule, create_task, update_crm, wait, request_approvalLLM Integration: mailagent/src/mailagent/llm/
API Endpoints:
/api/v1/admin/* - Provider CRUD and dashboard/api/v1/domains/* - Domain management and verification/api/v1/onboarding/* - Inbox creation and verification/api/v1/agents/* - Agent CRUD and configuration/api/v1/agents/{id}/process - Process email with agent/api/v1/invocations/* - Execution history and metrics/api/v1/webhooks/* - Inbound email processing/api/v1/send/* - Outbound email sendingEmail Processing Pipeline:
A comprehensive interface for creating and managing custom AI agents with configurable tool access and behavior settings.
New Routes:
/agents - Agent list page with grid view, stats, filtering, and search/agents/new - Multi-step agent creation wizard/agents/[agentId] - Agent detail page with execution history and metrics/agents/[agentId]/edit - Tabbed configuration editorFrontend Components: frontend/src/components/agents/
AgentCreationWizard - 7-step wizard (type, basic info, LLM, tools, behavior, prompts, review)AgentTypeBadge - Type indicator with icon and colorAgentStatusBadge - Active/inactive statusToolSelector - Multi-select tool picker with categoriesLLMProviderSelector - Provider and model selection (Claude, Gemini, Ollama)ConfidenceSlider - 0-1 range slider for thresholdsWorkingHoursConfigPanel - Hours, timezone, and days configurationPromptEditor - System prompt editor with variable hintsDashboard Widget:
AIAgentsWidget - Shows active agents, total runs, success rateSidebar Navigation:
Product Page:
/products/ai-agents - Marketing page for AI Agents featureBackend API Extensions:
GET /agents/check-handle - Verify mention handle availabilityGET /agents/{id}/metrics - Agent performance metrics (runs, success rate, avg duration)Database Migration: backend/scripts/migrate_agent_extended_config.sql
mention_handle, llm_provider, temperature, max_tokens, confidence_threshold, require_approval_below, max_daily_responses, response_delay_minutes, working_hours, custom_instructions, escalation_email, escalation_slack_channelDocumentation:
/docs/ai-agents.md - Comprehensive guide covering agent types, configuration, tools, and API/docs/README.md with AI Agents in guides and products/CLAUDE.md with AI Agents key files and API testing commandscheck_auto_sync_integrations) runs every minute to check which integrations need syncinggmail_last_sync_at and calendar_last_sync_at for accurate schedulingDatabase Migrations:
migrate_auto_sync_interval.sql - Adds auto_sync_interval_minutes columnmigrate_auto_sync_calendar_interval.sql - Adds auto_sync_calendar_interval_minutes columntiptap-markdown integration for seamless markdown parsing/serializationisInitialized state guard in useWorkspace hookdir() check that always returned 0 for total integrations checkedto_emails field to properly extract email addresses from recipient objects--legacy-peer-deps for dependency compatibilitycontainer class for full-width layoutstiptap-markdown@^0.8.10y-prosemirror@^1.3.7/p/my-project-k3f9x2)- Overview, Backlog, Board, Stories, Bugs, Goals, Releases, Timeline, Roadmap, Sprints
Pagination component with ellipsis support and accessibility labelsRoadmapRequest, RoadmapVote, RoadmapComment for voting system/api/v1/public/projects/{public_slug}/... for unauthenticated accessbackend/src/aexy/core/sanitize.py)POST /workspaces/{id}/projects/{id}/toggle-visibility - Toggle project public/privateGET/PUT /workspaces/{id}/projects/{id}/public-tabs - Configure visible tabsGET /public/projects/{slug} - Get public project infoGET /public/projects/{slug}/backlog|board|stories|bugs|goals|releases|roadmap|sprints|timeline - Public data endpointsGET/POST /public/projects/{slug}/roadmap-requests - List/create feature requestsPOST /public/projects/{slug}/roadmap-requests/{id}/vote - Vote on requestsGET/POST /public/projects/{slug}/roadmap-requests/{id}/comments - CommentsProject model includes is_public (boolean) and public_slug (unique string) fieldsalembic/versions/61fd11a7e0ea_add_public_project_visibility.py - Adds visibility columnsscripts/migrate_roadmap_voting.sql - Creates roadmap voting tables with indexes`
47 files changed, ~5,900 insertions(+), ~500 deletions(-)
`
Backend:
api/public_projects.py (new - 903 lines)api/projects.py (+186 lines)models/roadmap_voting.py (new - 205 lines)models/project.py (+37 lines)schemas/project.py (+265 lines)core/sanitize.py (new - 107 lines)Frontend:
app/p/[publicSlug]/page.tsx (new - 265 lines)components/public-project-page/* (new - 12 components)components/ui/pagination.tsx (new - 136 lines)app/(app)/settings/projects/[projectId]/page.tsx (+254 lines)lib/api.ts (+351 lines)A comprehensive intelligence analysis system that extracts insights from GitHub activity to provide developer profiling, burnout detection, expertise tracking, and team collaboration analysis.
Semantic Commit Analysis:
! suffix and BREAKING CHANGE: footerNew Service: backend/src/aexy/services/commit_analyzer.py
POST /api/v1/intelligence/commits/analyzeGET /api/v1/intelligence/commits/distributionPR Review Quality Analysis:
New Service: backend/src/aexy/services/review_quality_analyzer.py
GET /api/v1/intelligence/reviews/qualityPOST /api/v1/intelligence/reviews/analyzeGET /api/v1/intelligence/reviews/response-timeExpertise Confidence Intervals:
New Service: backend/src/aexy/services/expertise_confidence.py
GET /api/v1/intelligence/expertisePOST /api/v1/intelligence/expertise/updateGET /api/v1/intelligence/team/{workspace_id}/expertise/{skill_name}Burnout Risk Indicators:
New Service: backend/src/aexy/services/burnout_detector.py
GET /api/v1/intelligence/burnoutPOST /api/v1/intelligence/burnout/updateGET /api/v1/intelligence/team/{workspace_id}/burnoutCollaboration Network Analysis:
New Service: backend/src/aexy/services/collaboration_network.py
GET /api/v1/intelligence/collaboratorsGET /api/v1/intelligence/team/{workspace_id}/collaborationGET /api/v1/intelligence/team/{workspace_id}/collaboration/graphProject Complexity Classification:
New Service: backend/src/aexy/services/complexity_classifier.py
GET /api/v1/intelligence/complexityPOST /api/v1/intelligence/complexity/analyzePOST /api/v1/intelligence/complexity/updateGET /api/v1/intelligence/team/{workspace_id}/complexityTechnology Evolution Tracking:
New Service: backend/src/aexy/services/technology_tracker.py
GET /api/v1/intelligence/technologyPOST /api/v1/intelligence/technology/updateGET /api/v1/intelligence/team/{workspace_id}/technologyFull Analysis Endpoint:
POST /api/v1/intelligence/analyze-all - Runs all analysis types in one callDatabase Migration:
backend/scripts/migrate_github_intelligence.sqlsemantic_analysis JSONB column to commits tablequality_metrics JSONB column to code_reviews tableexpertise_confidence JSONB column to developers tableburnout_indicators JSONB column to developers tablelast_intelligence_analysis_at timestamp to developers tablecomplexity_analysis JSONB column to pull_requests tabledeveloper_collaborations table for collaboration graph storageNew API Router:
backend/src/aexy/api/intelligence.py with 22 endpointsFixed an issue where Slack notifications were not being sent for uptime monitor incidents when the monitor didn't have a specific slack_channel_id configured.
Root Cause:
monitor.slack_channel_id to be set, but most monitors relied on the workspace's default Slack channel configurationslack_channel_configsChanges:
slack to notification_channels when creating new monitors if Slack is configured for the workspaceslack to existing monitors when a Slack channel is first configured for a workspaceCentralized Slack Integration Helpers:
backend/src/aexy/services/slack_helpers.py module with shared functions: - get_slack_integration_for_workspace() - finds integration by workspace/org ID
- get_slack_channel_config() - gets channel config for an integration
- get_workspace_notification_channel() - combines both to get channel ID
- check_slack_channel_configured() - boolean check for Slack setup
uptime_service.py and uptime_tasks.pyAdded Constants for Notification Channels:
NOTIFICATION_CHANNEL_SLACK = "slack"NOTIFICATION_CHANNEL_WEBHOOK = "webhook"NOTIFICATION_CHANNEL_TICKET = "ticket"Improved Type Safety:
db: AsyncSession) to notification helper functions_send_slack_notification()Better Exception Handling:
Exception catches to specific SQLAlchemyError for database operationsHTTPError handling for Slack API callsGraceful Error Handling:
add_slack_to_monitors() call in try/except to prevent channel configuration failures if monitor update failsFiles Changed:
backend/src/aexy/services/slack_helpers.py (new)backend/src/aexy/services/uptime_service.pybackend/src/aexy/processing/uptime_tasks.pybackend/src/aexy/api/slack.pyProvider Edit Modal:
Provider Card Improvements:
Provider Test Feedback:
Credential Encryption (Security):
secret_key via SHA256core/encryption.pyEmailProvider TypeScript interface with credentials, description, settings, and status fieldscredentials and description parametershas_credentials boolean field to provider API responses for secure credential status indicationhas_credentials flag indicates if configured--force flag not re-running changed migrationshas_credentials from API)DNS Records UI:
Provider Management:
- SES: checks for access_key_id and secret_access_key
- SendGrid: checks for api_key
- Mailgun: checks for api_key and domain
- Postmark: checks for server_token
- SMTP: checks for host
provider_id nullable in SendingDomain modelSET NULL on delete for provider foreign key relationshipdns_records, verification_token, and verified_at fields to SendingDomainListResponse schemaA comprehensive real-time proctoring system for assessment integrity with AI-powered face detection, violation tracking, and chunked video recording with cloud storage.
Face Detection & Monitoring:
Violation Tracking:
Screen & Webcam Recording:
Proctoring Settings:
Security Features:
Backend Proctoring Service:
ProctoringService for event logging and analysisR2 Upload Service:
Assessment Settings UI (Step 3):
enable_webcam, enable_screen_recording, enable_fullscreen_enforcementenable_face_detection, enable_tab_tracking, enable_copy_paste_detectionallow_calculator, allow_ideAssessment Review UI (Step 5):
New Files:
frontend/src/hooks/useChunkedRecording.ts - Chunked recording hookfrontend/src/services/recordingUploadService.ts - R2 upload servicefrontend/src/constants/index.ts - MAX_VIOLATION_COUNT constantfrontend/public/models/ - Face-api.js model filesbackend/src/aexy/services/proctoring_service.py - Proctoring event servicebackend/src/aexy/services/r2_upload_service.py - Cloudflare R2 integrationDependencies Added:
face-api.js - Browser-based face detectionMonitor Visibility Bug:
/monitors endpoint, but frontend expected { monitors: [], total } formatAPI Response Format Alignment:
monitors.list() - Now correctly handles array response from backendincidents.list() - Now correctly handles { items: [] } response formatmonitors.getChecks() - Now correctly handles { items: [] } response formatUnknown Status Handling:
unknown status support for newly created monitors (before first check runs)unknown to STATUS_COLORS in all uptime pages to prevent render crashesDEFAULT_STATUS_STYLE fallback for unrecognized status valuesNull-Safe Data Handling:
?.) when accessing API response properties|| []) for all list dataTypeError: Cannot read properties of undefined (reading 'length') errorsFiles Updated:
frontend/src/lib/uptime-api.ts - API response normalizationfrontend/src/app/(app)/uptime/page.tsx - Dashboard null safetyfrontend/src/app/(app)/uptime/monitors/page.tsx - Monitors list null safetyfrontend/src/app/(app)/uptime/monitors/[monitorId]/page.tsx - Monitor detail null safetyfrontend/src/app/(app)/uptime/incidents/page.tsx - Incidents list null safetyfrontend/src/app/(app)/uptime/incidents/[incidentId]/page.tsx - Incident detail null safetyfrontend/src/app/(app)/uptime/history/page.tsx - Check history null safetyA comprehensive uptime monitoring system for tracking HTTP endpoints, TCP ports, and WebSocket connections with automatic incident management and ticket creation.
Core Features:
Incident Management:
ongoing, acknowledged, resolvedHTTP Check Features:
TCP Check Features:
WebSocket Check Features:
Notification Channels:
Database Tables:
uptime_monitors - Monitor configurationsuptime_checks - Individual check results (time-series)uptime_incidents - Incident tracking with ticket integrationAPI Endpoints:
GET /workspaces/{id}/uptime/monitors - List monitorsPOST /workspaces/{id}/uptime/monitors - Create monitorGET /workspaces/{id}/uptime/monitors/{id} - Get monitor detailsPATCH /workspaces/{id}/uptime/monitors/{id} - Update monitorDELETE /workspaces/{id}/uptime/monitors/{id} - Delete monitorPOST /workspaces/{id}/uptime/monitors/{id}/pause - Pause monitoringPOST /workspaces/{id}/uptime/monitors/{id}/resume - Resume monitoringPOST /workspaces/{id}/uptime/monitors/{id}/test - Run immediate testGET /workspaces/{id}/uptime/monitors/{id}/checks - Check historyGET /workspaces/{id}/uptime/monitors/{id}/stats - Monitor statisticsGET /workspaces/{id}/uptime/incidents - List incidentsGET /workspaces/{id}/uptime/incidents/{id} - Get incident detailsPATCH /workspaces/{id}/uptime/incidents/{id} - Update incident notesPOST /workspaces/{id}/uptime/incidents/{id}/resolve - Manually resolvePOST /workspaces/{id}/uptime/incidents/{id}/acknowledge - Acknowledge incidentGET /workspaces/{id}/uptime/stats - Workspace-level statisticsFrontend Pages:
/uptime - Uptime dashboard with stats and overview/uptime/monitors - Monitors list with create modal/uptime/monitors/[id] - Monitor detail with stats, checks, and configuration/uptime/incidents - Incidents list with filtering/uptime/incidents/[id] - Incident detail with timeline and post-mortem notes/uptime/history - Check history viewerProduct Page:
/products/uptime - Marketing landing page for uptime monitoringCelery Background Tasks:
process_due_checks - Runs every minute, dispatches checks for due monitorsexecute_check - Performs individual HTTP/TCP/WebSocket checkssend_uptime_notification - Sends Slack and webhook notificationscleanup_old_checks - Daily cleanup of check history (keeps 30 days)Access Control Integration:
- Engineering bundle: Uptime enabled
- People bundle: Uptime disabled
- Business bundle: Uptime disabled
- Full Access bundle: Uptime enabled
can_view_uptimeStatistics & Metrics:
Extended the booking module with team scheduling capabilities.
All Hands Mode:
ALL_HANDS assignment type for team event typesRSVP System:
response_token for accepting/declining/rsvp/{token} for viewing booking details and respondingpending, confirmed, declinedTeam Calendar View:
/booking/team-calendarCustom Booking Links:
/book/{workspace} - Lists all public event types/book/{workspace}/{event}/team/{team}?members=id1,id2,id3New Database Table:
booking_attendees - Stores team meeting attendees with RSVP status and response tokensNew API Endpoints:
GET /booking/rsvp/{token} - Get booking details for RSVPPOST /booking/rsvp/{token}/respond - Submit RSVP response (accept/decline)GET /public/book/{workspace}/teams - List workspace teams for bookingGET /public/book/{workspace}/team/{team_id} - Get team info for booking pageGET /booking/calendars/callback/{provider} - OAuth callback endpointNew Frontend Pages:
/booking/team-calendar - Team availability calendar view/book/{workspace} - Public workspace landing page/book/{workspace}/{event}/team/{team} - Team-specific booking page/rsvp/{token} - Public RSVP response page/docs/booking.md/products/booking/docs/README.md to include booking in documentation index/docs/google.md with booking calendar callback URLsCalendar OAuth Flow:
Callback URL Change:
/api/v1/booking/calendars/callback/{provider}?success=true and ?error=... query paramsAn intelligent knowledge graph feature that automatically extracts entities from documentation and visualizes relationships in an interactive force-directed graph.
Core Features:
Entity Types:
Relationship Types:
mentions, related_to, depends_on, authored_by, implements, references, links_to, shares_entityBackend Components:
knowledge_entities, knowledge_entity_mentions, knowledge_relationships, knowledge_document_relationships, knowledge_extraction_jobs/workspaces/{id}/knowledge-graph/KnowledgeExtractionService, KnowledgeGraphServiceAPI Endpoints:
GET /graph - Full graph data with filtersGET /graph/document/{id} - Document-centric viewGET /graph/entity/{id} - Entity neighborhoodGET /entities - List/search entitiesGET /path - Find path between nodesGET /statistics - Graph statisticsGET /temporal - Timeline dataPOST /extract - Trigger extractionGET /jobs - Extraction job statusFrontend Components:
/docs/knowledge-graphTemporal Features:
Quality Metrics:
A comprehensive calendar booking system similar to Calendly, fully integrated into the Aexy ecosystem.
Core Features:
Backend Components:
EventType, Booking, UserAvailability, AvailabilityOverride, CalendarConnection, TeamEventMember, BookingWebhookBookingService, AvailabilityService, CalendarSyncService, BookingPaymentService, BookingNotificationServiceFrontend Pages:
/booking - Booking dashboard with stats, event types overview, and upcoming bookings/booking/event-types - List and manage event types/booking/event-types/new - Create new event type/booking/event-types/[id] - Edit existing event type/booking/availability - Weekly availability schedule editor/booking/calendars - Calendar connections managementPublic Booking Pages:
/public/book/[workspace]/[event] - Public event booking page with calendar picker/public/book/confirmation/[bookingId] - Booking confirmation page/public/book/cancel/[bookingId] - Booking cancellation page/public/book/reschedule/[bookingId] - Booking reschedule pageEvent Type Configuration:
Availability Features:
Calendar Integration:
Booking Management:
Access Control Integration:
- Engineering bundle: Booking disabled
- People bundle: Booking disabled
- Business bundle: Booking enabled with all modules
- Full Access bundle: Booking enabled with all modules
can_view_bookingBackground Tasks (Celery):
send_booking_reminders - Send reminder emails 24h and 1h before meetingssync_all_calendars - Periodic calendar synchronizationprocess_booking_webhooks - Dispatch webhooks to registered endpointscleanup_expired_pending_bookings - Cancel stale pending bookingsmark_completed_bookings - Auto-mark past bookings as completedgenerate_booking_analytics - Generate booking statisticsEnterprise Features (Planned):
Migration Runner Script:
backend/scripts/run_migrations.py for running SQL migrationsschema_migrations table with checksums--list, --dry-run, --file, --force, --database-url optionsTest Token Generator:
backend/scripts/generate_test_token.py for API testingThe foundational release of Aexy - a comprehensive Engineering OS platform for team management, performance tracking, hiring, and business operations.
Customizable Dashboards:
Daily Standups:
Work Logs:
Time Tracking:
Blockers:
Activity Patterns:
Sprint Management:
Task Management:
Task Types:
Sprint Metrics:
Retrospectives:
Task Templates:
GitHub Integration:
Review Cycles:
Individual Reviews:
Review Submissions:
Peer Review Requests:
Work Goals (SMART Framework):
Contribution Summaries:
Assessment Platform:
Question Types:
Question Configuration:
Assessment Settings:
Candidates:
Attempts & Proctoring:
Evaluation:
Question Analytics:
Objects & Attributes:
Records:
Record Lists:
Activities:
Automations:
Sequences & Campaigns:
Webhooks:
Templates:
Campaigns:
Recipient Tracking:
Email Tracking:
Analytics:
Subscriber Management:
Document Management:
Sharing & Permissions:
Collaboration:
Form Builder:
Form Features:
Analytics:
Learning Goals:
Approvals:
Budget Management:
Ticket Management:
Multi-Workspace:
Integrations:
Security & Compliance: