Skip to content

feat: operator UI for Teams agent identities + end-to-end smoke - #896

Merged
Weegy merged 21 commits into
mainfrom
feat/w2a-operator-ui
Aug 27, 2026
Merged

feat: operator UI for Teams agent identities + end-to-end smoke#896
Weegy merged 21 commits into
mainfrom
feat/w2a-operator-ui

Conversation

@Weegy

@Weegy Weegy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Wave W2a of epic #860, integrated from 12 parallel units into one branch. The provisioning chain built in W1a had no operator surface — everything ran through curl. This gives it one, and adds an end-to-end smoke that drives the real chain against a throwaway tenant.

Refs #866, part of #860.

What the operator can do now

Create a Teams identity for an orchestrator. Bot slug and display name are optional (the server derives both from the agent); the target team is required, because the server requires it and provisioning installs the app into it.

Read the provisioning status. The state machine (pending → app_registered → bot_created → package_built → catalog_uploaded → installed) renders as a live chain with a badge, polled while the run is non-terminal and torn down on a terminal state or unmount.

Act on a last_error. The middleware classifies the failure server-side, next to the only code that writes those sentences, and the route emits identity.last_error_detail alongside the unchanged last_error. The panel renders what happened, the captured scopes or setup fields, and what to do next — plus a link to Microsoft's admin-consent step for a consent failure. The raw English sentence is a collapsed technical detail, never the message. arm_not_configured on app_registered reads as a legitimate stop (registration-only), not as a broken agent.

Copy the teams_bot block — the channel-teams teams_bots[] entry, verbatim in parseTeamsBotsConfig key order, with the bot password present only as its opaque vault ref. The copy says plainly that pasting it into the plugin's setup field is a manual step and that nothing syncs it; automatic config sync stays a documented follow-up.

Assign a team. Installed teams, consent status, install — and an honest 501 for uninstall, because teamsProvisioner@1 publishes none and clearing team_id would only make the middleware forget an install that is still live in Teams.

Open the native Agent Builder. Persona and behaviour design stays there; the detail page deep-links to the draft that published this orchestrator's agent plugin, or to the overview when no single draft matches — never a guessed [id].

Browse chat-context memory outside dev. /memory now reads the new authenticated GET /api/v1/operator/memory/contexts/{list,file} instead of the dev-only router. The endpoint's path guard normalizes every request into the /memories/contexts subtree segment-wise (a startsWith check would have handed over /memories/contextsX) and rejects traversal before the store is touched; a second, rooted accessor cannot emit an out-of-scope path even if the first layer were wrong. The agent tier and the shared kernel are deliberately not reachable through it.

Findings fixed during integration

# Severity Finding Fix
1 blocker Two units projected identity.last_error_detail on the same key with different wire shapes (camelCase vs snake_case); a merge keeping both would have produced a duplicate object key — legal JS, last one silently wins One owner: the camelCase TeamsProvisioningErrorDetail from classifyTeamsProvisioningError. The duplicate snake_case projection and its second classifier are deleted; the round-trip test moves onto the kept producers
2 blocker Create form omitted team_id when left empty, but TeamsIdentityProvisionSchema declares it z.string().min(1) — the hints literally invited an input that could only 400 Target team is a required field, submit is blocked while empty, both hints corrected in en/de
3 blocker onRerun={() => void provision({})} — the re-run action posted an empty body, so it 400'd in 100% of cases GET …/teams-identity additionally returns identity.team_id; the re-run resends it. No recorded target → disabled button with a reason, not an action that cannot succeed
4 blocker POST /:slug/teams wrote the new team_id before enqueueing, and the runner refuses a conflicting enqueue with a resolved {status:'rejected'} — never a rejection — so the .catch() never fired and the route answered 202 while run #1 installed into the old team refuseConflictingTeamRetarget() answers 409 before any write, using the new TeamsProvisioningJobRunner.runningTeamId(). A refused enqueue is now recorded via recordEnqueueFailure instead of dropped
5 blocker POST /:slug/teams-identity rewrote team_id on an already-installed row with no guard, while the runner returns early on installed — producing a read model that published an install that never happened Same guard; the 409 covers the installed case too
6 major The choke-point pin asserted projectTeamsBotConfig(row) against a router built without a clientSecretRef, so both sides collapsed to the default ref: dropping the second argument at the call site would have stayed green while operators pasted a config pointing at a nonexistent vault key A second router is mounted with an overriding clientSecretRef, and the GET's appPasswordSecretRef is asserted against it
7 major errors.throttled hardcoded {seconds} with detail.retryAfterSeconds ?? 0 — a throttle that exhausts its budget without a Retry-After header rendered "It can be retried in about 0 seconds", i.e. retry immediately at the exact moment the runner gave up The wait line is emitted only when the server actually sent the hint
8 major builderDraftId === null conflated "still loading" with "resolved to no match": the page stated a false negative on every load, and a click before both fetches settled dropped the deep link An explicit draftsSettled flag; while resolving, neither the hint nor a followable fallback href renders
9 major canInstall gated on installed.length === 0, but teams is empty for every state except installed — so the install control stayed enabled through the whole in-flight window, and the route's non-installed branch does not 409 pending_team_id === null && !running added to the conjunction
10 major pendingHint claimed "a provisioning run is targeting team X" for every non-installed state, terminal failures included — a panel that renders neither state nor last_error had nothing to contradict it The hint branches on running; a stopped chain gets its own copy pointing at the Teams identity section
11 major A 404 mapped unconditionally to "This path no longer exists in the memory store", and listDir turned a root 404 into an empty branch — so an unmounted router rendered as "No agent memory yet" over a store full of context trees A 404 on the contexts ROOT (or one that is not the router's own {"error":"not_found"} JSON) reads as an unreachable endpoint; listDir surfaces it instead of returning []
12 major Duplicate primitive: app/_lib/teamsIdentityErrors.ts was a second last_error sentence parser, dead since the middleware started projecting the structured detail Deleted. Its richer failure copy is what the panel renders now; one classifier, pinned by a round-trip test against the real producers
13 blocker (smoke) pollUntilTerminal honoured last_error_detail from the FIRST poll, but nothing clears a previous run's last_error on a re-run — the exact flow the handoff prescribes (fix the ARM fields, re-run) could report a green "registration only" for a run that had done nothing The pre-POST snapshot is kept; no terminal verdict is accepted until the row has demonstrably changed since it. The arm_not_configured branch is constrained to app_registered/bot_created, as its own doc comment already claimed
14 major (smoke) The production-host refusal compared url.host, which includes the port — https://app.omadia.ai:8443 slipped past the guard documented as having no override Compares url.hostname
15 major (smoke) assertMessagingEndpointBound accepted any status >= 400 && !== 404 as proof that Bot Framework auth is enforced, so a 500 from the deliberately malformed probe passed identically to a clean 401 Only 401/403 (or 400 for a rejected malformed activity) pass; 5xx is a hard failure with the body echoed

Two units were flagged clean (smoke-hub-artifact-ingest, operator-memory-contexts-endpoint); the hub-artifact unit produced no tracked changes at all, since the smoke scripts are gitignored.

The E2E smoke — not in this diff, on purpose

middleware/scripts/smoke-teams-e2e.ts (stage 1) and smoke-teams-e2e-stage2.ts are matched by middleware/scripts/smoke-*.ts in .gitignore and are deliberately absent from this PR — they hit byte5-internal endpoints. npm run smoke:teams-e2e is registered in middleware/package.json; stage 1 imports and calls stage 2.

What it does. Stage 1 downloads the real published plugin artifact from the hub and runs it through the production extractZipToDir. Stage 2 then drives the live chain: POST /api/v1/operator/agents/:slug/teams-identity (202), polls GET …/teams-identity through the full state machine, asserts the teams_bot projection and teams_app_id are complete, and probes the new bot's /api/teams/<botSlug>/messages route with an unsigned payload to prove it is live and rejects it.

How to run it against a throwaway environment.

cd middleware
export SMOKE_MW_BASE_URL=https://<scratch-host>          # no default exists
export SMOKE_TEAMS_E2E_TARGET=<scratch-host>             # echo back the exact host
npm run smoke:teams-e2e

It needs the M365 connector plugin installed and active in that environment, admin consent granted in the scratch tenant, and the ARM setup fields configured for the full chain — without them the run legitimately stops at app_registered and reports success-with-caveat. docs/middleware-agent-handoff.md carries the long form.

Why it must never run against production. A provisioning call persists an agent_teams_identities row and creates real Entra app registrations, Azure bot resources and Teams catalog entries in the target tenant — none of which this script can undo, and teamsProvisioner@1 publishes no uninstall. The guard is fail-closed and layered: no default target, an explicit opt-in, a host the caller has to echo back verbatim, a hostname-matched refusal of known production hosts with no override switch, and an abort when the shell carries a non-scratch DATABASE_URL.

Known limitation. The smoke does not send a genuine Bot Framework turn — signing one needs the freshly created app's secret, which never leaves the connector's vault. The visible reply in Teams stays a one-line manual step the run prints out.

Gates

Gate Result
middleware && npm run typecheck clean
middleware && npm test 7733 tests, 0 fail (16 skipped)
web-ui && npm run lint 0 errors (51 pre-existing warnings, none in this diff)
web-ui && npm run typecheck clean
web-ui && npm run test 107 files, 964 tests, 0 fail
web-ui && npm run i18n:check OK — 4050 keys, en + de

One flake was observed and is documented rather than papered over: AgentToolGrants.test.tsx ("shows the latest grant epoch in the heading summary") failed once in a full run and passed in every subsequent full run, in an isolated run, and in a run of its whole directory. This diff touches neither that component nor its test — the known cross-file pollution class, not a regression from this branch.

Notes for review

  • No schema change and no migration anywhere in this wave. identity.team_id and identity.last_error_detail are both additive projections of columns that already exist.
  • agent_teams_identities keeps ONE team_id (migration 0049), documented as resume evidence. The installed-teams read model is therefore derived, never plural, and it refuses to publish a team the runner did not actually install into — which is what findings 4 and 5 were about.
  • No secret material anywhere: appPasswordSecretRef is the opaque teams_bot_password:<appId> handle, and the shape check drops the whole block rather than render a value that is not ref-shaped.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Weegy added 21 commits August 27, 2026 08:39
…its choke point

Adds the authenticated, read-only operator surface for the chat-context memory
trees (epic #860, design #870 §2) — the W5 carry-over that lets an operator
browse `/memories/contexts/...` in production.

Until now `web-ui/app/memory/page.tsx` browsed those trees through
`/bot-api/dev/memory/list`, i.e. `harness-memory/src/devMemoryRouter.ts`, which
the memory plugin mounts only when `dev_memory_endpoints_enabled` is truthy —
a flag the kernel forbids in production. That router is also unauthenticated and
exposes the WHOLE `/memories` tree, so it could never become the production
answer. This router is: session-gated, and structurally unable to leave
`/memories/contexts`.

THE GUARD IS THE FEATURE
------------------------
Between an authenticated listing and the rest of `/memories` — the agent tier,
the shared kernel, every other context tree — stands nothing but the path guard,
so it is layered twice:

  1. `resolveContextPath` (pure, exported, unit-tested) normalises the operator's
     absolute `?path=` and rejects any `.`/`..` segment, any NUL byte, any
     relative or over-long path, and anything whose first two segments are not
     `memories/contexts`. That last test is segment-wise on purpose:
     `/memories/contextsX` is a different tree, and a `startsWith` check would
     have handed it over.
  2. `createRootedMemoryAccessor` re-normalises the resulting relative path and
     cannot emit a store path outside its root even if step 1 were wrong.

`createRootedMemoryAccessor` is new in `platform/memoryAccessor.ts`: the existing
per-plugin/per-orchestrator accessor and this one now share one engine
(`createScopedMemoryAccessor`), so the isolation rules live in exactly one place.
`createMemoryAccessor` keeps its signature and behaviour byte-for-byte — the
legacy `/memories/agents/<pluginId>` read-through is now expressed as an optional
`legacyPrefix` on the scope, and `memoryAccessorIsolation.test.ts` still passes
untouched.

WIRE SHAPE — settled here
-------------------------
`GET /list` answers `{ path, entries: [{ virtualPath, isDirectory, sizeBytes }] }`,
byte-compatible with the dev router, so web-ui's existing `ListResponse`/`Entry`
types and its self-entry filter carry over with only the URL changed.

`GET /file` is included alongside the listing the spec asked for. The memory page
has a second call site (`/dev/memory/file`), and shipping only the listing would
have left the operator a tree whose files cannot be previewed in production. It
is read-only and runs through the same guard; there is no write, delete or
promote verb here. Promotion stays where it is, at
`POST /api/v1/admin/memory/promotions/:slug`.

The `src/index.ts` mount is deliberately NOT in this commit — the wiring unit
owns it. Intended mount: `/api/v1/operator/memory/contexts` behind `requireAuth`.

Follow-up worth noting, not in scope: the router repeats the session check that
`requireAuth` already performs at mount time. That is deliberate belt-and-braces
for a memory-reading endpoint, matching `memoryPromote.ts`.
…ams_bot formatter

The Teams provisioning identity endpoint exposes `identity.last_error` as a
free-form English sentence and `teams_bot` as a raw config projection. Neither
can be rendered as-is: English on a German operator's screen violates the i18n
rule, and a config block with no explanation leaves the operator guessing what
to do with it.

- `teamsIdentity.ts` mirrors the state vocabulary of
  `agentTeamsIdentityStore.ts` (migration 0049's CHECK constraint), validates
  the GET envelope into a camelCase view, and renders the `teams_bots` entry
  exactly as channel-teams' `parseTeamsBotsConfig` expects it. A value that is
  not a vault handle drops the whole block instead of reaching a copy-paste
  box.
- `teamsIdentityErrors.ts` classifies `last_error` into
  consent_missing / arm_not_configured / throttled / unknown, capturing the
  missing scopes, the missing setup fields and the Retry-After hint as ICU
  arguments. The raw sentence is demoted to a secondary technical detail.
- ArmNotConfigured reads as a halt, never a failure: the runner keeps the
  identity on `app_registered`, and the copy says registration-only is a valid
  place to stop.
- The test reads the middleware sources, so a reworded producer sentence or a
  changed state vocabulary fails here instead of silently degrading the UI.

Follow-up, not in scope: the job runner should persist a structured code from
the start and the route project it as `last_error_detail`, which would demote
this parser to a fallback for older rows. Automatic `teams_bots[]` sync into
channel-teams is likewise still a follow-up — the copy says the paste is manual.
The W5 context browser fetched /bot-api/dev/memory/{list,file}, which the
memory plugin mounts only when dev_memory_endpoints_enabled resolves truthy
-- a flag the kernel forbids in production. The panel was therefore dead
exactly where an operator needs it, and its own error copy told them to set
DEV_ENDPOINTS_ENABLED, advice no production operator can act on.

Both call sites now go to GET /api/v1/operator/memory/contexts/{list,file}
(requireAuth, cookie session JWT -- the same gate as the Danger-Zone purge).
The wire shape is unchanged, so only the URL moves; the list handling, the
self-entry filter and the file/markdown preview carry over as they were.

That endpoint is structurally unable to read outside /memories/contexts, so
the page mirrors the scope instead of pretending: the root is CONTEXTS_ROOT,
breadcrumbs and "up" stop there, listDir treats an out-of-scope path as an
empty branch rather than a request, and the context tree no longer offers an
agent-tier node -- a node that always errors is worse than an absent one.
Promotion still targets the agent tier; that is a write on the audited
promote route, not a read here. The browser stays strictly read-only.

401 and 403 are ordinary answers on a gated route, so they get their own
copy (errorUnauthenticated / errorForbidden) rather than a bare "Listing
failed (HTTP 401)"; errorPathNotFound and errorOutOfScope replace the dev
endpoint's advice, and memory.errorDevEndpointUnavailable is gone.

Tests: MemoryContextBrowser.test.tsx pins the URL that actually goes out,
the path scope of every request, the file preview and the auth states, with
the real URL builders (importOriginal) so a stubbed builder cannot pass for
the wire. page.contexts.test.tsx now serves the operator endpoint only.
#860 W2a)

Lift the channel-teams `teams_bots[]` entry out of the GET
/operator/agents/:slug/teams-identity handler into one exported
`projectTeamsBotConfig()` so every further team-to-agent route emits a
byte-identical block. The entry is a config contract with channel-teams;
a second, drifting copy would hand operators a config the plugin
silently refuses to parse. Response bytes for the existing route are
unchanged: null unless BOTH app_id and tenant_id are known, appType
always 'SingleTenant', the bot password only as the opaque vault ref
teams_bot_password:<appId>. Pasting the block into channel-teams stays a
manual operator step; automatic config sync remains a follow-up.

Additively, the GET now also returns `last_error_detail` - the identity's
last_error decoded into { code, scopes?, fields?, retryAfterSeconds?, raw }
so the operator UI renders from a code plus typed arguments through i18n
instead of parsing English sentences. The classifier lives next to the
only code that writes those sentences (services/teamsProvisioningJob.ts):
changing a message and forgetting the decoder now breaks a colocated
round-trip test rather than degrading the UI in production. An exhausted
throttle budget gains a `throttled:` prefix and carries its Retry-After
hint, so that code is actually producible.

No schema change, no new route, no migration.
Adds the per-agent Teams bot identity section: create the identity (bot
slug, display name, optional target team), start provisioning, and watch
the state machine advance live against
`GET /v1/operator/agents/:slug/teams-identity`.

- AgentTeamsIdentity.tsx owns the data flow (fetch, poll, provision) and
  is mounted from AgentDetail.tsx, the single composition point for the
  Teams sections so the sibling Teams units extend one file.
- All seven states render readably: the badge names the current state and
  an ordered chain marks progress. `failed` is a sink, not a chain step,
  so it never claims a position the run did not reach.
- Polling is keyed on the current non-terminal state — it survives polls
  that return an unchanged state, restarts on a transition, and is torn
  down both at a terminal state (installed/failed) and on unmount.
- Four non-errors stay non-alarming: 404 `teams_identity_not_found` is
  the create form's trigger, the two 503 capability gates render as
  informational notices, and a `last_error` on a non-terminal state keeps
  polling. 409 `bot_slug_taken` maps to localized copy with the operator's
  input preserved.
- `last_error` is NOT parsed in web-ui. The panel renders the server-side
  classification (`identity.last_error_detail`) through i18n with ICU
  arguments and keeps the raw runner sentence as a secondary technical
  detail; the client only narrows the already-structured projection, and
  degrades to the localized fallback when the field is absent.
- New i18n keys are additive under the existing `operatorAgents`
  namespace, en + de.

Follow-up worth doing separately: the provisioning job runner should
persist a structured error code from the start, rather than having the
route classify the sentence it wrote.
…uilder

Persona, tone and behaviour are designed in the native Agent Builder
(app/store/builder/[id], PersonaPillar et al.). The operator agent detail
page stays a capability-assignment surface and now just points there — no
persona UI is duplicated, and no Kemia integration is involved.

The builder route is keyed by a draft [id] while the operator page is keyed
by an orchestrator slug, and no column joins the two directly. The published
agent plugin does: a builder draft pins the plugin it published
(published_agent_id) and an orchestrator carries that same plugin id in its
assigned set. resolveBuilderDraftId() takes the assigned plugins whose
catalog entry is kind === 'agent' and deep-links the draft that published
one of them.

The resolution is deliberately conservative: only an unambiguous single
match produces /store/builder/<id>. No match, an ambiguous match, a catalog
that has not resolved yet, or a failed drafts listing all fall back to the
/store/builder overview, which always exists — a guessed id would 404 and
picking one of several drafts would open the wrong persona. The drafts fetch
is best-effort and never raises this page's error banner, which stays
reserved for the writes it owns.

Link label, hint and fallback notice go through next-intl (en + de).
Adds the operator surface for "which teams is this agent's app installed
in, and put it into one":

  GET    /v1/operator/agents/:slug/teams
  POST   /v1/operator/agents/:slug/teams
  DELETE /v1/operator/agents/:slug/teams/:teamId  -> 501

No schema change, no migration, no widening of the connector contract.
Everything the routes report is derived from the existing
`agent_teams_identities` row and projected through the wave's single
`projectTeamsBotConfig` / `classifyTeamsProvisioningError` choke points.

STOP-GATE FINDINGS (the reason the shape is what it is):

1. The read model can never be plural. Migration 0049 is
   one-identity-per-agent with a SINGLE nullable `team_id`, documented as
   "the install target of the last provisioning request". An install SET
   needs N rows, i.e. a new table.
2. The connector cannot enumerate installs. `teamsProvisioner@1` exposes
   createAppRegistration / createBot / buildAppPackage / uploadToCatalog /
   getCatalogApp / installToTeam - no listing method. `getCatalogApp`
   answers tenant-CATALOG presence, never a team install, so a "live
   lookup" through it would assert something the connector never said.
3. There is no uninstall either. `installToTeam` has no counterpart, so
   DELETE cannot do the thing its name promises. It answers 501 with the
   reason instead of clearing `team_id`, which would only make the
   middleware forget an install that is still live in Teams.

So the routes report exactly what the row proves, mark every entry
`evidence: 'identity_row'`, and ship the platform's limits as data
(`capabilities.{install,uninstall,enumerate,multi_team}` plus a reason per
false) so the operator UI can disable a control instead of discovering the
gap through a failed request. A team below state 'installed' is surfaced
as `pending_team_id`, never as an install. Consent status is read back
from the runner's recorded failure through its own classifier - the typed
connector guards are caught where they are thrown, at write time - and
`granted` is only claimed for a state the chain could not have reached
without consent.

POST records the target through the store's own gate and hands the chain
to the provisioning runner (the single writer of state); it is idempotent
for the team the agent is already in and answers 409 for a SECOND team
rather than overwriting the only team_id the schema has.

`OperatorTeamsIdentityRecord.teamId` is now required, pinned in the pg
suite: a store that stopped exposing it would leave GET /:slug/teams
reporting "no installs" forever instead of failing.
…#860 W2a)

STAGE 2 of middleware/scripts/smoke-teams-e2e.ts (gitignored, hits
byte5-internal endpoints) drives the live provisioning chain over HTTP:
POST /api/v1/operator/agents/:slug/teams-identity (202), then polls
GET .../teams-identity through pending -> app_registered -> bot_created ->
package_built -> catalog_uploaded -> installed, asserts the teams_bot
projection and teams_app_id are complete, and verifies the new bot's
/api/teams/<botSlug>/messages route is live and rejects an unsigned
payload. It runs on the environment STAGE 1 establishes.

Production-write guard, fail-closed: a provisioning call persists an
agent_teams_identities row and creates real Entra/Azure/Teams objects, so
the stage has no default target. It skips entirely without an explicit
opt-in token, requires the caller to echo the target host back, refuses
known production hosts with no override, and aborts when the shell holds
a non-scratch DATABASE_URL.

Registration-only is a valid terminal outcome: with no ARM setup fields
the chain stops at app_registered with arm_not_configured and the run
reports success-with-caveat. Missing admin consent stays a hard stop.

GET .../teams-identity now emits identity.last_error_detail alongside
last_error - { code, scopes?, fields?, retry_after_seconds?, raw }.
Additive; no schema change, no migration. The classifier lives next to
the producers that write those sentences, with a round-trip test so
rewording a message without updating the parser breaks a colocated test
instead of silently degrading the operator UI. Clients render from the
structured object and must not parse the English sentence.

Docs: handoff section on pointing the smoke at a scratch tenant and what
it needs, plus a CHANGELOG entry.

Follow-up: the job runner should persist a structured code from the
start; that needs a migration on agent_teams_identities and its own unit.
…tity-panel' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-teams-bot-config-block
, W2a)

The last mile of Teams provisioning is not automated: the operator copies
the resulting `teams_bots` entry into the channel-teams setup field by
hand. Render that block on the Teams identity panel and say the manual
step out loud instead of letting anyone assume a sync that does not exist.

- `TeamsBotConfigBlock` renders the route's `teams_bot` object verbatim
  through `parseTeamsIdentityEnvelope` + `formatTeamsBotsConfig` from
  `_lib/teamsIdentity`, so the JSON array and its key order stay exactly
  what `parseTeamsBotsConfig` expects on paste. Nothing is reshaped.
- `teams_bot` is null until app_id AND tenant_id exist (before the
  app_registered step): that gets an explanatory line, not an empty box.
- `appPasswordSecretRef` is the opaque vault ref, never the password. A
  value that is not ref-shaped drops the whole block rather than printing
  it into a copy-paste box, and nothing logs the ref.
- One-click copy with a soft clipboard failure: the block stays visible
  and selectable, no error banner.

i18n: adds `operatorAgents.teamsIdentity.teamsBot.{heading,blockLabel,
copy,copied}` in both locales; the manual-step, instructions, secret-ref
and follow-up copy already came from the `_lib` unit.

Merge fix, in the same namespace: this branch merges the `_lib` unit and
the panel unit, and both had appended a `teamsIdentity` object to
`operatorAgents` — a DUPLICATE JSON key that silently dropped the whole
`_lib` block (JSON.parse keeps the last), failing its i18n coverage test.
The two are consolidated into one object; the panel's route-error
catalogue moves to `teamsIdentity.routeErrors.*` so it stops colliding
with the `_lib` long-form provisioning-error catalogue at
`teamsIdentity.errors.*`. Message text is unchanged.

Follow-up worth its own issue: writing the `teams_bots[]` config into
channel-teams automatically, and persisting a structured error code from
the job runner instead of classifying the sentence after the fact.
Adds the operator surface for "which team is this orchestrator's app
installed in, put it into one, and has the tenant consented" on the agent
detail page, backed by the wave's derived install read model
(GET/POST /v1/operator/agents/:slug/teams, DELETE /:slug/teams/:teamId).

The panel is CAPABILITY-DRIVEN rather than hard-coded to today's platform
limits. teamsProvisioner@1 publishes neither an installation listing nor an
uninstall, and migration 0049 records ONE team_id per agent, so the route
ships those limits as `capabilities.*` plus a reason per false. Every
control reads that block:

- uninstall renders DISABLED with a localized reason instead of a live
  button that answers 501, and the same control lights up unchanged the day
  the connector contract gains an uninstall;
- install is refused once a team is tracked (multi_team false) and the 409
  team_install_conflict maps to its own copy;
- the derived-not-enumerated caveat hangs off `enumerate`, so it disappears
  rather than lying when a listing method appears;
- an absent or partial capability block is parsed fail-closed at BOTH the
  network boundary (getAgentTeams) and the render boundary, so a middleware
  that never learned to report capabilities disables everything instead of
  enabling a lie.

Consent is rendered from the route's server-derived verdict
(consent.status + missing_scopes + what the verdict rests on). The
remediation copy is NOT duplicated here - it belongs to the Teams identity
panel, and this one points at it, so the wave keeps a single place with the
consent instructions. The provisioning state vocabulary and last_error are
likewise left to that panel.

Writes disable the panel while in flight, uninstall sits behind a
ConfirmDialog, and a success refreshes this panel and then the page through
AgentDetail's router.refresh() convention. Every failure renders the
localized copy for the machine code; the server's English
`unsupported_reason` appears only as a secondary technical detail (web-ui
i18n hard rule). New keys are additive under operatorAgents.teamsInstalls,
en + de.

Follow-up, not in scope: the job runner should persist a structured error
code from the start rather than having the route classify the sentence it
wrote, and an install SET (plus a real uninstall) needs both a schema change
and a wider connector contract.
…emory-contexts-endpoint' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-wiring
…config-block' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-wiring
…nment-ui' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-wiring

# Conflicts:
#	web-ui/app/operator/agents/[slug]/_components/AgentDetail.tsx
#	web-ui/messages/de.json
#	web-ui/messages/en.json
…der-link' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-wiring
…text-browser-operator-endpoint' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-wiring
…2E smoke, close en/de parity (#860 W2a)

Wiring unit for wave W2a. It owns every hub file so the parallel feature
units never collide on one.

1. middleware/src/index.ts - mount createOperatorMemoryContextsRouter at
   /api/v1/operator/memory/contexts behind requireAuth, next to its verified
   operator siblings (/api/v1/operator/agents, /api/v1/operator/channels) and
   with the same boot-log convention. Until now the switched memory browser
   in web-ui pointed at an endpoint nothing mounted; the dev router it
   replaces (@omadia/memory devMemoryRouter) is unauthenticated, exposes the
   whole /memories tree and is forbidden in production. The root memoryStore
   is passed in deliberately - the router's own rooted accessor is the scope
   choke point and cannot emit a path outside /memories/contexts.

2. middleware/package.json - register "smoke:teams-e2e", next to the existing
   smoke entries. The script itself is gitignored, like every other
   scripts/smoke-*.ts.

3. web-ui/messages/{en,de}.json - final parity pass over the 136 keys the
   Teams identity panel, the view types and error mapper, the config block,
   the installs panel, the builder link and the memory browser each added.
   The catalogues are the key-level union of those units' deltas: additions
   and updates applied, and the two keys the memory browser retired
   (memory.errorDevEndpointUnavailable, memory.contexts.agentTier) stay
   retired. Every new key has a real German translation; no ICU argument
   differs between the locales.

AgentDetail is the single composition point for the Teams sections, so the
identity and installs panels now mount together. AgentDetail.test.tsx stubs
both - they fetch their own read models and raise their own role="alert"
banners, which their own suites cover, and leaving them live made this
suite's alert assertions ambiguous rather than about AgentDetail's own write
errors.

Gates: middleware typecheck + 7712 tests green; web-ui lint, typecheck, 963
tests, i18n:check (4048 keys) and i18n:literals (0 to translate) green.
Conflict resolution: one owner for identity.last_error_detail. The
choke-point unit's camelCase TeamsProvisioningErrorDetail wins (it is what
the merged web-ui and both middleware test suites already consume); the
smoke unit's parallel snake_case teamsLastErrorDetail projection and its
duplicate classifier are dropped. teamsProvisioningLastError.test.ts is
rewritten onto the kept producers so the round-trip pin survives.
…d team_id, retarget guards

Blockers and majors from the per-unit reviews, fixed across the merged wave.

MIDDLEWARE
- One owner for `identity.last_error_detail`: the camelCase
  `TeamsProvisioningErrorDetail` from `classifyTeamsProvisioningError`. The
  parallel snake_case `teamsLastErrorDetail` projection and its duplicate
  classifier are dropped; the round-trip test moves onto the kept producers.
- `GET /:slug/teams-identity` additionally returns `identity.team_id` — the
  POST requires `team_id` and has no fall-back-to-stored path, so without it a
  re-run could only ever 400.
- `refuseConflictingTeamRetarget()`: both POSTs answer 409 BEFORE any write
  when the row is already `installed` in another team, or when a run is in
  flight toward another team. `installToTeam` uses the teamId captured at
  enqueue, so an accepted retarget installed into the OLD team while the row
  claimed the new one — a read model asserting an install that never happened.
- `TeamsProvisioningJobRunner.runningTeamId()` exposes the in-flight target,
  because the runner refuses a conflicting enqueue with a RESOLVED
  `{status:'rejected'}` a fire-and-forget caller cannot observe in time.
- `startProvisioningRun()` records that refusal via `recordEnqueueFailure`
  instead of dropping it in a `.catch()`-only handler.
- Route test now pins `deps.clientSecretRef` through the HTTP layer; the old
  pin compared two calls that both collapsed to the default ref.

WEB-UI
- Create form requires the target team and the re-run resends the recorded
  one; both hints corrected in en/de. No target recorded -> disabled button
  with a reason, not an action that can only fail.
- Deleted `app/_lib/teamsIdentityErrors.ts`: a second sentence parser for
  `last_error`, dead since the middleware started projecting the structured
  detail. Its (better) failure copy is what the panel now renders, which also
  fixes the throttle case: no `Retry-After` hint means no wait line, instead
  of "retry in about 0 seconds" at the moment the runner gave up.
- Agent Builder link: a `draftsSettled` flag separates "still loading" from
  "resolved to no match" — the page no longer states a false negative on every
  load, and no click can drop the deep link mid-flight.
- Team assignment: install is gated on `pending_team_id`/`running`, not just
  on `teams.length` (which is empty for every non-installed state); the
  pending hint distinguishes a run under way from a chain that stopped.
- Memory browser: a 404 on the contexts ROOT (or one that is not the router's
  own JSON) reads as an unreachable endpoint, not a missing path — a mounted
  router can never 404 that path, and the old copy told operators their store
  was empty when the router was simply not mounted.

Refs #866, part of #860.
@Weegy
Weegy merged commit 69f3f5b into main Aug 27, 2026
8 checks passed
Weegy added a commit that referenced this pull request Aug 27, 2026
Operators had no single place that says how to get several omadia agents
into Microsoft Teams as separate named bots. The knowledge was spread
across three repos, two manifests, five migrations and a handful of PR
descriptions, so every attempt rediscovered the same traps: consent that
silently does not apply, ARM fields whose absence is a partial success
rather than a failure, and a teams_bots block nothing syncs for you.

The new guide walks the whole path and states the platform limits up
front, because they decide the architecture: Teams cannot change a bot
name per message, bots never see each other, and rate limits are per bot
(which is an argument FOR separate identities, not against them).

Every API path, field name, state and setup key is verified against main
rather than carried over from the draft, which predated three waves --
the operator UI (#896), the memory ACL (#881) and the .template ingest
fix (#880) all landed after it. The one claim that could not be grounded
in code is marked as a VERIFY comment instead of asserted.

Part of #860
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant