From d4f06dd194b11a26c58ad33fc53840867dca1685 Mon Sep 17 00:00:00 2001 From: Colin Smith <7762103+colinmxs@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:59:40 -0700 Subject: [PATCH 1/2] fix(iam): grant bedrock-agentcore:GetMemory to app-api + runtime roles (#531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentCore Memory strategy discovery (MemoryClient.get_memory_strategies) calls bedrock-agentcore:GetMemory, but neither the App API task role nor the AgentCore Runtime execution role allowed it β€” every other memory data-plane action was granted. The denied GetMemory made strategy discovery fail silently: - App API: GET /memory returned empty facts/preferences with 200, so the Settings memories/preferences page rendered blank despite stored records. - Runtime: _discover_strategy_ids() failed, leaving retrieval config empty, so the agent kept writing events (CreateEvent) but never recalled long-term memories. Add bedrock-agentcore:GetMemory to the AgentCoreMemoryAccess statement on both roles (app-api scoped to the memory ARN; runtime scoped to memory/*). No other actions changed. Validated against the AWS Service Authorization Reference (GetMemory = Read on the memory resource type) and the live AccessDenied in app-api logs. Bumps VERSION to 1.0.4 + brief RELEASE_NOTES/CHANGELOG entries. Infra (IAM) change β€” deploys via the platform (CDK) pipeline. --- CHANGELOG.md | 13 ++++++++ README.md | 4 +-- RELEASE_NOTES.md | 30 +++++++++++++++++++ VERSION | 2 +- backend/pyproject.toml | 2 +- backend/uv.lock | 2 +- frontend/ai.client/package-lock.json | 4 +-- frontend/ai.client/package.json | 2 +- .../constructs/app-api/app-api-iam-grants.ts | 8 +++++ .../inference-api/inference-api-iam-roles.ts | 11 +++++++ infrastructure/package-lock.json | 4 +-- infrastructure/package.json | 2 +- 12 files changed, 73 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da6d816a..41cedb11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented in this file. Format follows For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +## [1.0.4] - 2026-07-01 + +IAM hotfix restoring AgentCore Memory. Both the App API task role and the AgentCore Runtime execution role were missing `bedrock-agentcore:GetMemory`, which `get_memory_strategies()` needs to resolve strategy IDs β€” breaking the memory dashboard (empty results) and long-term recall (retrieval silently disabled). Ships via the platform (CDK) pipeline; no migration. + +### πŸ› Fixed + +- Memory dashboard (`GET /memory`, `/memory/preferences`, `/memory/facts`) returned empty lists with a 200: `get_memory_strategies()` β†’ `bedrock-agentcore:GetMemory` was denied on the App API task role, so strategy discovery yielded no namespaces and retrieval was skipped (`app-api-iam-grants.ts`) +- Agent stopped recalling long-term memories: the runtime's `_discover_strategy_ids()` makes the same `GetMemory` call to build retrieval namespaces; the runtime execution role also lacked the action, disabling long-term retrieval while `CreateEvent` writes still succeeded (`inference-api-iam-roles.ts`) + +### πŸ—οΈ Infrastructure + +- Added `bedrock-agentcore:GetMemory` to the `AgentCoreMemoryAccess` statement on both the App API Fargate task role (scoped to the memory ARN) and the AgentCore Runtime execution role (scoped to `memory/*`); no other actions changed. Validated against the AWS Service Authorization Reference (`GetMemory` = Read on the `memory` resource type) + ## [1.0.3] - 2026-06-30 Maintenance patch: CI/CD pipeline cleanup, re-enabled path-scoped auto-deploys, and a dependency/CodeQL sweep. No application code or user-facing behavior changes; upgrade in place. diff --git a/README.md b/README.md index 5571891c..d570b3d7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **An open-source, production-ready Generative AI platform for institutions** *Built by Boise State University, designed for everyone.* -[![Release](https://img.shields.io/badge/Release-v1.0.3-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.0.4-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) [![Nightly](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml/badge.svg)](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml) ![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white) @@ -296,7 +296,7 @@ agentcore-public-stack/ See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog, including new features, bug fixes, platform upgrades, and deployment notes for each release. -**Current release:** v1.0.3 +**Current release:** v1.0.4 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4a318580..3408f96f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,33 @@ +# Release Notes β€” v1.0.4 + +**Release Date:** July 1, 2026 +**Previous Release:** v1.0.3 (June 30, 2026) + +--- + +> ⚠️ **Upgrading from a beta?** 1.0.4 is an in-place upgrade from any 1.0.x with no migration. Moving from a pre-1.0.0 beta is still the destructive backup β†’ teardown β†’ redeploy β†’ restore migration described in the [1.0.0 notes](#upgrading-an-existing-deployment). Brand-new deployments need none of this. + +--- + +## Highlights + +v1.0.4 is a one-line-per-role IAM hotfix that restores **AgentCore Memory** functionality. Both the App API task role and the AgentCore Runtime execution role granted every memory data-plane action *except* `bedrock-agentcore:GetMemory` β€” the action the SDK's `get_memory_strategies()` call requires to resolve a memory's strategy IDs. Without it, strategy discovery failed silently: the **Settings β†’ memories/preferences page came back empty** (the App API returned empty lists with a 200), and the **agent stopped recalling long-term memories** (the runtime kept writing conversation events but ran with retrieval disabled). Granting `GetMemory` on both roles fixes both symptoms. This is an **infrastructure (IAM) change**, so it deploys via the platform (CDK) pipeline. + +## πŸ› Bug fixes + +- **Memory dashboard showed no memories/preferences.** `GET /memory` calls `get_memory_strategies()` β†’ `bedrock-agentcore:GetMemory`, which the App API task role didn't allow. The call `AccessDenied`, strategy discovery returned no IDs, and the endpoint returned empty `facts`/`preferences` with a 200 β€” so the page rendered blank even though records existed. (`app-api-iam-grants.ts`) +- **Agent didn't recall long-term memories.** At session creation the runtime's `_discover_strategy_ids()` makes the same `GetMemory` call to build its retrieval namespaces; the runtime execution role also lacked the action, so retrieval was silently disabled ("long-term memory retrieval disabled") while event writes (`CreateEvent`) continued to succeed. (`inference-api-iam-roles.ts`) + +## πŸ—οΈ Infrastructure + +- Added `bedrock-agentcore:GetMemory` to the `AgentCoreMemoryAccess` policy statement on **both** roles β€” the App API Fargate task role (scoped to the memory ARN) and the AgentCore Runtime execution role (scoped to `memory/*`). No other action names changed. Verified against the AWS Service Authorization Reference (`GetMemory` is a Read action on the `memory` resource type). + +## πŸš€ Deployment notes + +This is an IAM change on `PlatformStack`, so it ships through the **platform (CDK)** pipeline, not the API-only backend path. After deploy, no data migration is needed and existing memories become visible immediately. Note the App API caches strategy discovery per process (`functools.lru_cache`) β€” a normal deploy rolls the ECS tasks and AgentCore Runtime, so the cache starts fresh; no manual restart required. + +--- + # Release Notes β€” v1.0.3 **Release Date:** June 30, 2026 diff --git a/VERSION b/VERSION index 21e8796a..ee90284c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.3 +1.0.4 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f2bb88ab..e3632cb3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.0.3" +version = "1.0.4" requires-python = ">=3.10" description = "Multi-agent conversational AI system with AWS Bedrock AgentCore" readme = "README.md" diff --git a/backend/uv.lock b/backend/uv.lock index 5af1a6a4..c7ba86b0 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.0.3" +version = "1.0.4" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index d290dec9..fce726e6 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.0.3", + "version": "1.0.4", "dependencies": { "@angular/cdk": "21.2.14", "@angular/common": "21.2.17", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index ef7f986f..64f88a38 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.0.3", + "version": "1.0.4", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/infrastructure/lib/constructs/app-api/app-api-iam-grants.ts b/infrastructure/lib/constructs/app-api/app-api-iam-grants.ts index 32fce027..ed00d88c 100644 --- a/infrastructure/lib/constructs/app-api/app-api-iam-grants.ts +++ b/infrastructure/lib/constructs/app-api/app-api-iam-grants.ts @@ -403,6 +403,14 @@ export function grantAppApiPermissions(props: AppApiIamGrantsProps): void { // do not exist as IAM actions, so the entire policy was a silent // no-op β€” the App API hit AccessDeniedException on ListEvents. actions: [ + // GetMemory is required by the memory dashboard read path: + // memory_service._get_strategy_namespaces() calls + // MemoryClient.get_memory_strategies(), which invokes GetMemory to + // enumerate the memory's strategy IDs. Without it the call + // AccessDenies, strategy discovery returns (None, None, None), and + // GET /memory returns empty preferences/facts with a 200 β€” the + // memories/preferences page renders blank even though records exist. + 'bedrock-agentcore:GetMemory', 'bedrock-agentcore:CreateEvent', 'bedrock-agentcore:GetEvent', 'bedrock-agentcore:ListEvents', diff --git a/infrastructure/lib/constructs/inference-api/inference-api-iam-roles.ts b/infrastructure/lib/constructs/inference-api/inference-api-iam-roles.ts index f4318dbf..af0f32fd 100644 --- a/infrastructure/lib/constructs/inference-api/inference-api-iam-roles.ts +++ b/infrastructure/lib/constructs/inference-api/inference-api-iam-roles.ts @@ -301,6 +301,17 @@ export function createRuntimeExecutionRole( // speculative names (CreateMemoryEvent, ListMemoryEvents, // RetrieveMemory) that don't exist as IAM actions. actions: [ + // GetMemory is required for long-term memory RETRIEVAL. At session + // creation the agent calls _discover_strategy_ids() -> + // MemoryClient.get_memory_strategies(), which invokes GetMemory to + // resolve the SEMANTIC / USER_PREFERENCE / SUMMARIZATION strategy IDs + // used to build the retrieval namespaces. Without it that call + // AccessDenies, the retrieval config is left empty, and the agent + // silently runs with "long-term memory retrieval disabled" β€” it keeps + // writing events (CreateEvent) but never recalls stored memories. + // Verified against the AWS Service Authorization Reference: GetMemory + // is a Read action on the `memory` resource type. + 'bedrock-agentcore:GetMemory', 'bedrock-agentcore:CreateEvent', 'bedrock-agentcore:GetEvent', 'bedrock-agentcore:ListEvents', diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index bfc7ce12..0e846d6a 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -1,12 +1,12 @@ { "name": "infrastructure", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "infrastructure", - "version": "1.0.3", + "version": "1.0.4", "dependencies": { "aws-cdk-lib": "2.260.0", "constructs": "10.6.0" diff --git a/infrastructure/package.json b/infrastructure/package.json index 2edef91b..ce53abaf 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "infrastructure", - "version": "1.0.3", + "version": "1.0.4", "bin": { "infrastructure": "bin/infrastructure.js" }, From b40a59e78265fd4b705cca44a50814d810df5f87 Mon Sep 17 00:00:00 2001 From: Colin Smith <7762103+colinmxs@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:48:02 -0700 Subject: [PATCH 2/2] Release/1.1.0 (#604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Validate role mappings and shorten role-cache invalidation window Adds two related guards on the role-management surface: 1. apis/shared/rbac/role_constraints.py validates jwt_role_mappings on both create_role and update_role: - Every entry must match ^[A-Za-z0-9_-]{2,64}$ (well-shaped IdP group identifiers only β€” no whitespace, HTML, control characters, etc.). - On a protected role (system_admin), entries that would map every authenticated user β€” 'default', '*', 'user', 'users', 'everyone', 'anyone', 'authenticated', 'all', 'public', etc. β€” are rejected. Violations raise RoleConstraintError (a ValueError subclass) which the existing route handler converts to HTTP 400. 2. apis/shared/rbac/version.py exposes a process-wide monotonic roles-version counter. AppRoleAdminService bumps it after every role mutation, and AppRoleCache.invalidate_all() bumps it too. The user-profile cache in apis/shared/auth/dependencies tags each entry with the current version and treats entries from older versions as stale on read. This eliminates the post-revert window where users continued to see permissions from a recently-removed role until the 5-minute TTL elapsed. Tests: - tests/rbac/test_role_mutation_constraints.py (23 tests) covers ubiquitous-mapping rejection on protected roles, format validation including a parametrized set of malformed inputs, and confirms the rule does not apply to non-protected roles. - tests/rbac/test_role_version_invalidation.py (6 tests) covers monotonicity, thread-safety under concurrent bumps, profile-cache refresh after a bump, and confirms cache.invalidate_all + role mutations both bump the version. * Apply static AST policy to user-supplied diagram and analysis code Both generate_diagram_and_validate and analyze_spreadsheet take a python_code parameter and forward it to AWS Bedrock Code Interpreter. The sandbox is isolated from the host, but legitimate use of these tools is narrow: matplotlib / numpy / pandas / seaborn for charts and dataframe operations. Anything beyond that surface area β€” subprocess, os, sys, socket, eval/exec/compile, dunder attribute walks β€” is an abuse signal regardless of intent. apis/shared/security/python_ast_policy.py adds validate_diagram_code, which parses the source with the standard ast module and walks the tree: - Imports must root in an allowlist of plotting / data-analysis packages (matplotlib, numpy, pandas, seaborn, scipy, math, statistics, json, datetime, random, collections, itertools, functools, re, decimal, fractions, csv, io). Submodules of allowed roots pass; anything else is rejected. - Bare names matching the host-process escape set (subprocess, os, sys, socket, shutil, pickle, ctypes, importlib, urllib, http, requests, httpx, asyncio, threading, multiprocessing, signal, pathlib, tempfile, etc.) are rejected wherever they appear, even with no import statement (defeats __import__('os') and aliasing). - Dunder attribute access (__class__, __bases__, __subclasses__, __builtins__, __dict__, ...) is rejected so the standard 'walk the type hierarchy' obfuscation cannot reach builtins. - Empty source, syntax errors, and sources over 32 KiB are rejected generically. Both tools call the validator at the top of their implementation, before any sandbox lookup, file resolution, or AWS API call. The check runs server-side after the LLM emits the tool call, so a user-supplied system_prompt cannot disable it. Rejections return a generic message ('Diagram code rejected by policy.' / 'Analysis code rejected by policy.') with no detail; the structural reason is logged at WARN. Tests: - 56 unit tests in tests/security/test_python_ast_policy.py covering every forbidden import, every forbidden bare name, dunder access via attribute chains, obfuscation patterns (alias assignment, string concatenation), edge cases (empty / whitespace / oversize / syntax-error / non-string), and four realistic chart-code samples. - 5 integration tests for the diagram tool confirm the sandbox is not invoked when policy fails, and that valid chart code passes the policy gate. - 3 integration tests for the spreadsheet tool confirm policy fires before file resolution, and that legitimate pandas analysis code passes. * feat(tools): add MCPGatewayConfig model for Gateway target registration (#419) (#450) Adds the protocol=mcp ("MCP Gateway (AgentCore)") config layer so an admin can register an externally deployed MCP server as a target on the centralized AgentCore Gateway. This is the backend model foundation (PR1 of 5); the live target lifecycle, infra grants, route orchestration, and form land in later PRs. - GatewayListingMode (default|dynamic), GatewayCredentialType (gateway_iam_role|oauth|api_key), and GatewayOAuthGrantType (authorization_code|client_credentials|token_exchange) enums, mirroring the bedrock-agentcore-control listingMode / credentialProviderType / grantType surfaces (uppercased at the AWS boundary by the forthcoming GatewayTargetService). - MCPGatewayConfig (mirrors MCPServerConfig) with target_name, endpoint_url, listing_mode, credential_type, credential_provider_arn, oauth_scopes, grant_type, custom_parameters, per-tool MCPToolEntry flags, and AWS-assigned target_id/gateway_arn (the catalog<->AWS link used by update/delete reconciliation). Reuses MCPToolEntry serialization. - OAuth grant_type defaults to authorization_code (3LO, on-behalf-of-user β€” the flow AWS's June-2026 Gateway auth-code blueprint documents); custom_parameters are carried because they are part of the AgentCore token-vault key and must match between target registration and token retrieval. - Co-gating validator: OAuth requires DEFAULT listing + a provider ARN; API key requires a provider ARN; gateway IAM role forbids one. Encodes the listing-mode gotcha (DYNAMIC disables 3LO + semantic search) at the model layer. - ToolDefinition.mcp_gateway_config + to_dynamo_item/from_dynamo_item round-trip under the mcpGatewayConfig key. - MCPGatewayConfigRequest/Response (+ to_model/from_model) wired into ToolCreateRequest, ToolUpdateRequest, and AdminToolResponse. - 17 model/serialization/validator tests; existing tool-model consumers green. Co-authored-by: Claude Opus 4.8 (1M context) * feat(infra): publish gateway id SSM param + app-api Gateway target IAM grants (#419) (#452) PR3 of 5 for admin-managed AgentCore Gateway target registration. Wires the infrastructure the app-api GatewayTargetService (PR #451) needs at runtime. - AgentCoreGatewayConstruct publishes /{prefix}/gateway/id as an SSM parameter (the construct header already claimed this but only emitted CFN outputs). app-api reads it at runtime via ssm:GetParameter β€” never at CFN deploy time β€” so there is no same-stack publish/consume ordering deadlock. - app-api-iam-grants adds: - SsmReadGatewayId: ssm:GetParameter on /{projectPrefix}/gateway/id. - AgentCoreGatewayTargetAccess: bedrock-agentcore:{Create,Get,Update,Delete, List}GatewayTarget scoped to the gateway resource type (arn:...:bedrock-agentcore:region:account:gateway/*). Target operations are authorized through the parent gateway ARN β€” there is no gateway-target resource type. - Action names verified against the control-plane API model and the IAM service-authorization reference (2026-06-05). Create/Get/Delete/List are in the reference; UpdateGatewayTarget is a real control-plane operation following the Update* IAM precedent in this file (UpdateOauth2CredentialProvider) but was not yet in the reference β€” included with a comment + fallback note (delete+recreate) in case any account rejects the forward-looking name. - Completes the placeholder gateway-SSM-params construct test. Gate: tsc --noEmit clean; jest constructs/security-policy/ssm-safety/platform-stack green (full stack synthesizes with the new statements; no Action:*/Resource:* violations). Pre-existing, unrelated config.test.ts failures and the bare `cdk synth` artifacts-cert lookup error reproduce on clean develop. Co-authored-by: Claude Opus 4.8 (1M context) * feat(tools): Gateway target service + admin route lifecycle (#419) (#453) Re-lands the GatewayTargetService that was stranded when #451 merged into its stacked base instead of develop, and adds the admin route + ToolCatalogService lifecycle for protocol=mcp Gateway target registration (create-AWS-first, update reconcile, hard/soft delete, 409/502 route mapping). Backend for #419 is now complete on develop; the admin form (Phase 5) remains. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(admin): Gateway target admin form for protocol=mcp (#419) (#455) Admin UI to register an externally deployed MCP server as an AgentCore Gateway target: protocol=mcp form section (target/endpoint/listing-mode/credential-type + conditional ARN/scopes/grant), per-tool approval rows with Discover-from-server, OAuth co-gating, and 502-vs-400 error handling. Completes the #419 feature on develop. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(tools): AGENTCORE_GATEWAY_ID env override for local/CI testing (#419) (#456) GatewayTargetService resolves the gateway id from an explicit value, then AGENTCORE_GATEWAY_ID, then SSM /{PROJECT_PREFIX}/gateway/id β€” letting local/CI bypass the SSM lookup. Co-Authored-By: Claude Opus 4.8 (1M context) * Bind persisted roles to JWT in profile-sync handler (#458) POST /users/me/sync now derives the persisted roles list exclusively from current_user.roles (the validated JWT claims). Roles are already populated server-side by the BFF callback's _sync_user_from_id_token off the trusted ID-token decode, and per-request RBAC reads them from the access token's verified claims plus the Users table that the callback writes β€” the request body has no role to play in this path. UserProfileSyncRequest no longer declares a 'roles' field. Pydantic's extra='allow' setting keeps the endpoint backwards-compatible with clients that still send extra keys (no 4xx; the extras surface via model_extra and are dropped when building the persisted profile). The handler logs a WARNING when it sees a legacy 'roles' key so stale clients can be tracked down. Tests: - test_sync_persists_jwt_roles_not_body_roles: body roles do not influence the persisted record. - test_sync_with_no_jwt_roles_persists_empty_list: empty JWT roles cannot be filled in by the body. - test_sync_warns_when_legacy_roles_field_present: legacy field emits the WARN log. - Plus seven baseline tests covering email normalization, name and picture pass-through, validation errors, auth requirement, and the graceful no-op when the Users repository is disabled. * feat(tools): Gateway MCP targets β€” self-service registration, agent wiring & health UX (#419) (#457) * fix(tools): correct Gateway mcpServer credential config (None + IAM service) (#419) CreateGatewayTarget rejected our GATEWAY_IAM_ROLE targets with "IamCredentialProvider is required for mcpServer targets using IAM authentication": unlike OpenAPI/Lambda targets (which accept a bare GATEWAY_IAM_ROLE), an mcpServer (HTTP endpoint) target must supply an explicit iamCredentialProvider naming the AWS service to sign for. We were sending `[{credentialProviderType: GATEWAY_IAM_ROLE}]` with no nested provider. - New credential type NONE (public endpoint) β€” omits credentialProviderConfigurations entirely (the API parameter is optional). This is now the default (least-config path; the admin opts into IAM/OAuth/API-key). - GATEWAY_IAM_ROLE now carries aws_service (required) + optional aws_region, sent as credentialProvider.iamCredentialProvider{service, region?}. Model validator requires aws_service for IAM and the create/update calls omit the credential block for NONE. - Backend: MCPGatewayConfig + request/response models + GatewayTargetService. - Frontend: 'none' credential option (default), AWS service/region fields shown for the IAM case, build/restore wiring, and the discover auth mapping unchanged (gateway_iam_role β†’ aws-iam, else none). Verified: full backend suite 3466 passed; tool-form gateway spec 6/6 (ng test). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(agents): point Gateway client at the infra-param gateway + expand #419 catalog tools (#419) The agent resolved the gateway from a hardcoded `/strands-agent-chatbot/dev/...` SSM param, while the admin form (#419) registered targets on the CDK gateway `/{PROJECT_PREFIX}/gateway/id` β€” two different gateways, so admin-registered tools never reached the agent. - New shared `gateway_identity.resolve_gateway_id`/`gateway_url_from_id`: one source of truth (explicit id β†’ AGENTCORE_GATEWAY_ID override β†’ SSM `/{PROJECT_PREFIX}/gateway/id`). The agent's `get_gateway_url_from_ssm` and GatewayTargetService both resolve through it, so they can't diverge. - Expand a `protocol=mcp` catalog tool (`gateway_class_search`) into the gateway's runtime per-tool ids (`gateway____`) in base_agent, so the FilteredMCPClient matches them; raw RBAC gateway ids (already containing `___`) pass through unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(tools): self-service Gateway MCP registration β€” per-target IAM grant, health, discovery UX (#419) Make admins able to register any same-account Lambda-URL MCP server through the form with no infra change, and surface the failures that were previously silent. Per-target Lambda grant (replaces the gateway role's standing `mcp-*` wildcard): - app-api grants the gateway role `lambda:InvokeFunctionUrl` on exactly the registered function at registration (`AddPermission` naming the role as Principal β€” a role-specific resource grant authorizes it same-account, no identity grant) and revokes it on delete. `gateway_lambda_grant.py` + GatewayTargetService lifecycle; new `MCPGatewayConfig.lambda_function_name` and a conditional "Lambda function name" form field. - Cross-account validation: `GetFunctionUrlConfig` confirms same-account + that the endpoint is under the function URL; otherwise a 400 tells the admin to go public or use a credential provider. - CDK: gateway role loses its Lambda-invoke wildcard (logs only); app-api gains scoped `AddPermission`/`RemovePermission`/`GetFunctionUrlConfig` + `GetGateway`. Gateway target health: `GET /admin/tools/{id}/gateway-status` (+ statusReasons on GatewayTargetInfo) and a Ready/Syncing/Failed/Missing badge on the tools list, so a FAILED target sync is visible instead of only "the agent can't see the tool". Discovery + form polish: unwrap the upstream HTTP status from MCP discovery errors (403 stays 403 with an actionable message; 401β†’400 to avoid the SPA logout); auto-derive awsService/awsRegion from the endpoint and recommend IAM when an AWS host is left on None. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * docs(docs-site): add API Keys + Admin section to nav, flesh out Tools page (#459) * docs(docs-site): add API Keys to Features and a new Admin section Adds an API Keys page to the Features menu, and registers a new "Admin" sidebar section (after Features) whose sub-menu mirrors the SPA admin console β€” Cost Analytics, Quotas, Fine-Tuning, Models, Tools, Connectors, Users, Roles, Auth Providers, User Menu Links, and Conversation Modes, plus an Admin Overview landing page. Pages follow the existing Draft-stub convention and are picked up via Starlight directory autogeneration. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(docs-site): flesh out Tools page and shorten its menu label Shortens the Features menu link to "Tools" via sidebar.label while keeping the full "Tools and Multi-Protocol Architecture" page title. Replaces the Draft stub with content covering the four tool protocols (Direct, AWS SDK, MCP+SigV4, A2A), the intentionally-limited default tool set, and the extension points that make it flexible by design. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * docs(specs): add admin-managed Skills with RBAC + tool binding design plan (#460) Design plan for an admin feature to author Skills (instruction bundles that bind existing catalog tools) and make them available to user roles via RBAC, leveraging the existing SkillAgent/SkillRegistry progressive-disclosure runtime. Covers data model, persistence, admin API, RBAC extensions, cross-source tool binding, cache correctness, frontend, forward-compat for user-shared skills, and a 6-PR breakdown. Design only β€” no implementation. Co-authored-by: Claude Opus 4.8 (1M context) * feat(skills): shared data layer for admin-managed skills (PR-1) (#461) Foundational, no-behavior-change PR-1 of the admin-managed Skills feature (docs/specs/admin-skills-rbac-tool-binding.md Β§4/Β§5/Β§11). Adds the DynamoDB-backed Skill catalog as a parallel of the Tools catalog; nothing consumes it yet (RBAC, admin API, frontend, runtime wiring are PR-2..PR-6). - apis/shared/skills/models.py β€” SkillDefinition (mirrors ToolDefinition): skill_id (^[a-z][a-z0-9_]{2,49}$), display_name, description, instructions, bound_tool_ids, compose, status (active/draft/disabled), category, forward-compat owner_id/visibility (ADMIN-scope in v1), computed allowed_app_roles, audit fields; snake_caseβ†’camelCase to/from_dynamo_item. - apis/shared/skills/repository.py β€” SkillCatalogRepository (mirrors ToolCatalogRepository): reuses the app-roles table (PK=SKILL#{id}, SK=METADATA); get/list/create/update/soft_delete/exists/batch_get. - apis/shared/skills/freshness.py β€” 10s-TTL per-skill freshness hash + all-skill-ids snapshot + invalidate(skill_id). - apis/shared/skills/__init__.py β€” public exports. - infra: add SkillOwnerIndex GSI (GSI4PK=OWNER#{owner_id}, GSI4SK=SKILL#{skill_id}, PAY_PER_REQUEST) to the app-roles table now to avoid a Phase-2 migration; unused in v1. - tests: model round-trip + skill_id regex, repository CRUD (moto), freshness cache; infra tables-detailed asserts the new GSI; conftest roles_table gains GSI4 + a skill_repository fixture. Import-boundary clean (apis.shared imports neither app_api nor inference_api). Co-authored-by: Claude Opus 4.8 (1M context) * feat(skills): RBAC extension β€” grant skills to roles (PR-2) (#462) PR-2 of the admin-managed Skills feature (docs/specs/admin-skills-rbac-tool-binding.md Β§4.2/Β§5/Β§7/Β§11). Extends the RBAC engine so skills are granted to AppRoles exactly like tools/models, and adds the server-side SkillAccessService. No route/frontend/runtime wiring yet (PR-3..PR-5), so no user-visible behavior change. Mirrors the tool RBAC path throughout: - rbac/models.py: EffectivePermissions.skills + AppRole.granted_skills + UserEffectivePermissions.skills (trailing default); grantedSkills/skills on the Create/Update/Response DTOs (defaulted β†’ additive, non-breaking). - rbac/admin_service.py: _compute_effective_permissions unions granted_skills across inheritance; create_role threads it; new get_roles_granting_skill / add_skill_to_role / remove_skill_from_role. - rbac/service.py: _merge_permissions unions skills (with "*"); new can_access_skill / get_accessible_skills. - rbac/repository.py: write SKILL_GRANT#{id} reverse-lookup items reusing the GSI2 keyspace with a disjoint SKILL# partition (no new GSI); get_roles_for_skill. - rbac/seeder.py + scripts/seed_bootstrap_data.py: system_admin gets the skill wildcard (granted_skills=["*"] + SKILL_GRANT#*); default stays gated ([]). - app_api/admin/services/skill_access.py: SkillAccessService mirroring ToolAccessService (no gateway_* passthrough β€” skills have no dynamic form). Tests: skill resolution/wildcard/inheritance/reverse-lookup, access-service filtering, repo round-trip + tool↔skill partition non-collision + stale-grant cleanup, bootstrap SKILL_GRANT#* assertions, make_app_role factory extended. Full suite: 3571 passed, 3 skipped. Import-boundary clean. Co-authored-by: Claude Opus 4.8 (1M context) * feat(skills): admin API for skill authoring + role grants (PR-3) (#463) PR-3 of the admin-managed Skills feature (docs/specs/admin-skills-rbac-tool-binding.md Β§6/Β§11). Adds the /admin/skills REST surface + SkillCatalogService, mirroring app_api/admin/tools/routes.py and app_api/tools/service.py. Net-new admin-only routes; no frontend (PR-4) or runtime (PR-5) consumer yet, so no behavior change to existing surfaces. - apis/shared/skills/models.py: admin request/response DTOs mirroring the tool equivalents (SkillCreate/UpdateRequest, SkillRoleAssignment, SkillRolesResponse, Set/AddRemoveSkillRolesRequest, AdminSkillResponse + from_skill_definition, AdminSkillListResponse). - apis/shared/skills/repository.py: add hard delete_skill (PR-1 had only soft_delete_skill; the ?hard= flag needs it). - apis/app_api/skills/service.py (new): SkillCatalogService β€” CRUD, _validate_bound_tools (batch-get vs the tool catalog; reject unknown/non-active), allowedAppRoles hydration, bidirectional role sync onto granted_skills. - apis/app_api/admin/skills/routes.py (new): /admin/skills CRUD + /roles endpoints; ValueError->400, missing->404, freshness invalidation on writes. - apis/app_api/admin/routes.py: register the subrouter under /admin. Tests (moto-backed): service CRUD + bound-tool validation + role sync + hydration; TestClient route tests for status codes, camelCase shape, bound-tool 400, and a /roles round-trip. One moto app-roles table backs SKILL#/TOOL#/ROLE#. Full suite: 3606 passed, 3 skipped. Import-boundary clean. Co-authored-by: Claude Opus 4.8 (1M context) * docs(specs): re-scope admin Skills to reference material; defer scripts (#464) Adds a Β§0 revision (2026-06-09) to the admin-managed Skills plan capturing the direction agreed with the product owner: - Three drivers, reordered: tool-hiding UX (primary) > token efficiency > ecosystem-compatible authored expertise. Skills and assistants are distinct primitives (skill = bound tools for an outcome; assistant = a knowledge- grounded "Project"), not redundant. - New skill bundle shape: SKILL.md instructions + supporting reference files (read-only, progressive disclosure) + bound catalog tools. - Scripts + code execution DEFERRED (out of scope) β€” removes the code-exec risk; imports bring knowledge faithfully, behavior is re-expressed via bound catalog tools. - Import-prefill of a SKILL.md/folder/zip is now in scope (asset ingestion, not live discovery; tool bindings stay manual). - Re-sequenced remaining phasing: PR-4 reference-file data layer (S3-backed resource store) -> PR-5 frontend incl. import -> PR-6 runtime incl. a read-reference-file disclosure level + cross-source bind resolver -> PR-7 default flip. - Inline updates to non-goals, data model (resources manifest), persistence (S3 skill-resources bucket), phasing, and risks. PR-1..3 marked merged. Co-authored-by: Claude Opus 4.8 (1M context) * feat(skills): S3-backed reference-file data layer for admin Skills (PR-4) (#465) Adds the data + API layer for a skill's supporting reference files (read-only markdown/resources for deep progressive disclosure). Bytes live in a new content-addressed S3 bucket; the DynamoDB SkillDefinition row carries only a lightweight `resources` manifest (400 KB item limit). No runtime or frontend consumes them yet β€” zero behavior change. - models: SkillResourceRef + resources manifest on SkillDefinition, round-tripped through to/from_dynamo_item (camelCase, backward-compatible) - shared: SkillResourceStore (content-hash keyed, dedupe, graceful degradation), mirroring the artifacts/MCP-apps persistence patterns - service: upload/list/read/delete resource CRUD with filename/size/count validation, atomic manifest update, orphaned-object GC - api: /admin/skills/{id}/resources endpoints (require_admin) - cdk: SkillResourcesConstruct bucket, threaded via PlatformComputeRefs; app-api read/write + inference-api read-only (PR-6 forward-compat) - tests: model round-trip, moto store, TestClient endpoints, infra jest Spec: docs/specs/admin-skills-rbac-tool-binding.md Β§0.5 (PR-4) Co-authored-by: Claude Opus 4.8 (1M context) * feat(skills): admin frontend for authoring skills + reference files (PR-5) (#466) Adds the admin/skills/ Angular feature, mirroring admin/tools/: list page, create/edit form, bound-tool picker, role-grant dialog, and management of a skill's S3-backed reference files (PR-4 data layer). Import-prefill ingests a SKILL.md (frontmatter -> id/name/description, body -> instructions); tool bindings are picked manually and scripts are out of scope. - models: admin-skill.model.ts (AdminSkill, SkillResourceRef, request DTOs, SKILL_STATUSES/CATEGORIES) + skill-import.util.ts (parse SKILL.md + slugify) - service: AdminSkillService (resource()+signals CRUD, role sync, reference-file list/upload/read/delete) - pages: skill-list (filters, expandable rows, tool/ref-file/role badges, role dialog, soft delete) + skill-form (reactive form, tool picker, reference-file upload/view/delete + inline authoring, SKILL.md import) - components: skill-role-dialog (copy of tool-role-dialog) + tool-picker-dialog (multi-select active catalog tools) - routes: skills / skills/new / skills/edit/:skillId; "Skills" admin nav link - tests: skill-import.util.spec + admin-skill.service.spec (ng test) No backend changes. Spec: docs/specs/admin-skills-rbac-tool-binding.md Β§0.5 (PR-5) Co-authored-by: Claude Opus 4.8 (1M context) * feat(skills): runtime β€” DB-backed load + RBAC + skills_hash + local binding (PR-6a) (#467) Opt-in (agent_type="skill"); the default "chat" path is unchanged. First slice of the skills runtime wiring: admin-authored skills now load for the right users, fold their local tools behind the meta-tools, and cache correctly. Gateway/external MCP tool folding + the read-reference-file level + an example seed are deferred to PR-6b; the default flip to PR-7. - registry: load_records() DB source (inline instructions + bound_tool_ids + compose + resources), DB-aware load_instructions, all_bound_tool_ids, bind_catalog_tools (resolve bound catalog ids -> live objects). File scan unchanged. - skill_tools: make_skill_tools(registry) β€” per-agent (dispatcher, executor) closures, fixing a latent concurrency bug (module-global registry would cross-pollute concurrent per-user skills). Module-level fns kept for dev. - skill_agent: DB discovery + enabled_tools augmentation (skill-as-grant, all sources) before super().__init__; bind/fold local bound tools; degrade to ChatAgent at 0 skills; restore original enabled_tools in the construction snapshot for resume cache parity. - chat: route resolves accessible skills via shared AppRoleService (get_accessible_skills, "*" -> get_all_skill_ids), threads them into every get_agent call; get_agent adds skills_hash to _create_cache_key (digest of accessible ids + their updated_at). Empty for chat -> key unchanged. - tests: registry DB source/binding, make_skill_tools isolation, skills_hash cache separation + chat path unaffected, _fetch_skill_records filtering. Spec: docs/specs/admin-skills-rbac-tool-binding.md Β§0.5 (PR-6), Β§8 Co-authored-by: Claude Opus 4.8 (1M context) * feat(skills): runtime MCP-tool folding + reference-file disclosure + example seed (PR-6b) (#468) Runtime increment after PR-6a (#467). All opt-in via agent_type="skill"; the default "chat" path is unchanged, so zero default behavior change. 1. Fold gateway/external MCP tools behind the meta-tools. PR-6a folded only LOCAL callables; gateway/external tools materialize as *client objects* (one MCPClient exposing many tools), so they were available but not hidden. New mechanism: - mcp_tool_folding.py: a per-client fold set; both FilteredMCPClient and UICapableMCPClient drop folded names from list_tools_sync (the seam Strands uses to build the model tool list). Setting a fold also invalidates the client's _loaded_tools cache so Strands re-lists with the fold applied (external clients are pre-flighted before folds are known). - mcp_binding.py: resolve_mcp_bindings maps each non-local bound id to its concrete MCP tool(s) + owning client (gateway via expand_gateway_tool_ids from the catalog, no session; external by enumerating the live client), wrapping each as a FoldedMCPTool. The client object stays in the agent's tool list (Strands keeps its session alive); skill_executor runs folded tools through MCPClient.call_tool_sync. - SkillAgent._bind_mcp_tools wires it after the existing local binding. 2. Read-reference-file progressive-disclosure level. skill_dispatcher's previously-unused `reference` arg now serves a skill's supporting reference files from the PR-4 S3 SkillResourceStore on demand; the no-arg dispatch response lists available filenames so the model knows what it can read (mirrors how a real SKILL.md body names its files). 3. Bootstrap example-skill seed. seed_example_skills seeds one demonstrable bundle: instructions + a bound local tool (fetch_url_content) + an S3-uploaded reference file, granted to the default role. Mirrors seed_default_tools; the skill-resources bucket name is derived in seed.sh from the project prefix. Spec: docs/specs/admin-skills-rbac-tool-binding.md (Β§0.5, Β§8, Β§12). Full backend suite: 3711 passed, 3 skipped. Co-authored-by: Claude Opus 4.8 (1M context) * feat: per-tool MCP enablement (skills binding + model settings) (#469) * feat(tools): scoped tool-id helpers + agent runtime resolution A bare catalog tool id still means the whole MCP server; a scoped `toolId::name` selects one tool of it. Adds apis/shared/tools/scoped_ids.py as the single source of truth and resolves scoped ids at the runtime seam: the tool-filter classifies by base id, expand_gateway_tool_ids emits only the chosen gateway____, and external MCP clients pass the SDK-native tool_filters allow-list (matched on the raw mcp tool name). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(tools): validate and persist per-tool selections Skill bound_tool_ids and user tool preferences accept scoped ids: the base catalog tool must exist + be active, and (when the server has a curated list) the named tool must be one it exposes. GET /tools/ now surfaces each MCP server's tools via UserToolAccess.serverTools, each with its effective enabled state (scoped pref -> server-level pref -> catalog default). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(tools): live discover-tools endpoints for saved MCP servers Adds POST /admin/tools/{id}/discover (admin, skills picker) and POST /tools/{id}/discover (session auth + RBAC, model settings) so per-tool selection works for servers whose tools aren't enumerated in the catalog. External servers are listed live (OAuth-3LO falls back to the curated list); gateway targets return the tools recorded at registration. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(admin): per-tool selection in the skills tool picker The Bind Tools dialog now expands an MCP server into its individual tools with per-tool checkboxes (parent shows all/partial/none), emitting scoped ids in boundToolIds; servers with no curated list get a live Discover tools action. Bound-tool chips render as "Server Β· tool". Adds the frontend scoped-tool-id helper mirroring the backend. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(settings): per-tool toggles in model settings An MCP server row in model settings expands into per-tool switches; toggling the server toggles all, toggling a tool switches the server to a subset. enabledToolIds emits scoped ids for a partial server and the bare id when all tools are on. Servers with no enumerated tools offer a live Discover tools action. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * feat(skills): flip default agent_type to "skill" (PR-7) (#470) Final increment of the admin-managed Skills feature. Stacked on PR-6b (#468). The server now defaults a user turn to the SkillAgent instead of ChatAgent (routes.DEFAULT_AGENT_TYPE = "skill"). A user with zero accessible skills gets a SkillAgent that degrades to plain ChatAgent behavior, so the flip is a no-op for them; a user whose roles grant a skill gets that skill's bound tools folded behind the two meta-tools (PR-6a/6b). Clients opt out per turn with agent_type="chat". Implementation: - routes.py: DEFAULT_AGENT_TYPE constant; the invocations() handler computes effective_agent_type = input_data.agent_type or DEFAULT_AGENT_TYPE and uses it for skill resolution + the three non-resume get_agent calls. - The resume get_agent call gates accessible_skill_ids on the snapshot's type (snapshot.agent_type == "skill"), so a turn explicitly built as "chat" that pauses on an interrupt rebuilds the SAME cache key on resume (empty skills_hash) instead of orphaning the paused agent. - service.get_agent keeps a conservative "chat" fallback for direct callers; the request-policy default lives in the route. SPA omits agent_type β†’ gets "skill". toolTokens is measured post-deploy via the context-attribution contextBreakdown partition (folded bound tools drop out of the tools partition; the skill catalog moves into the system partition). Full backend suite: 3712 passed, 3 skipped (1 pre-existing flaky PBT test). Co-authored-by: Claude Opus 4.8 (1M context) * feat(agent): re-enable Strands Bedrock auto prompt caching (#471) Uncomment the cache_config block in ModelConfig.to_bedrock_config() so caching_enabled=True now emits CacheConfig(strategy="auto"). Strands places cache points per-model and no-ops with a warning for models that don't support automatic caching, so this is safe to set unconditionally on the Bedrock path. The prior deferral was for the strands PR #1438 blocker (cachePoint blocks colliding with non-PDF document attachments), resolved in strands-agents 1.39.0 β€” we pin 1.40.0. Update the test that asserted the key was omitted to assert CacheConfig(strategy="auto") is present. Co-authored-by: Claude Opus 4.8 (1M context) * docs(env): document missing backend env vars in .env.example (#472) Cross-referenced the env vars set by the CDK constructs (per tests/supply_chain/test_env_var_contract.py) against what the Python code reads, and added the gap to backend/src/.env.example with placeholder values and explanatory comments matching the existing style. Added (17 vars): - Core wiring: PROJECT_PREFIX, AGENTCORE_RUNTIME_WORKLOAD_NAME, INFERENCE_API_URL, LOG_LEVEL - AgentCore: MEMORY_ARN, AGENTCORE_MEMORY_TYPE, BROWSER_ID, AGENTCORE_LOCAL_OAUTH_CALLBACK_URL - DynamoDB: DYNAMODB_API_KEYS_TABLE_NAME, DYNAMODB_USER_MENU_LINKS_TABLE_NAME - New "Admin-managed Skills" section: S3_SKILL_RESOURCES_BUCKET_NAME - New "Fine-tuning" section: FINE_TUNING_ENABLED, DYNAMODB_FINE_TUNING_ACCESS_TABLE_NAME, DYNAMODB_FINE_TUNING_JOBS_TABLE_NAME, S3_FINE_TUNING_BUCKET_NAME, FINE_TUNING_DEFAULT_QUOTA_HOURS, SAGEMAKER_EXECUTION_ROLE_ARN, SAGEMAKER_SECURITY_GROUP_ID, SAGEMAKER_SUBNET_IDS Intentionally excluded: render-Lambda-internal aliases (ARTIFACTS_BUCKET, ARTIFACTS_TABLE, RENDER_TOKEN_SECRET_ARN, CSP_SCRIPT_SRC, FRAME_ANCESTOR_ORIGIN), the AWS-injected AWS_DEFAULT_REGION, and legacy local-only cruft that nothing reads. Co-authored-by: Claude Opus 4.8 (1M context) * feat(settings): platform chat-mode settings foundation (skills-mode PR-1) (#473) Adds the admin-managed chat-mode policy that the skills-mode feature (docs/specs/skills-mode.md) builds on: which agent mode (skill/chat) new conversations default to, and whether users may toggle between modes. - New apis/shared/platform_settings/ domain β€” ChatModeSettings stored as a SYSTEM_SETTINGS#chat-mode sentinel item in the auth-providers table (the existing first-boot convention; zero CDK changes since both app-api and the inference runtime already have the table env + IAM), with a 60s TTL-cached service for the per-turn inference read. - GET/PUT /admin/settings/chat (require_admin) with audit stamping. - GET /system/chat-settings β€” SPA-facing policy read (session auth). - Defaults reproduce current behavior (skill default, toggling allowed), so an unconfigured environment sees no change. Nothing consumes the policy yet β€” enforcement lands in PR-2. Co-authored-by: Claude Fable 5 * add time info to user messages on hover * feat(skills): user skills surface + chat-mode policy enforcement (skills-mode PR-2) (#474) Backend half of skills mode (docs/specs/skills-mode.md): users get a listable, per-skill-toggleable view of their RBAC-granted skills, and the admin chat-mode policy from PR-1 (#473) is now enforced on /invocations. - New GET /skills/ + PUT /skills/preferences (session auth) β€” ACTIVE RBAC-accessible skills with per-user prefs merged, mirroring the tools endpoints. Prefs stored as USER#{id}/SKILL_PREFERENCES in the skills table (UserSkillPreference, mirrors UserToolPreference). - Shared resolver apis/shared/skills/access.py β€” single source of truth for "which skills does this user get" used by both app-api and the inference path (the routes helper stays as a thin test seam). - InvocationRequest.enabled_skills β€” None = all accessible (back-compat); a list is intersected server-side with the RBAC set (narrow, never grant); the effective set feeds SkillAgent AND the skills_hash cache key, so toggle changes can't cross-pollute cached agents. - Chat-mode policy enforcement: effective agent_type now resolves through the admin settings (TTL-cached). When toggling is disabled the client's skill/chat choice is overridden server-side; voice and other internal types bypass the policy. DEFAULT_AGENT_TYPE is now aliased to the policy model's compiled-in default so they can't drift. - PausedTurnSnapshot.enabled_skills β€” paused skill turns persist their effective skill set so resume rebuilds the same cache key even if the user toggles skills mid-pause; legacy snapshots fall back to request-time resolution. Full backend suite: 3808 passed, 3 skipped. Co-authored-by: Claude Fable 5 * chore(kaizen): weekly research scan 2026-06-12 (#475) Generated by the kaizen-research skill. Top 5 ideas appended to docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning. Co-authored-by: Claude Sonnet 4.6 * feat(spa): skills mode UX β€” mode toggle, skills picker, request wiring (skills-mode PR-3) (#476) * feat(spa): skills mode UX β€” mode toggle, skills picker, request wiring (skills-mode PR-3) The user-facing half of skills mode (docs/specs/skills-mode.md), on top of the #474 backend: - New SkillService (mirrors ToolService): loads GET /skills/, per-skill optimistic toggles persisted via PUT /skills/preferences. - New ChatModeService: admin policy from GET /system/chat-settings (compiled-in default on failure), effective mode precedence = policy lock > local selection > user preferred mode > admin default. Toggling persists preferredAgentMode (user settings) and the session's agentType so a conversation reopens in the mode it was using. - Model-settings panel: Skills/Tools segmented control (hidden when the admin disallows toggling), Skills toggle section (visual twin of the Tools section, with empty state) in skills mode, Tools section in tools mode. - Chat request: sends agent_type each turn; skills mode sends enabled_skills and enabled_tools=[] (capabilities come from skills); assistant turns are excluded and keep pre-skills-mode behavior. - Session page hydrates the stored mode from session preferences, mirroring the lastModel pattern. - Backend (additive): SessionPreferences.agent_type (validated to skill|chat, merge-safe in the metadata PUT) and user settings preferredAgentMode. Frontend: 1215 tests green (ng test) + production build clean. Backend: full suite green (two pre-existing PBT tests flaked under load in an unrelated area β€” session-manager truncation β€” and pass clean in isolation and on file re-run). Co-Authored-By: Claude Fable 5 * fix(spa): silence the error dialog for the mode toggle's best-effort persist Toggling skills/tools mode persists preferredAgentMode via PUT /users/me/settings, which fails loud (503, #161 behavior) when user-settings storage isn't configured. The toggle already applied in-memory, so the background persist now opts out of the global error toast via the existing SUPPRESS_ERROR_TOAST context token; explicit settings-page saves stay loud. Failure still logs to console. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 * fix(agents): raise OAuth consent interrupt for skill-folded external MCP tools (#477) In skills mode a skill-bound external MCP tool executes through the skill_executor meta-tool, so OAuthConsentHook's provider_lookup (keyed on the selected tool being an MCPAgentTool) resolved nothing and the consent gate silently never fired: the tool ran tokenless, returned an auth error as text, and the model apologized instead of the turn pausing with an oauth_required interrupt. - OAuthConsentHook: optional tool_use_provider_lookup second-chance resolver (gate + 401-retry handler) consulted when provider_lookup returns None - mcp_binding: make_folded_tool_provider_lookup maps the executor's tool_use input (skill_name/tool_name) -> bound FoldedMCPTool -> owning client -> provider id; gateway clients resolve to None (SigV4, not user OAuth) - FoldedMCPTool.invoke now returns a ToolResult-shaped error on failure so the error status survives the fold and the consent hook's 401-retry heuristic (gated on status == "error") can fire for folded tools - SkillAgent wires the resolver over its registry via the new BaseAgent._build_tool_use_provider_lookup hook point (None for chat) The resume path is unchanged: the interrupt is raised on the skill_executor call itself, so consent -> interrupt_responses -> cache warm -> the client's lazy token provider picks up the token. Co-authored-by: Claude Fable 5 * fix(agents): gate skill-folded external MCP tools behind the approval prompt (#478) An admin's needs_approval=True flag was silently bypassed in skills mode: the flagged tool runs behind the skill_executor meta-tool, so the approval hook's selected_tool lookup resolved nothing and the tool_use name never matched. Mirror the OAuth consent fold fix β€” an optional tool_use-based second-chance lookup resolves the folded target via the SkillRegistry, checks it against the owning client's needs_approval set, and raises the same tool_approval_required interrupt with the inner tool's name + args so the dialog describes the real tool, not the executor. Co-authored-by: Claude Fable 5 * feat: add Amazon Bedrock Mantle as a model provider (#479) * feat(models): add Bedrock Mantle as a model provider Mantle is AWS's OpenAI-compatible inference surface for Bedrock-hosted open-weight models (qwen, gpt-oss, gemma, deepseek, ...). It is a distinct provider from `bedrock` because it speaks the OpenAI wire protocol with a short-term bearer token rather than the Converse API with SigV4. What this adds: - apis/shared/bedrock/bearer_token.py: SigV4-presigned `CallWithBearerToken` bearer token (ported inline from aws-bedrock-token-generator, no new dep) plus region+path base-URL helper. - GET /admin/mantle/models: browse the live regional Mantle roster via the OpenAI SDK (seeds the escape-hatch form's model-id suggestions). - ModelProvider.MANTLE end to end: model_config translation, agent_factory builds a Strands OpenAIModel against the Mantle base URL, BaseAgent + inference chat pipeline thread the value through. - mantle_endpoint_path on the managed-model shape, persisted and resolved server-authoritatively in _resolve_model_settings. Mantle serves different models on different paths (/v1 vs /openai/v1) and exposes NO API to discover which, so the path is recorded per model (from the model card) rather than probed or mapped. Carried through the paused-turn snapshot so a resumed Mantle turn rebuilds the same base URL. Caching is intentionally left off for Mantle: prompt caching on Bedrock is model-bound to Anthropic Claude + a few Amazon Nova models, none of which run through the Mantle provider. Requires `bedrock-mantle:*` IAM (separate infra commit) and Mantle being enabled for the account/region. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(infra): grant bedrock-mantle IAM for Mantle browse + inference Bedrock Mantle has its own IAM service namespace β€” `bedrock-mantle:*`, NOT `bedrock:*`. Mirror the AWS-managed AmazonBedrockMantleInferenceAccess policy: - AgentCore runtime role: `bedrock-mantle:CreateInference` + `Get*`/`List*` on `project/*`, plus `bedrock-mantle:CallWithBearerToken` (mantle-provider inference). - App-API task role: read-only `Get*`/`List*` + `CallWithBearerToken` for the GET /admin/mantle/models browse endpoint. The token signer is authorized against this namespace; without it inference returns an IAM denial even when Mantle is enabled for the account. Integration test asserts both roles carry CallWithBearerToken and the runtime carries CreateInference. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(spa): curated Bedrock Mantle catalog + endpoint-path form Add a "Bedrock Mantle" tab to the admin model catalog, mirroring the Bedrock tab: curated cards with vetted, human-reviewed settings (pricing, modalities, context, and the all-important endpoint path baked in). No probing, no magic. - CURATED_MANTLE_MODELS seeded with Qwen3 Coder 30B (/v1) and Gemma 4 31B (/openai/v1). Pricing verified against the AWS Bedrock pricing page (2026-06); modalities/capabilities/context/path verified against each model card (Gemma 4: text+image+video in, text out, reasoning + tool use + vision). - Model shape gains `mantleEndpointPath`; the model form becomes Mantle-aware: a /v1 vs /openai/v1 selector (with model-card guidance), caching controls hidden (Mantle open-weight models don't cache), and a model-id datalist seeded from the live GET /admin/mantle/models roster for off-catalog adds. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * Bind persisted email to the validated session in profile-sync handler POST /users/me/sync now derives the persisted email from current_user.email (the JWT-bound value populated by the BFF callback at login time) instead of the request body. Display fields (name, picture) still come from the body; the field set on UserProfileSyncRequest is reduced to those plus provider_sub. Pydantic's extra='allow' setting keeps the endpoint backwards- compatible with clients that still send legacy keys ('roles', 'email') β€” they surface via model_extra and are dropped when building the persisted profile. The handler logs a single WARNING listing every legacy field it observed so stale clients can be tracked down. A session whose JWT has no email claim now returns 422 rather than persisting an empty row. Tests: - test_sync_persists_jwt_email_not_body_email: a body-supplied email does not influence the persisted record. - test_sync_email_domain_derived_from_jwt: the derived email_domain also tracks the JWT, not the body. - test_sync_jwt_email_normalized_to_lowercase: persisted email is lowercased regardless of the JWT casing. - test_sync_missing_jwt_email_returns_422: missing JWT email refuses the upsert. - test_sync_warns_when_legacy_fields_present: combined warn covers both 'roles' and 'email' in body. - test_share_access_email_match (7 tests): locks in that ShareService._check_access compares the share's allowed_emails list against requester.email only β€” case-insensitive, with owner override and access_level=public/specific semantics covered. * Wrap user-supplied system prompts with a platform safety floor SystemPromptBuilder.from_user_prompt now assembles every prompt as: PLATFORM_SAFETY_FLOOR The floor states that text inside the user_instructions tag is advisory, that tool-input policies are enforced server-side and cannot be coerced, that the code-execution surface accepts only chart / dataframe code, and that identity claims come from the validated session rather than the prompt. Embedded user_instructions tags in the user portion are stripped before assembly so a caller cannot close the wrapper and write text below the floor. User-supplied prompts are bounded: - SystemPromptBuilder truncates the user portion to MAX_USER_PROMPT_LENGTH (8 KiB) before assembly. - InvocationRequest.system_prompt rejects payloads larger than MAX_USER_SYSTEM_PROMPT_CHARS (also 8 KiB) at the API boundary so oversized inputs surface as a 4xx instead of being silently truncated. The diagram-tool input policy added in apis/shared/security/python_ast_policy.py remains the authoritative enforcement against arbitrary code execution; this branch is structural defense-in-depth at the prompt boundary. Tests: - test_system_prompt_safety_floor.py (10 new tests) covers floor precedence over user portion, behaviour for empty/None user prompts, resistance to wrapper-tag injection (closing or opening), length truncation, exact-limit pass-through, and the API request boundary's oversize rejection / valid-size acceptance / None-allowed contracts. - test_system_prompt_builder.py existing TestFromUserPrompt updated from 'user prompt becomes the entire base prompt unchanged' to 'assembled prompt starts with the floor and contains the user text inside the wrapper tags'. * Route fetch_url_content through the shared URL validator The fetch_url_content tool now runs every URL through the apis.shared.security.url_validator helper before opening any HTTP connection. The validator rejects loopback, link-local, RFC1918, ULA, multicast, reserved, unspecified, CGNAT, and cloud metadata-service addresses, and resolves every DNS answer to defeat host-name rebinding to a private result. Redirect handling is now manual. The httpx client is constructed with follow_redirects=False; the tool walks the redirect chain itself, applying the same validator to each Location target before re-fetching. Up to three redirects are followed; a 3xx without a Location header falls through to the standard response path. Redirect targets that fail the validator surface a generic 'Redirect target is not permitted.' error to the caller. Tests: - 8-case parametrized rejection sweep covering 169.254.169.254, fd00:ec2::254, 127.0.0.1, RFC1918 ranges, [::1], and 0.0.0.0, asserting that AsyncClient is never constructed when validation fails (ergo no network I/O). - DNS-resolution rebinding: a host name whose only A record is private is rejected with no client construction. - Disallowed scheme (file://) rejected without network I/O. - Generic error contract: rejection message contains no resolved address or 'metadata' label. - Public URL passes the validator and reaches the network layer. - Redirect handling: the constructed client must use follow_redirects=False, and a 302 to 169.254.169.254 results in exactly one outbound request (the original) β€” the tool never follows to the metadata IP. * Reject session-metadata PUT when session id is owned by another user The PUT /sessions/{session_id}/metadata handler now consults a new session_exists_for_other_user() helper before taking the 'session-not-found-for-this-user β†’ create new' branch. The helper queries the SessionLookupIndex GSI directly (without filtering on userId) and reports whether a row exists for the given session id under any user. When that returns True for a non-owner, the PUT returns 404 β€” matching GET's behaviour and avoiding an enumeration oracle. The existing per-user fetch (get_session_metadata) already returns None for non-owners because of how its DynamoDB query is partitioned; the new helper closes the gap that None has two distinct meanings: genuinely empty vs. taken-by-someone-else. Tests: - TestUpdateSessionMetadataOwnership (4 tests): - 404 when the session id is taken under a different user, and the write helper is never called. - The existence-check verdict alone is enough to block the write even when the per-user fetch returned None. - Genuinely fresh session ids still create normally. - Owner updates skip the existence check entirely (the per-user fetch already returned the record). - Two existing 'create-new' tests updated to mock the new helper. * Scope SigV4 signing on outbound MCP requests to recognized AWS endpoints detect_aws_service_from_url now returns Optional[str]: a recognized service name for Lambda Function URLs, API Gateway, and AgentCore Gateway hosts, and None for everything else. The previous fallback to 'lambda' for unrecognized hosts meant the SigV4 signer would attach task IAM credentials (Authorization: AWS4-HMAC-SHA256 ... + X-Amz-Security-Token) to outbound requests destined for arbitrary hosts. create_external_mcp_client treats the None return as a refusal: when auth_type=aws-iam and the server URL doesn't match a known AWS service, the function returns None instead of constructing a SigV4-wired client. The /admin/tools/discover route already maps a None client to a 400 with a generic message, so the discovery endpoint's contract for arbitrary-URL admin discovery still holds for non-signing auth modes (auth_type=none / oauth2 / etc.) β€” only the credential-bearing AWS IAM path is scoped down. Tests: - test_mcp_sigv4_scope.py (14 tests): - 4 service-detection cases for legitimate AWS hostnames (Lambda Function URL, API Gateway, AgentCore Gateway). - 7 None-return cases for non-AWS hosts including hostname look-alikes (amazonaws.com.attacker.com, lambda-url.fake.com, lambda-url-lookalike.example.com). - aws-iam against a non-AWS URL refuses client construction. - aws-iam against an AWS URL constructs normally. - Non-signing auth modes (auth_type=none) against arbitrary URLs still construct, since no credentials are at stake. - Existing test_defaults_to_lambda_for_unknown_url replaced with test_returns_none_for_unknown_url to reflect the new contract. * fix(backup/restore): include S3 Vectors index in the snapshot loop (#481) The assistants RAG knowledge base is backed by an AWS::S3Vectors::Index ('rag-vector-index-v1') that lives in a separate AWS service from regular S3 β€” the s3vectors boto3 client / AWS::S3Vectors::* CFN types. 'aws s3 sync' cannot reach it, list_objects_v2 doesn't see it, and nothing in backup.py or restore.py was touching it. The result: after teardown -> redeploy -> restore, the new vector index was empty, every assistant's knowledge base appeared connected (DDB document metadata restored, S3 originals restored) but every retrieval call returned zero hits because no vectors existed for any assistant_id. This change closes that gap with the same 'snapshot and replay' model the rest of the restore tool uses. backup.py - new VECTOR_INDEXES list (parallel to S3_BUCKETS) - new backup_vector_index() that paginates s3vectors.list_vectors with returnData=True + returnMetadata=True and streams every record to vectors/{logical}.jsonl.gz in the backup bucket. The line format ({key, data, metadata}) is byte-compatible with s3vectors.put_vectors on restore. - run() iterates VECTOR_INDEXES after the regular S3 buckets pass restore.py - new VECTOR_INDEXES constant mirroring backup.py - new restore_vector_index() that reads the gzipped JSONL, batches records 50-at-a-time (matching bedrock_embeddings.store_embeddings _in_s3 BATCH_SIZE), and calls s3vectors.put_vectors. Idempotent on re-run (put_vectors with same key is upsert), skips cleanly on older backups that pre-date the vectors snapshot, and skips cleanly on target prefixes where RAG is disabled. - run_restore() runs the vector restore step after the S3 buckets pass and before the AgentCore Memory replay tests - tests/supply_chain/test_backup_coverage.py adds TestBackupCoversVectorIndexes β€” 5 assertions that scan the CDK constructs for AWS::S3Vectors::Index resources and verify backup.py declares them, calls list_vectors with returnData/returnMetadata, and restore.py both defines AND wires in restore_vector_index. Same canary pattern that already covers DynamoDB tables and regular S3 buckets. - tests/supply_chain/test_backup_coverage.py: extract_backup_tables is now section-scoped so its 'logical' regex stops slurping entries from S3_BUCKETS or the new VECTOR_INDEXES. Drive-by fix. - scripts/restore-data/test_restore.py adds 7 behavioral tests for restore_vector_index covering: 1:1 round-trip with batch-of-50 flushing, missing backup file skip, missing target SSM skip, dry-run, idempotent re-run, unknown logical name skip, and the rag-vectors logical-name pin. All 48/48 restore-data tests pass; all 5/5 new vector coverage tests pass. The 3 pre-existing supply-chain failures (system-prompts table and skill-resources bucket from prior feature PRs) are untouched and unrelated. NOTE: the existing affected forker still has an empty vector index post-restore. This PR does not include a one-off recovery script β€” that's intentionally scoped as a separate task per the diagnosis discussion. Co-authored-by: Colin * feat(docs): add full-screen maintenance page (#482) Adds a standalone, directly-navigable maintenance splash at /maintenance for use during migration downtime. It lives in src/pages/ (outside Starlight's content collection), so it never appears in the sidebar/nav and renders without Starlight chrome β€” reachable only by navigating to the URL directly. Self-contained markup + styles (works even if nothing else loads) matching the docs/SPA house style: Boise blue, frosted glass, lava-lamp blob field, graph-paper grid, Bricolage display type, a pulsing Bronco-orange status orb, and an indeterminate progress shimmer. Responsive, honors prefers-reduced-motion, and carries noindex/nofollow so crawlers skip it. Co-authored-by: Claude Opus 4.8 * feat(docs): remove action buttons from maintenance page (#483) The maintenance splash (added in #482) is purely informational, so the "Try again" reload button and "Status & updates" link add no real value on a static page. Drop both buttons along with the now- unused click handler script and the .actions/.btn CSS, leaving the status pill, headline, message, progress shimmer, and sign-off. Co-authored-by: Claude Opus 4.8 * Tighten admin-route input handling and data-layer ownership edges (#484) A handful of related cleanups across admin and data-edge routes that share a common shape: each one either accepted ill-formed input that went on to crash a downstream call, leaked upstream error detail in 500 bodies, or didn't verify ownership before mutating shared records. Admin/Bedrock model listing GET /admin/bedrock/models filter parameters now use Pydantic Literal/regex types so out-of-shape values are refused at the request boundary with a generic 422 (no input echo, no enum-set reflection). The route's bespoke ClientError/BotoCoreError/Exception handlers are removed in favour of the app-wide register_aws_client_error_handler installed at startup, which maps ValidationException-class codes to a generic 400 and other ClientErrors to a generic 502. A new register_validation_error_handler replaces FastAPI's default 422 body with the same generic body. Memory-record delete DELETE /memory/{record_id} now fetches the record's metadata via GetMemoryRecord and verifies the calling user appears in the /actors/{user_id} segment of the record's namespace before issuing batch_delete_memory_records. Non-owners (and missing records) get a generic 404. Two new helpers β€” get_memory_record_owner and record_namespace_owner β€” encapsulate the lookup and namespace parse. OIDC discovery + auth-provider connectivity tests POST /admin/auth-providers/discover now runs the supplied issuer URL through the SSRF validator (https-only, no loopback / link-local / private / metadata addresses) before fetching .well-known/openid- configuration. POST /admin/auth-providers/{id}/test now applies the same validator to each stored URL β€” jwks_uri, token_endpoint, and the issuer-derived discovery URL β€” independently; a URL that fails validation is reported unreachable in the per-endpoint result map and is not contacted. Files pagination cursor GET /files now refuses cursors whose embedded PK doesn't match the caller's USER#{user_id} partition. The repository raises a new InvalidCursorError; the route maps that to a generic 400 instead of the previous 500 the cross-partition DynamoDB query produced. Tests: - test_admin_bedrock_models.py (16) β€” Pydantic rejection of bad enum/regex filters short-circuits before any boto3 call; AWS ValidationException maps to 400 with no message reflection; other ClientError maps to 502 generic; legitimate filters reach AWS; unhandled exception β†’ 500 with no exception detail in body. - test_memory_delete_ownership.py (4) β€” owner can delete; non-owner gets 404 and no AWS call; record without namespaces treated as not owned; missing record β†’ 404 with no AWS call. - test_auth_providers_ssrf.py (12) β€” discover refuses 8 disallowed issuer shapes (loopback, link-local, RFC1918, IPv6 loopback, http://, file://, javascript:); accepts a legitimate https issuer; test endpoint skips disallowed jwks_uri / token_endpoint / issuer URLs individually, marking them unreachable without contacting them. - test_files_cursor_validation.py (5) β€” cursor with another user's PK rejected at repository, garbage cursor rejected, cursor without PK rejected, owner's own cursor still works, route maps the error to 400. - Two existing auth_providers tests updated to mock DNS through the validator's getaddrinfo so their fake hostnames continue to work. * ci(artifacts): plumb CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS through deploy workflows (#485) The artifacts iframe CSP frame-ancestors list is built from the deployed SPA origin plus config.artifacts.extraFrameAncestors (fed by CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS), and is enforced on both the artifacts CloudFront response-headers-policy and the render Lambda's FRAME_ANCESTOR_ORIGIN. But neither platform.yml nor nightly-deploy-pipeline.yml passed that var through, so there was no way to allow a local SPA (http://localhost:4200) to embed artifacts served by a deployed dev distribution β€” the iframe fails with "refused to connect". Mirror the existing CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS passthrough. Unset by default (config falls back to []), so prod is a no-op. Co-authored-by: Claude Opus 4.8 * Sanitize admin error paths and pin viewer-facing TLS to 1.2+ baseline Three small, independent cleanups: Fine-tuning jobs listing β€” tolerant deserialization GET /admin/fine-tuning/jobs now serializes records one at a time; any record that fails JobResponse validation is dropped with a WARN log naming the offending job_id, and the listing returns 200 with the well-formed records. Repository-level failures (the table unreachable, etc.) still surface as 500 β€” only per-record validation drops are tolerated. External model listing β€” generic 503 on missing credentials GET /admin/gemini/models and GET /admin/openai/models now respond with 503 'External model provider not configured.' when the required API key environment variable is unset. The specific env var name (GOOGLE_API_KEY / GOOGLE_GEMINI_API_KEY / OPENAI_API_KEY) is logged server-side at ERROR for operator diagnostics, never echoed to the response body. Transport security baseline CloudFront SPA distribution now pins MinimumProtocolVersion to TLSv1.2_2021 when serving a custom domain β€” drops TLS 1.0/1.1 entirely and prunes the cipher set to AEAD-only. ALB HTTPS listener now pins SslPolicy to ELBSecurityPolicy-TLS13-1- 2-Res-2021-06 β€” TLS 1.2 minimum, modern ciphers only, no CBC. Tests: - test_admin_lows.py (8) β€” fine-tuning listing skips malformed records and returns 200; well-formed-only and all-malformed cases; repository failure still surfaces as 500; gemini and openai unconfigured each return 503 with no env-var-name reflection in the body. - transport-security.test.ts (2) β€” CDK synth assertions that the CloudFront distribution sets MinimumProtocolVersion=TLSv1.2_2021 and the ALB HTTPS listener sets a 2021-vintage SslPolicy. * fix(skills): resolve and persist subset-scoped external MCP tool bindings (#486) * fix(skills): resolve subset-scoped external MCP bindings at fold time A skill binding a *subset* of an external MCP server (scoped ids like `canvas::courses`) resolved to no client at fold time, so the skill folded zero tools and the model reported the server "not connected" β€” the OAuth consent gate never fired because no FoldedMCPTool existed. - base_agent: register/look up external tools by their *base* catalog id (the catalog and tool filter both key on the base), deduping scoped ids for the same server so a per-tool binding is classified and loaded. - ExternalMCPIntegration.get_client: collapse a scoped lookup id to its base and match the cached client, tolerating the `|allow:` subset cache-key suffix. Co-Authored-By: Claude Opus 4.8 * fix(skills): reset stale tool folds when rebuilding skill agents MCP clients are process-global and reused across agent builds, and the fold set persists on them (set_folded_tool_names only ever adds). resolve_mcp_bindings enumerates an external server through that same fold-filtered list_tools_sync, so a stale fold from a prior build makes a re-bind see zero tools β€” the bound tool "works once, then disappears" on the next turn. - mcp_tool_folding: add reset_folded_tool_names to clear a client's fold set and cached tool list. - skill_agent: reset every client this build will (re)bind before resolving, then let the build recompute and re-apply the fold. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 * fix(deps): remediate all 22 HIGH Dependabot findings (#487) Bumps vulnerable dependencies across backend, scripts, and frontend to patched versions. All 22 open HIGH-severity Dependabot alerts addressed. Backend (pyproject.toml + uv.lock): - cryptography 47.0.0 -> 48.0.1 (GHSA-537c-gmf6-5ccf) - starlette 1.0.0 -> 1.3.1 (CVE-2026-48818, CVE-2026-54283) - python-multipart 0.0.27 -> 0.0.30 (CVE-2026-53539) - pyjwt[crypto] 2.12.1 -> 2.13.0 (CVE-2026-48526) - urllib3 pinned 2.7.0 (CVE-2026-44431, CVE-2026-44432) Scripts (backup-data, restore-data): - add uv constraint cryptography>=48.0.1 -> 49.0.0 (GHSA-537c-gmf6-5ccf) Frontend (package.json + package-lock.json): - @angular/* framework 21.2.11 -> 21.2.17 (CVE-2026-50170/50171/54266/54267/54268) (cdk 21.2.14, build/cli 21.2.16 -- latest existing patch for those packages) - hono override -> 4.12.26 (CVE-2026-54290) - piscina override -> 5.2.0 (CVE-2026-55388) - undici override -> 7.28.0 (CVE-2026-9697) - vite override -> 8.0.16 (CVE-2026-53571) Verified: backend pytest 3935 passed/3 skipped; frontend build OK; frontend unit tests 1216 passed. All locked versions >= patched thresholds. * Fix/dependabot quick fixes (#488) * fix(deps): remediate all 22 HIGH Dependabot findings Bumps vulnerable dependencies across backend, scripts, and frontend to patched versions. All 22 open HIGH-severity Dependabot alerts addressed. Backend (pyproject.toml + uv.lock): - cryptography 47.0.0 -> 48.0.1 (GHSA-537c-gmf6-5ccf) - starlette 1.0.0 -> 1.3.1 (CVE-2026-48818, CVE-2026-54283) - python-multipart 0.0.27 -> 0.0.30 (CVE-2026-53539) - pyjwt[crypto] 2.12.1 -> 2.13.0 (CVE-2026-48526) - urllib3 pinned 2.7.0 (CVE-2026-44431, CVE-2026-44432) Scripts (backup-data, restore-data): - add uv constraint cryptography>=48.0.1 -> 49.0.0 (GHSA-537c-gmf6-5ccf) Frontend (package.json + package-lock.json): - @angular/* framework 21.2.11 -> 21.2.17 (CVE-2026-50170/50171/54266/54267/54268) (cdk 21.2.14, build/cli 21.2.16 -- latest existing patch for those packages) - hono override -> 4.12.26 (CVE-2026-54290) - piscina override -> 5.2.0 (CVE-2026-55388) - undici override -> 7.28.0 (CVE-2026-9697) - vite override -> 8.0.16 (CVE-2026-53571) Verified: backend pytest 3935 passed/3 skipped; frontend build OK; frontend unit tests 1216 passed. All locked versions >= patched thresholds. * fix(deps): remediate easy MEDIUM/LOW Dependabot findings Straightforward in-range/direct dependency bumps for remaining alerts. Risky or blocked findings deliberately left out (see below). Backend (pyproject.toml + uv.lock): - aiohttp 3.13.5 -> 3.14.1 - authlib 1.7.0 -> 1.7.1 - python-multipart 0.0.30 -> 0.0.31 - idna pinned 3.15 (transitive) Scripts (backup-data, restore-data): - pytest 8.4.2 -> 9.0.3 (dev) Frontend (package.json + package-lock.json): - mermaid 11.14.0 -> 11.15.0 (direct) - @babel/core override ">=7.29.6 <8.0.0" -> 7.29.7 (bounded to 7.x; open-ended pulled babel 8 which broke the Angular build) Infrastructure (package-lock.json, lock-only): - @babel/core -> 7.29.7 (within range) Deliberately NOT included (not "easy"): - esbuild 0.28.1: pinned by vite 8; override risks build breakage (low sev) - js-yaml 4.2.0: major bump (consumers pin ^3) - dompurify: one alert has no patched version published - infra fast-uri (HIGH) / brace-expansion / js-yaml: bundled inside aws-cdk-lib@2.251.0/node_modules; npm overrides can't reach them. Requires an aws-cdk-lib upgrade -- separate, deliberate change. Verified: backend pytest 3935 passed/3 skipped; frontend build OK + 1216 unit tests passed; infra tsc build OK + 396 jest tests passed. * Fix/dependabot quick fixes (#489) * fix(deps): remediate all 22 HIGH Dependabot findings Bumps vulnerable dependencies across backend, scripts, and frontend to patched versions. All 22 open HIGH-severity Dependabot alerts addressed. Backend (pyproject.toml + uv.lock): - cryptography 47.0.0 -> 48.0.1 (GHSA-537c-gmf6-5ccf) - starlette 1.0.0 -> 1.3.1 (CVE-2026-48818, CVE-2026-54283) - python-multipart 0.0.27 -> 0.0.30 (CVE-2026-53539) - pyjwt[crypto] 2.12.1 -> 2.13.0 (CVE-2026-48526) - urllib3 pinned 2.7.0 (CVE-2026-44431, CVE-2026-44432) Scripts (backup-data, restore-data): - add uv constraint cryptography>=48.0.1 -> 49.0.0 (GHSA-537c-gmf6-5ccf) Frontend (package.json + package-lock.json): - @angular/* framework 21.2.11 -> 21.2.17 (CVE-2026-50170/50171/54266/54267/54268) (cdk 21.2.14, build/cli 21.2.16 -- latest existing patch for those packages) - hono override -> 4.12.26 (CVE-2026-54290) - piscina override -> 5.2.0 (CVE-2026-55388) - undici override -> 7.28.0 (CVE-2026-9697) - vite override -> 8.0.16 (CVE-2026-53571) Verified: backend pytest 3935 passed/3 skipped; frontend build OK; frontend unit tests 1216 passed. All locked versions >= patched thresholds. * fix(deps): remediate easy MEDIUM/LOW Dependabot findings Straightforward in-range/direct dependency bumps for remaining alerts. Risky or blocked findings deliberately left out (see below). Backend (pyproject.toml + uv.lock): - aiohttp 3.13.5 -> 3.14.1 - authlib 1.7.0 -> 1.7.1 - python-multipart 0.0.30 -> 0.0.31 - idna pinned 3.15 (transitive) Scripts (backup-data, restore-data): - pytest 8.4.2 -> 9.0.3 (dev) Frontend (package.json + package-lock.json): - mermaid 11.14.0 -> 11.15.0 (direct) - @babel/core override ">=7.29.6 <8.0.0" -> 7.29.7 (bounded to 7.x; open-ended pulled babel 8 which broke the Angular build) Infrastructure (package-lock.json, lock-only): - @babel/core -> 7.29.7 (within range) Deliberately NOT included (not "easy"): - esbuild 0.28.1: pinned by vite 8; override risks build breakage (low sev) - js-yaml 4.2.0: major bump (consumers pin ^3) - dompurify: one alert has no patched version published - infra fast-uri (HIGH) / brace-expansion / js-yaml: bundled inside aws-cdk-lib@2.251.0/node_modules; npm overrides can't reach them. Requires an aws-cdk-lib upgrade -- separate, deliberate change. Verified: backend pytest 3935 passed/3 skipped; frontend build OK + 1216 unit tests passed; infra tsc build OK + 396 jest tests passed. * fix(deps): upgrade aws-cdk-lib 2.251.0 -> 2.260.0 to clear bundled CVEs The remaining infra Dependabot HIGH/MEDIUM alerts were in deps bundled inside aws-cdk-lib's own node_modules (unreachable by npm overrides): - fast-uri 3.1.0 -> 3.1.2 (HIGH, 2 advisories) β€” bundled via ajv - brace-expansion 5.0.5 -> 5.0.6 (MEDIUM) β€” bundled via minimatch Bumping aws-cdk-lib to 2.260.0 (latest 2.x) re-bundles both at patched versions. constructs 10.6.0 already satisfies the ^10.5.0 peer (unchanged). Verified: infra tsc build clean; jest 396 passed/18 suites (full PlatformStack construction + Template.fromStack assertions, no template regressions). cdk-lib v2 minor bump, backward compatible. * ci: add pull_request test gate (backend/frontend/infra) (#490) The deploy workflows (backend.yml, platform.yml, frontend-deploy.yml) only run on push to develop/main and run their test jobs as a pre-deploy gate, so unit tests never executed on the PR itself. PRs into develop ran only skip-auth-guard. Adds .github/workflows/ci.yml triggered on pull_request -> [develop, main] with three parallel test jobs reusing the existing commands: - test-backend: uv sync + uv run pytest tests/ - test-frontend: npm ci + npm run test:ci (vitest) - test-infra: npm ci + npx jest No build/deploy/AWS steps β€” deploys stay push-only. Actions are SHA-pinned with the shared checkout SHA, runners pinned to ubuntu-24.04, and cancel-in-progress: true (safe; no CDK deploy). Conforms to all tests/supply_chain checks (31 passed). * fix(infra): shared CloudFront cert + consistent domain/cert handling across edge origins (#491) Collapse the three-separate-cert first-deploy footgun into one shared wildcard, and make cert handling consistent and fail-loud across all CloudFront origins. - config.ts: add CDK_CLOUDFRONT_CERTIFICATE_ARN (top-level cloudfrontCertificateArn). frontend/artifacts/mcpSandbox certs fall back to it when their section-specific ARN is unset; section-specific wins. ALB cert stays separate (region-specific). One us-east-1 wildcard ({domain}+*.{domain}) now satisfies all three origins. - artifacts-distribution-construct: add domain-set-but-cert-missing guard mirroring mcp-sandbox (replaces the false 'config.ts already enforced' comment + opaque fromCertificateArn(undefined) crash), and add a domain-less fallback to the CloudFront default domain so domain-less synth no longer crashes with 'reading startsWith'. - load-env.sh: forward cloudfrontCertificateArn + mcpSandbox.certificateArn context params (were missing, breaking the cdk.context.json path). - workflows: wire CDK_CLOUDFRONT_CERTIFICATE_ARN job-level env in platform / nightly / teardown. - docs: step-02/step-03/ACTIONS-REFERENCE recommend the single shared cert and reframe per-origin vars as optional overrides; troubleshooting entry for the synth cert-guard failure. - tests: CloudFront cert resolution (config), artifacts cert guard + domain-less fallback, and end-to-end shared-cert PlatformStack synth. Full infra suite: 20 suites / 406 tests green. * Fix/cdk cli version and deploy node pin (#492) * fix(infra): bump aws-cdk CLI 2.1120.0 -> 2.1128.0 to match aws-cdk-lib 2.260.0 aws-cdk-lib 2.260.0 emits cloud-assembly schema 54.0.0, but the pinned aws-cdk CLI (2.1120.0) only reads up to schema 53 β€” so synth/deploy failed with 'CDK CLI is not compatible with the CDK library ... Maximum schema version supported is 53.x.x, but found 54.0.0. You need at least CLI version 2.1128.0'. The library was bumped without bumping the CLI. Scripts invoke 'npx cdk', which resolves the local aws-cdk devDependency on the CI runner, so bumping the pin + regenerating the lockfile is the fix. Also bump the devcontainer global CDK pin (Dockerfile) and the version tables (README, dev-environment steering) that are documented to track package.json, so the interactive 'cdk' in the container doesn't drift and reproduce the same error locally. Verified in the devcontainer: npx cdk --version -> 2.1128.0; a synth of an aws-cdk-lib 2.260.0 assembly (manifest schema 54.0.0) is read by the 2.1128.0 CLI with exit 0; full infra suite 20 suites / 406 tests green. * ci(platform): pin Node 22 in the PlatformStack deploy jobs The deploy jobs in platform.yml and nightly-deploy-pipeline.yml were the only jobs without actions/setup-node β€” they ran scripts/platform/deploy.sh (which does npm ci + cdk via deploy.sh -> scripts/cdk/install.sh) on the runner's ambient Node instead of the Node 22 every other job and the devcontainer pin. Add setup-node (node 22 + npm cache) so the deploy toolchain is pinned and reproducible. Note: deps were already being installed (deploy.sh calls install.sh -> npm ci); the recent schema-mismatch failure was the stale aws-cdk pin (2.1120.0), fixed in 4339f261 by bumping to 2.1128.0. This change is the toolchain-pinning gap the #396 refactor left in the deploy jobs. * Fix/deploy fixes (#494) * fix(infra): auto-generate IAM role names to avoid first-deploy collisions Drop explicit roleName from the AgentCore memory/code-interpreter/browser/ gateway/runtime execution roles and the SageMaker execution role. Fixed physical names collide with orphaned roles left by a rolled-back/partial deploy. Every consumer references these roles by .roleArn (or resolves them at runtime via GetGateway / SAGEMAKER_EXECUTION_ROLE_ARN), so auto-generated names are safe. * chore(infra): replace deprecated pointInTimeRecovery with pointInTimeRecoverySpecification Silences the aws_dynamodb.TableOptions#pointInTimeRecovery deprecation warnings across all DynamoDB table constructs. Synthesized CloudFormation output is unchanged (still PointInTimeRecoveryEnabled: true). * fix(deploy): re-seed image-tag SSM param when the referenced ECR image is missing The seed guard skipped any URI-shaped value, trusting the build pipeline. But image-tag params are not CFN-managed and survive teardown, so a stale project-repo URI could outlive its ECR repo and break the AgentCore Runtime / ECS task def with 'repository does not exist'. Verify the image actually exists (ecr describe-images) before skipping; otherwise overwrite with the bootstrap URI. * fix(infra): grant Cognito group + delete actions for first-boot admin setup Add cognito-idp:CreateGroup and AdminAddUserToGroup (the first-boot flow creates the system_admin group and adds the initial admin) plus AdminDeleteUser (so the rollback path doesn't orphan a Cognito user and block retry with UsernameExistsException) to the app-api task role. * fix(infra): restore stable IAM role names for AgentCore execution roles (#495) Reverts the roleName removal from 7107cf98 for the AgentCore memory/ code-interpreter/browser/gateway/runtime and SageMaker execution roles. Auto-generating these names is unsafe on an already-deployed stack: the role ARN feeds create-only properties on the AgentCore resources (BrowserCustom/CodeInterpreterCustom executionRoleArn, Memory memoryExecutionRoleArn, Gateway/Runtime roleArn). Renaming the role replaces it (new ARN) -> forces replacement of the dependent AgentCore resource -> CFN re-creates it with the same create-only Name -> 'already exists' collision -> UPDATE_ROLLBACK. Confirmed via ai-sbmt-api-PlatformStack events (BrowserCustom/CodeInterpreterCustom DELETE_COMPLETE with empty PhysicalResourceId during rollback). Orphaned fixed-name roles on a *fresh* deploy are handled by deleting the orphans before deploying, not by renaming. Added comments on each role to prevent re-introducing the auto-name change. * fix(ci): build arm64 images on native ARM runners (#496) The RAG ingestion Lambda and AgentCore Runtime are arm64, but the post-refactor backend.yml ran their build jobs on amd64 (ubuntu-24.04). inference-api compensated with QEMU emulation (platform: linux/arm64); rag-ingestion had neither the platform input nor a build-one.sh PLATFORM, so it produced an amd64 image -> the arm64 Lambda failed every invoke with Runtime.InvalidEntrypoint (file uploads stuck 'uploading', no embeddings). Restore the pre-refactor (main) approach: build both arm images on native ubuntu-24.04-arm runners instead of emulating on amd64. - backend.yml: build-inference-api and build-rag-ingestion -> runs-on ubuntu-24.04-arm; drop the QEMU-triggering platform input from inference-api (native build needs no emulation). - build-one.sh: rag-ingestion PLATFORM=linux/arm64 (explicit native build). Deploy jobs stay on ubuntu-24.04 (API-only, no docker build). Note: the stale amd64 rag-ingestion ECR image must be deleted once so the content-hash build doesn't skip the corrected arm64 build. * feat(skills): gate skills feature behind SKILLS_ENABLED flag (default off) (#497) * feat(skills): gate skills feature behind SKILLS_ENABLED flag (default off) Defer the skills feature (user picker, admin catalog, skills mode) for a release without removing any merged code. A new apis/shared/feature_flags.py::skills_enabled() reads SKILLS_ENABLED (default false), mirroring the FINE_TUNING_ENABLED precedent. Deployed environments go dark automatically because the env var is absent; set SKILLS_ENABLED=true on both app-api and inference-api to re-enable. Gated surfaces (code and data left intact): - app-api main.py: user-facing skills router mounted only when enabled. - app-api admin/routes.py: admin skills + chat-mode-policy routers gated. - app-api system/routes.py: GET /system/chat-settings reports chat/no-toggle/ skillsEnabled:false when off (new skills_enabled field on the response). - inference-api chat/routes.py: force skill -> chat when off (voice/other agent types untouched). - SPA: ChatModeService.skillsEnabled drives admin nav hide; mode toggle and skills section auto-hide via allowModeToggle:false; SkillService eager load gated by an effect so a disabled env never fires the now-404 GET /skills/. Tests default off; skills-mode tests opt in via SKILLS_ENABLED=true and new off-behavior tests cover the forced-chat path and admin mount gating. Co-Authored-By: Claude Opus 4.8 * test(model-settings): mock Skill/ChatMode services to stop teardown rejection model-settings.spec instantiated the real SkillService and ChatModeService, which fire /skills/ (now via an effect) and /system/chat-settings on construction. With no httpMock those requests fail asynchronously, and the SKILLS_ENABLED change shifted the timing so a console.error landed during worker teardown β€” surfacing as an unhandled EnvironmentTeardownError that failed the vitest run with exit 1 even though all 1218 tests passed. Provide minimal mocks for both services so the spec fires no stray async work. Full suite exits 0. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 * feat(tools): forward admin OIDC token on MCP tool discovery (#498) The admin "discover from server" button signs its request as the app-api task role (SigV4, service=lambda), but that role has no lambda:InvokeFunctionUrl β€” only inference-api does. Against an AuthType=AWS_IAM Lambda Function URL the signed request is rejected with 403, which surfaces during MCP client init as an anyio TaskGroup ExceptionGroup and falls through to a generic 502. For same-team MCP servers that validate a forwarded user JWT (Lambda URL AuthType=NONE), discovery should mirror the runtime forward_auth_token path and sign with the admin's own OIDC token instead of SigV4. Add a forward_auth_token flag to MCPDiscoverRequest; when set, the discover route forwards admin.raw_token as the bearer (400 if unavailable) and skips SigV4. Provider-gated OAuth (3LO) discovery is still rejected β€” the admin session can't supply an end-user provider token. Wire the flag through the admin tool form's discover call so the existing "Forward app authentication token" checkbox governs discovery too. Co-authored-by: Claude Opus 4.8 * fix(nightly): auto-teardown ephemeral nightly deploys; fix teardown for single-stack (#499) Reconcile the nightly pipeline and teardown scripts with the single PlatformStack, API-driven-deploy architecture. nightly-deploy-pipeline.yml: restore the workflow_call input contract the orchestrator still passes (ref, project-prefix, alb-subdomain, skip-teardown, label, source-project-prefix, run-e2e). Every job now checks out inputs.ref and deploys to the ephemeral inputs.project-prefix (never the shared environment). Add an always() teardown job (needs all deploy/test jobs, gated on skip-teardown) so every nightly stack is destroyed even on partial/failed deploys -- no paying for idle resources. Ephemeral env runs with no custom domain and an unset CDK_COGNITO_DOMAIN_PREFIX (defaults to the unique prefix). scripts/nightly/teardown.sh: delete -PlatformStack via cloudformation delete-stack + wait (was a dead cdk-destroy loop over removed per-stack names). scripts/teardown/destroy.sh: add PlatformStack to the foundation phase while keeping legacy InfrastructureStack/app-stack handling, so the manual teardown works for both single-stack and legacy deployments. * fix(app-api): grant secretsmanager:PutSecretValue on auth-provider secret (#501) The admin auth-providers endpoints (POST/DELETE /admin/auth-providers) write the provider client-secret bag back to the auth-provider-secrets secret via PutSecretValue (apis/shared/auth_providers/repository.py), but the App API task role was only granted GetSecretValue. Configuring/removing an auth provider failed with AccessDeniedException on secretsmanager:PutSecretValue. Add a least-privilege PutSecretValue statement scoped to just the auth-provider-secrets secret (trailing wildcard matches the random ARN suffix). No other runtime-written secret needs this. * Fix/nightly (#500) * fix(nightly): auto-teardown ephemeral nightly deploys; fix teardown for single-stack Reconcile the nightly pipeline and teardown scripts with the single PlatformStack, API-driven-deploy architecture. nightly-deploy-pipeline.yml: restore the workflow_call input contract the orchestrator still passes (ref, project-prefix, alb-subdomain, skip-teardown, label, source-project-prefix, run-e2e). Every job now checks out inputs.ref and deploys to the ephemeral inputs.project-prefix (never the shared environment). Add an always() teardown job (needs all deploy/test jobs, gated on skip-teardown) so every nightly stack is destroyed even on partial/failed deploys -- no paying for idle resources. Ephemeral env runs with no custom domain and an unset CDK_COGNITO_DOMAIN_PREFIX (defaults to the unique prefix). scripts/nightly/teardown.sh: delete -PlatformStack via cloudformation delete-stack + wait (was a dead cdk-destroy loop over removed per-stack names). scripts/teardown/destroy.sh: add PlatformStack to the foundation phase while keeping legacy InfrastructureStack/app-stack handling, so the manual teardown works for both single-stack and legacy deployments. * fix(nightly): restore test-infra/backend/frontend gates in deploy pipeline The pipeline rewrite for ephemeral auto-teardown dropped the per-pipeline test gates, breaking infrastructure/test/repo-shape.test.ts which requires: - build-*/code-deploy-* jobs gate on test-backend - deploy-frontend gates on test-frontend - deploy-platform gates on test-infra Re-add the three test jobs (checking out inputs.ref) and the needs edges, keeping the input-driven ephemeral deploy + always() teardown intact. Also add the test jobs to teardown's needs so teardown waits for the whole graph. Verified: npx jest repo-shape passes (49/49); both nightly workflow YAMLs parse and all orchestrator calls pass only declared inputs. * docs(infra): correct stale SSM comment on mcp-sandbox origin wiring (#502) The McpSandboxDistributionConstruct doc comment claimed it publishes the proxy origin to SSM at `/{prefix}/mcp-sandbox/origin`. That was true of the pre-#396 standalone McpSandboxStack, but the single-stack consolidation dropped the SSM publication: the origin is now exposed as `proxyOrigin` and threaded through PlatformComputeRefs straight into inference-api's `AGENTCORE_MCP_APPS_SANDBOX_ORIGIN` env var. The stale comment misleads anyone debugging the sandbox (a missing SSM param looks like a broken deploy when it is expected). Update the comment to match the code. Co-authored-by: Claude Opus 4.8 * chore(kaizen): weekly research scan 2026-06-19 (#493) Generated by the kaizen-research skill. Top 5 ideas appended to docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning. Co-authored-by: Claude Opus 4.8 (1M context) * fix(mcp-apps): accept spec-array content in ui/message (#503) The ui/message bridge handler read `params.content` as a single block ({type,text}), but per SEP-1865 / the ext-apps SDK the View sends an ARRAY of content blocks (content: [{type:'text',text}]). Every spec-compliant widget message was therefore rejected with -32000 "Invalid ui/message params" (e.g. an MCP App's app.sendMessage()). Read `content` as an array and concatenate its text blocks (mirrors the ui/update-model-context handler). Update MessageParams to the array shape, fix the two bridge specs that sent single objects, and add a multi-block concatenation regression test. Co-authored-by: Claude Opus 4.8 * fix(mcp-apps): retry transient TLS/connect failures on MCP client start (#504) A single TLS handshake blip (e.g. SSLV3_ALERT_HANDSHAKE_FAILURE from a TLS-inspecting middlebox) or connection reset when starting an external MCP client otherwise fails the whole agent build: Strands' start() raises MCPClientInitializationError, the tool fails to load, and agent creation errors out for the user. UICapableMCPClient.start() now retries transient transport failures β€” ConnectError/SSLError/timeouts, detected by walking the MCPClientInitializationError -> ExceptionGroup -> httpx.ConnectError chain β€” up to 3 attempts with exponential backoff. Non-transient errors (bad URL, auth, protocol) are re-raised on the first attempt. Strands resets its init future + background thread on failure (stop()), so re-invoking start() is safe. Covers both external and gateway clients. Co-authored-by: Claude Opus 4.8 * fix(mcp-apps): give widget ui/message turns the loading + scroll affordances (#505) A user turn initiated by an MCP App widget (ui/message β†’ submitChatRequest) appeared in the conversation but skipped the two affordances the composer path triggers: the loading indicator (the page sets chatLoading before submitting) and the scroll-to-top of the new user message (chat-container's post-submit setTimeout). The widget delegate called the service directly, bypassing the chat-input β†’ chat-container β†’ page chain. - ChatStateService: add a `scrollToLastUserTick` signal + `requestScrollToLastUser()`. - ChatContainer: react to the tick by scrolling the last user message to the top (mirrors onMessageSubmitted; skips the initial 0 so it doesn't scroll on mount). - mcp-app-frame widget delegate: set chatLoading(true) and request the scroll around submitChatRequest (the user message is added synchronously inside it). The composer path is untouched (it keeps its own setTimeout scroll), so no existing behavior changes. Co-authored-by: Claude Opus 4.8 * feat(connectors): scaffold export-target adapters for saving conversations to connected apps (#507) Extends the connector/adapter pattern that powers Google Drive document import (read) to the write direction, so a connector can also be a *destination* a user saves content to (e.g. saving a conversation transcript to Drive). This is PR-1 of the plan in docs/specs/conversation-export-connectors.md: data + capability scaffold only, no user-facing behavior yet. The connector auth layer (OAuthProvider + AgentCore Identity + consent UX) is direction-agnostic and reused unchanged; only the capability layer is new. Rather than bolting a write method onto the read-shaped FileSourceAdapter, this adds a parallel ExportTargetAdapter registry. - Add export_target_adapter_id to OAuthProvider (model, Dynamo serializers, repository apply_metadata_update) and the Create/Update/Response API models, mirroring file_source_adapter_id. A connector may now be both a file source and an export target. - New apis/app_api/export_targets/ package: ExportTargetAdapter contract, ExportTargetMetadata (with supported_formats), ExportFormat / ExportDestination / CreatedFile models, and an ExportTargetRegistry singleton (intentionally empty until the Google Drive export adapter lands in PR-2). - New admin GET /admin/export-target-adapters endpoint and _validate_export_target_adapter enforced on connector create/update. - Tests mirror the file-source adapter tests, using a stub adapter under a test-only key so it never collides with the future shipped adapter. Full backend suite green (3978 passed, 3 skipped). Co-authored-by: Claude Opus 4.8 * feat(export-targets): Google Drive export adapter + transcript renderer (#508) PR-2 of the Save Conversations to Connected Apps feature (docs/specs/conversation-export-connectors.md). Builds the write-side capability on top of PR-1's scaffold: the first ExportTargetAdapter, the transcript renderer, and the connector resolve/token helpers. Still no user-facing endpoint β€” that lands in PR-3. - GoogleDriveExportAdapter (drive.file scope, least privilege): multipart /related upload to /upload/drive/v3/files. GOOGLE_DOC uploads an HTML body against the Google Doc MIME type (Drive converts to a native Doc); MARKDOWN uploads a plain .md file. When no destination folder is given, find-or-creates a single app folder ("AI Conversations", overridable via EXPORT_DRIVE_FOLDER_NAME) β€” which works under drive.file because the search only matches the app's own files. Now registered in the registry. - render.py: pure render_transcript(title, messages, fmt, include) -> RenderedDocument. Markdown is the intermediate representation; GOOGLE_DOC converts it to HTML via markdown-it-py (already a dependency), so Markdown structure maps to real Docs styling. Honors the ExportInclude checkboxes (tool calls / images / citations on; reasoning / timestamps off). Raw HTML in message text is escaped, not injected (CommonMark's default passthrough is forced off). - service.py: resolve_export_target / require_export_target_token / http_error_for_export_target_error, mirroring the file-source service (404 not-a-target, 403 RBAC, 409 not-connected, 503 no-workload). - ExportInclude model added; PR-1 admin tests updated now the registry ships the Drive adapter. PDF is deferred: no PDF renderer is in the dependency set, so the Drive adapter advertises only google_doc + markdown for now. Tests: render, Drive adapter (httpx.MockTransport), and service helpers. Full backend suite green (4012 passed, 3 skipped). Co-authored-by: Claude Opus 4.8 * feat(export-targets): export endpoint + conversation-export receipts (#508 follow-up) (#509) Add the PR-3 export endpoint for saving a conversation transcript out to a connected app (Google Drive being the first export target): - POST /sessions/{session_id}/export β€” ownership check, resolve connector to export-target adapter, validate the requested format against the adapter, mint the user's OAuth token (409 = consent needed, the SPA's retry hook), page the full transcript, render it, and create the document. Adapter failures map to 502/403/404. - GET /export-targets β€” catalog mirroring GET /file-sources, surfacing per connector `connected` and `supportedFormats` so the SPA dialog can build its connector + format pickers without extra backend work. Persist an export receipt on the session (spec R-2): - ExportReceipt model + export_receipts on SessionMetadata and SessionMetadataResponse so a "Saved Β· Open" affordance survives a reload. - add_export_receipt: race-free list_append/if_not_exists writer mirroring add_pending_interrupt; best-effort so a metadata write never fails an export that already succeeded. Tests: 16 route cases (catalog gating/connected/formats; export success + receipt; multi-page transcript ordering; 404/403/409/503/422/502 matrix). Full backend suite green. Co-authored-by: Claude Opus 4.8 * feat(export-targets): "Save to…" conversation export SPA + admin mapping dropdown (PR-4) (#510) Frontend for the conversation-export feature: a "Save to…" action that pushes a conversation transcript out to a connected app (Google Drive first), plus the admin surface to map a connector as an export target. Save-to dialog (session/): - ExportService + ExportError client for GET /export-targets and POST /sessions/{id}/export (mirrors FileSourceService: OAuth2CallbackUrl header, suppressed error toast, HTTP-status-carrying error). - ExportDialogComponent (CDK dialog): destination picker, format choice gated by each connector's supportedFormats, include-checkbox group (messages locked on; tool calls / images / citations on; reasoning / timestamps off). A not-connected or 409-expired destination runs the shared OAuth consent popup and retries the save automatically; success shows "Open in ". - "Save to…" item wired into the session-list overflow menu beside Share. Admin mapping dropdown (admin/connectors/): - exportTargetAdapterId on the connector model + create/update requests, an Export-target adapter resource over GET /admin/export-target-adapters, and an "Export Target" dropdown on the connector form with the same provider-compatibility filter, scope-coverage warning, and tri-state save semantics as the file-source dropdown. Tests: export.service.spec (4) + export-dialog.component.spec (6) covering catalog load, auto-select, save success, include toggles, and the consent-then-retry path. Production build + affected specs green. Depends on the app-api export endpoints in #509 for the runtime save path; the admin dropdown works against the already-merged admin adapter endpoint. Co-authored-by: Claude Opus 4.8 * feat(export-targets): destination folder picker for "Save to…" (PR-5) (#511) Reuse the import file-source browse dialog as a destination folder picker so a conversation export can target a specific Drive folder instead of only the default app folder. Unblocked by the combined-scope Drive connector (D-4): `drive.readonly` lets the existing roots/browse endpoints power the picker, `drive.file` writes the file β€” `parentId` already flowed through the export endpoint and request. Backend - Add a `browsable` flag to the `GET /export-targets` catalog, computed from the connector's `file_source_adapter_id` + the file-source registry. Only a connector that is also a shipped file source can back the picker; export-only connectors hide it and keep landing in the app folder. Frontend - Generalize `file-source-browser-dialog` with a `mode: 'import' | 'pick-folder'`. In pick-folder mode it shows folders only, tracks the current folder name client-side, and closes with a `FolderSelection` ("Use this folder"). Import mode is unchanged. - Wire the picker into the export dialog: a "Folder" row (shown only for browsable targets) opens the picker and threads the chosen `parentId` into the save request; default stays the app folder. A successful pick reconciles the connected flag. Tests: backend export-route catalog (browsable true/false/unshipped); picker specs for both dialogs. Full AOT build + architecture import-boundary tests green. Co-authored-by: Claude Opus 4.8 * feat(infra): support external (cross-account) Route53 hosted zones (#512) Add a manageDnsRecords flag (CDK_MANAGE_DNS_RECORDS env / manageDnsRecords context, default true). When false, the stack still attaches the custom domain + ACM cert to the ALB, SPA, artifacts, and mcp-sandbox origins but skips the in-account HostedZone.fromLookup + record creation that would fail when the zone for domainName lives in another AWS account. In that mode each origin emits CfnOutputs with the record name and alias target so an operator can create the records by hand. - config.ts: add manageDnsRecords to AppConfig + loadConfig, log it - zones/alb-dns + spa/artifacts/mcp-sandbox distribution constructs: guard record creation, emit manual-DNS CfnOutputs when unmanaged - load-env.sh: export + validate CDK_MANAGE_DNS_RECORDS - workflows (platform, nightly): pass the flag through - cdk.context.json + test mock-config: add the field - docs: document external-R53 deployment Co-authored-by: Colin * Release/1.0.0 (#513) * Release/v1.0.0 beta.25 (#282) * Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * docs(readme): update version badges and tech stack to v1.0.0-beta.18 - Update release badge from v1.0.0-beta.17 to v1.0.0-beta.18 - Bump Tailwind CSS version from v4.1 to v4.2 in all references - Update current release version in release notes section - Reflect latest dependency versions in architecture and tech stack documentation * feat(embeddings): add optional token validation bypass for search queries - Add skip_token_validation parameter to generate_embeddings function - Allow skipping tiktoken-based token validation for short inputs where tiktoken may not be installed - Update search_assistant_knowledgebase to skip validation for query embeddings - Enables embedding generation in environments where tiktoken is unavailable (e.g., search Lambda) * refactor(embeddings): extract shared embedding logic to separate module - Move core embedding generation and vector store operations to apis.shared.embeddings - Create new shared bedrock_embeddings module with generate_embeddings, store_embeddings_in_s3, search_assistant_knowledgebase, and delete_vectors_for_document - Extract vector search logic to new apis.shared.assistants.vector_search module - Keep ingestion-specific token validation (tiktoken-based) in app_api embeddings module - Update ingestion embeddings module to re-export shared functions for backward compatibility - Simplify bedrock_embeddings in ingestion pipeline to focus on chunk validation and splitting - Update imports across documents routes and rag_service to use new shared modules - Reduces code duplication and establishes clear separation between shared RAG infrastructure and ingestion-specific concerns * docs(release-notes): document v1.0.0-beta.19 features and fixes - Add Angular production build optimization section explaining minification and tree-shaking enablement - Document embeddings refactor extracting shared logic to apis.shared.embeddings module - Add skip_token_validation parameter documentation for generate_embeddings function - Update highlights section to mention Angular production build optimization - Clarify CodeQL workflow improvements and unused import/variable cleanup - Enable optimization flag in angular.json production configuration for reduced bundle size * docs(release-notes): remove Angular optimization section and revert config - Remove "Frontend Production Build Optimization" section from release notes - Revert optimization flag removal from angular.json production configuration - Align documentation with actual production build configuration state * feat: add API Keys section to README for programmatic access to AI models * fix(model_config): comment out caching configuration due to Bedrock limitations * feat(create-training-job): enhance file upload with drag-and-drop support and update dataset upload instructions * feat(create-training-job): add support for custom HuggingFace models and enhance model search functionality * fix(test_model_config): remove caching mock and update test for Bedrock config caching behavior * feat(create-training-job): add tests for custom HuggingFace model selection and submission * feat: refactor session compaction and enable by default (#86) * feat: update compaction configuration and enhance session manager tests * fix: update tests for compaction defaults and commented-out caching - Update compaction model test to expect enabled=True and protected_turns=3 - Fix caching test to reflect cache_config being commented out due to Bedrock limitations Co-Authored-By: Claude Opus 4.6 * feat(create-training-job): enhance file upload with drag-and-drop support and update dataset upload instructions * feat(create-training-job): add support for custom HuggingFace models and enhance model search functionality * fix(test_model_config): remove caching mock and update test for Bedrock config caching behavior * feat(create-training-job): add tests for custom HuggingFace model selection and submission * fix: update tests for compaction defaults and commented-out caching - Update compaction model test to expect enabled=True and protected_turns=3 - Fix caching test to reflect cache_config being commented out due to Bedrock limitations Co-Authored-By: Claude Opus 4.6 --------- Signed-off-by: Phil Merrell Co-authored-by: Claude Opus 4.6 * test(to_bedrock_config): add missing result assignment in caching disabled test * Potential fix for code scanning alert no. 41: Clear-text logging of s… (#85) * Potential fix for code scanning alert no. 41: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * ci: Add explicit read-only permissions to all workflows - Add `permissions: contents: read` to 13 GitHub Actions workflows - Workflows updated: app-api, bootstrap-data-seeding, codeql, frontend, gateway, inference-api, infrastructure, nightly-deploy-pipeline, nightly, rag-ingestion, release, sagemaker-fine-tuning, version-check - Implements principle of least privilege by explicitly declaring minimal required permissions - Improves security posture and aligns with GitHub Actions best practices * fix(security): Redact sensitive information from logs - Mask client ID in seed_auth_provider output, showing only first 8 characters - Redact full Secrets ARN in seed_auth_provider, displaying only resource name - Replace full exception objects with error codes in seed_bootstrap_data error messages - Downgrade MCP client configuration logging from info to debug level - Remove user ID from OAuth token retrieval and re-auth status log messages - Add URL validation to OAuth callback redirect to prevent open redirect vulnerabilities - Prevents accidental exposure of credentials and sensitive identifiers in application logs * fix(security): Resolve remaining CodeQL clear-text logging alerts - seed_auth_provider: Fully redact Secrets Manager ARN from output - external_mcp_client: Remove server URL from logs, decouple oauth_token from log expressions - oauth_tool_service: Isolate decrypted token into _try_get_token() to prevent taint bleed, use lazy log formatting - config.ts: Remove AWS account ID and CORS origins from CDK config log output * Potential fix for code scanning alert no. 499: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 496: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 498: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 497: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> --------- Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: colinmxs * feat(frontend): enable production optimization, branch-aware BUILD_CONFIG - Remove optimization: false from base options (was blocking prod override) - Production: optimization, no source maps, extract licenses - Fix anyComponentStyle budget from 4kB to 200kB for Tailwind - BUILD_CONFIG: mainβ†’production, developβ†’development, dispatchβ†’manual input Production build: 4.96 MB initial (871 KB gzip) vs 8.85 MB unoptimized * fix: move Google Fonts import to index.html to prevent CI build failure * ci: skip docker builds and CDK synth on pull requests * implement conversation sharing. (#87) * implement conversation sharing. * Potential fix for code scanning alert no. 509: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: ofilson * Potential fix for code scanning alert no. 510: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: ofilson * fix github warnings * fix log issue --------- Signed-off-by: ofilson Co-authored-by: Oscar Filson Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Allow for private share (only with yourself) * release: v1.0.0-beta.19 * fix float error on sharing * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * ci: skip redundant stack dependency checks on PRs (keep infrastructure only) * ci: skip install on PRs for rag-ingestion (no downstream jobs) * ci: revert check-stack-deps skip on workflows with PR jobs, skip entire gateway/sagemaker on PRs * fix(security): resolve CodeQL log-injection, unused-import, and unused-variable alerts - Remove user-controlled values from 180 log f-strings (py/log-injection) - Remove 87 unused Python imports (py/unused-import) - Remove 27 unused JS/TS variables (js/unused-local-variable) - Fix 3 useless assignments (js/useless-assignment-to-local) - Fix 1 incompatible type comparison (js/comparison-between-incompatible-types) * fix(tests): remove stale AgentCoreMemorySessionManager patch from session factory tests The CodeQL commit removed the unused AgentCoreMemorySessionManager import from session_factory.py, breaking two tests that patched it at that path. Removed the unnecessary patch decorator since TurnBasedSessionManager was already being patched separately. * chore(docker): add shared embeddings module to rag-ingestion Lambda image - Copy shared embeddings package to Lambda task root directory - Add apis/__init__.py to ensure proper Python package structure - Enable ingestion embeddings to access re-exported shared embeddings module - Resolves import errors when bedrock_embeddings.py loads shared embeddings * fix(quality): resolve all open CodeQL findings on develop Empty excepts (5 fixes): - url_fetcher: narrow bare except to Exception, add comment - code_interpreter_diagram_tool: narrow bare except to Exception - tool_result_processor: add explanatory comment to JSONDecodeError catch - users/service: log warning on invalid pagination cursor - event_formatter: log warning instead of silently swallowing errors Catch BaseException (2 fixes): - url_fetcher: narrowed to Exception (same fix as empty except) - code_interpreter_diagram_tool: narrowed to Exception Unreachable code (1 fix): - stream_processor: remove dead if result_seen: break (never set to True) Redundant assignment (1 fix): - fine_tuning/routes: remove unused job = on create_inference_job Print during import (1 fix): - inference_api/main: replace print() with logging Commented-out code (1 fix): - inference_api/chat/models: remove commented InvocationRequest class Unnecessary lambdas (2 fixes): - job_repository, inference_repository: lambda v: int(v) β†’ int Unused local variables (13 fixes): - Remove or rename: period, user_id, error_msg, matches, requested_set, exception_type, updated, limit, preferences, execution_output, next_month, next_year across 10 files Unused imports (3 fixes): - compaction_models: remove unused field import - bedrock_embeddings: remove dead re-exports, clean up __init__.py - timezone: use find_spec for pytz availability check Cyclic import (1 fix): - Move get_metadata_storage() factory from metadata_storage.py to storage/__init__.py, breaking the metadata_storage ↔ dynamodb_storage cycle. Update 3 callers to import from apis.app_api.storage. Dismissed as false positives (11 alerts): - 9x untrusted-checkout on nightly workflows (schedule/dispatch only) - 1x non-iterable for-loop (Enum is iterable) - 1x unused global _generic_validator_initialized (global stmt tracking) * fix(deps): patch Dependabot security vulnerabilities - requests 2.32.5 β†’ 2.33.0 (insecure temp file reuse, CVE) - picomatch 4.0.3 β†’ 4.0.4 (frontend, ReDoS + method injection, via override) - picomatch 2.3.1 β†’ 2.3.2 (infrastructure, method injection, via override) - diff 4.0.x β†’ patched (infrastructure, DoS in parsePatch, via audit fix) Unfixable: - yaml 1.10.2 bundled inside aws-cdk-lib 2.244.0 (latest) β€” awaiting AWS CDK update - Pygments 2.19.2 (latest) β€” no patched version released yet * fix(rag-ingestion): ensure Lambda uses latest image digest on deploy - Add FUNCTION_NAME variable to capture Lambda function identifier - Update Lambda function code explicitly after image push to force digest refresh - Add wait condition to ensure function update completes before deployment succeeds - Remove outdated next steps logging that duplicated deployment completion message - Resolve issue where CDK's SSM-resolved image tags don't trigger updates when underlying image layers change, causing CloudFormation to report no changes despite fresh image push * fix share issues and icon tweaks * release: v1.0.0-beta.20 * fix(rag-ingestion): restore shared embedding re-exports for Lambda handler The CodeQL fix removed re-exports from bedrock_embeddings.py, but the RAG ingestion Lambda handler imports generate_embeddings and store_embeddings_in_s3 from embeddings.bedrock_embeddings (Lambda task root path). Restored re-exports with __all__ and explanatory comments. * feat(documents): add upload failure reporting and assistant cleanup - Add ReportUploadFailureRequest model for client-side upload error reporting - Implement POST /{document_id}/upload-failed endpoint to mark documents as failed - Add update_document_status service function to update document status and error details - Implement background cleanup of vectors and S3 objects when assistant is deleted - Add delete_vectors_for_assistant function to remove embeddings from vector store - Update document routes to import new models and service functions - Add start.sh to .gitignore - Update bedrock_embeddings to support vector deletion by assistant ID - Enhance frontend document service to handle upload failure reporting - Improve assistant deletion flow with proper resource cleanup and error handling * feat(assistants): remove archive functionality and simplify deletion - Remove archive_assistant service function and endpoint - Simplify delete operation to single hard delete without archive option - Remove include_archived query parameter from list assistants endpoint - Remove ARCHIVED status from assistant status enum - Update frontend assistant model and services to remove archive references - Simplify assistant lifecycle by consolidating soft and hard delete into single delete operation - Update API documentation and test examples to reflect deletion changes * feat(frontend): upgrade Analog.js testing dependencies and remove vitest config - Add @analogjs/vite-plugin-angular and @analogjs/vitest-angular v3.0.0-alpha.18 - Update package-lock.json with new dependency tree and transitive dependencies - Remove vitest.config.ts in favor of Analog.js configuration - Update app.config.spec.ts and tool-rail.component.spec.ts test files - Modernize Angular testing setup with latest Analog.js tooling * feat(documents): implement reliable deletion with soft-delete and cleanup retries - Add deleting status to document lifecycle and TTL field for auto-expiry - Create cleanup_service.py with retry logic for S3 vectors and source file deletion - Implement soft-delete pattern: mark documents as deleting, return immediately, cleanup asynchronously - Update search path to filter out non-complete documents and prevent stale results - Add batch soft-delete for assistant deletion with background cleanup - Implement deterministic vector key generation for reliable cleanup - Add comprehensive property-based and integration tests for deletion flows - Update RAG service to cross-check document status during search - Configure DynamoDB TTL as backstop for failed cleanups (7-day expiry) - Add Kiro spec documentation for reliable document deletion design * test(assistants): remove archive assistant test and fix package dependencies - Remove test_archive_assistant test case as archive functionality was removed - Update package-lock.json to fix dependency flags for Angular DevKit and related packages - Change chokidar dev flag to devOptional to reflect optional development dependency - Remove unnecessary dev flags from multiple dependencies (ajv, chalk, cli-cursor, fast-deep-equal, and others) - Align package metadata with current project configuration * chore(frontend): pin Analog.js dependencies to exact versions - Remove caret (^) version specifiers from @analogjs/vite-plugin-angular - Remove caret (^) version specifiers from @analogjs/vitest-angular - Lock both packages to 3.0.0-alpha.18 for consistent builds - Prevent unexpected minor/patch updates that could introduce breaking changes * chore(frontend): pin Analog.js devDependencies to exact versions - Update @analogjs/vite-plugin-angular from ^3.0.0-alpha.18 to 3.0.0-alpha.18 - Update @analogjs/vitest-angular from ^3.0.0-alpha.18 to 3.0.0-alpha.18 - Remove caret (^) prefix to lock exact versions and ensure consistent builds * feat(fine-tuning-dashboard): add informational section about fine-tuning and update icons * chore(deps)(deps): bump the frontend-minor-patch group (#101) Bumps the frontend-minor-patch group in /frontend/ai.client with 10 updates: | Package | From | To | | --- | --- | --- | | [@ng-icons/core](https://github.com/ng-icons/ng-icons) | `33.1.0` | `33.2.0` | | [@ng-icons/heroicons](https://github.com/ng-icons/ng-icons) | `33.1.0` | `33.2.0` | | [katex](https://github.com/KaTeX/KaTeX) | `0.16.33` | `0.16.44` | | [marked](https://github.com/markedjs/marked) | `17.0.3` | `17.0.5` | | [mermaid](https://github.com/mermaid-js/mermaid) | `11.12.3` | `11.13.0` | | [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.2.1` | `4.2.2` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.0.18` | `4.1.2` | | [postcss](https://github.com/postcss/postcss) | `8.5.6` | `8.5.8` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.1` | `4.2.2` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.0.18` | `4.1.2` | Updates `@ng-icons/core` from 33.1.0 to 33.2.0 - [Release notes](https://github.com/ng-icons/ng-icons/releases) - [Changelog](https://github.com/ng-icons/ng-icons/blob/main/CHANGELOG.md) - [Commits](https://github.com/ng-icons/ng-icons/commits/v33.2.0) Updates `@ng-icons/heroicons` from 33.1.0 to 33.2.0 - [Release notes](https://github.com/ng-icons/ng-icons/releases) - [Changelog](https://github.com/ng-icons/ng-icons/blob/main/CHANGELOG.md) - [Commits](https://github.com/ng-icons/ng-icons/commits/v33.2.0) Updates `katex` from 0.16.33 to 0.16.44 - [Release notes](https://github.com/KaTeX/KaTeX/releases) - [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md) - [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.33...v0.16.44) Updates `marked` from 17.0.3 to 17.0.5 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v17.0.3...v17.0.5) Updates `mermaid` from 11.12.3 to 11.13.0 - [Release notes](https://github.com/mermaid-js/mermaid/releases) - [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.3...mermaid@11.13.0) Updates `@tailwindcss/postcss` from 4.2.1 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/@tailwindcss-postcss) Updates `@vitest/coverage-v8` from 4.0.18 to 4.1.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/coverage-v8) Updates `postcss` from 8.5.6 to 8.5.8 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.8) Updates `tailwindcss` from 4.2.1 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss) Updates `vitest` from 4.0.18 to 4.1.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest) --- updated-dependencies: - dependency-name: "@ng-icons/core" dependency-version: 33.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: "@ng-icons/heroicons" dependency-version: 33.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: katex dependency-version: 0.16.44 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: marked dependency-version: 17.0.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: mermaid dependency-version: 11.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: "@tailwindcss/postcss" dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: postcss dependency-version: 8.5.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: tailwindcss dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: vitest dependency-version: 4.1.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the aws-cdk group (#90) Bumps the aws-cdk group in /infrastructure with 2 updates: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk). Updates `aws-cdk-lib` from 2.244.0 to 2.245.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.245.0/packages/aws-cdk-lib) Updates `aws-cdk` from 2.1113.0 to 2.1115.0 - [Release notes](https://github.com/aws/aws-cdk-cli/releases) - [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1115.0/packages/aws-cdk) --- updated-dependencies: - dependency-name: aws-cdk-lib dependency-version: 2.245.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-cdk - dependency-name: aws-cdk dependency-version: 2.1115.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump actions/setup-node from 5.0.0 to 6.3.0 (#100) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5.0.0 to 6.3.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/a0853c24544627f65ddf259abe73b1d18a591444...53b83947a5a98c8d113130e565377fae1a50d02f) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump github/codeql-action (#95) Bumps the actions-minor-patch group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 4.34.1 to 4.35.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/38697555549f1db7851b81482ff19f1fa5c4fedc...c10b8064de6f491fea524254123dbe5e09572f13) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump @types/node in /infrastructure (#94) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.1 to 25.5.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.5.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump jsdom in /frontend/ai.client (#102) Bumps [jsdom](https://github.com/jsdom/jsdom) from 27.4.0 to 29.0.1. - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v27.4.0...v29.0.1) --- updated-dependencies: - dependency-name: jsdom dependency-version: 29.0.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump ng2-charts in /frontend/ai.client (#105) Bumps [ng2-charts](https://github.com/valor-software/ng2-charts) from 8.0.0 to 10.0.0. - [Release notes](https://github.com/valor-software/ng2-charts/releases) - [Commits](https://github.com/valor-software/ng2-charts/compare/v8.0.0...v10.0.0) --- updated-dependencies: - dependency-name: ng2-charts dependency-version: 10.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the angular group (#97) Bumps the angular group in /frontend/ai.client with 10 updates: | Package | From | To | | --- | --- | --- | | [@angular/cdk](https://github.com/angular/components) | `21.2.3` | `21.2.4` | | [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `21.2.5` | `21.2.6` | | [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `21.2.5` | `21.2.6` | | [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `21.2.5` | `21.2.6` | | [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `21.2.5` | `21.2.6` | | [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `21.2.5` | `21.2.6` | | [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `21.2.5` | `21.2.6` | | [@angular/build](https://github.com/angular/angular-cli) | `21.2.3` | `21.2.5` | | [@angular/cli](https://github.com/angular/angular-cli) | `21.2.3` | `21.2.5` | | [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `21.2.5` | `21.2.6` | Updates `@angular/cdk` from 21.2.3 to 21.2.4 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.3...v21.2.4) Updates `@angular/common` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/common) Updates `@angular/compiler` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/compiler) Updates `@angular/core` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/core) Updates `@angular/forms` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/forms) Updates `@angular/platform-browser` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/platform-browser) Updates `@angular/router` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/router) Updates `@angular/build` from 21.2.3 to 21.2.5 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.3...v21.2.5) Updates `@angular/cli` from 21.2.3 to 21.2.5 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.3...v21.2.5) Updates `@angular/compiler-cli` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/compiler-cli) --- updated-dependencies: - dependency-name: "@angular/cdk" dependency-version: 21.2.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/common" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/core" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/forms" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/router" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/build" dependency-version: 21.2.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cli" dependency-version: 21.2.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler-cli" dependency-version: 21.2.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump jest and @types/jest in /infrastructure (#92) Bumps [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) and [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest). These dependencies needed to be updated together. Updates `jest` from 29.7.0 to 30.3.0 - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.3.0/packages/jest) Updates `@types/jest` from 29.5.14 to 30.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest) --- updated-dependencies: - dependency-name: jest dependency-version: 30.3.0 dependency-type: direct:development update-type: version-update:semver-major - dependency-name: "@types/jest" dependency-version: 30.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * add conversation deleting handling for shared conversations + bug fix * chore(deps)(deps): bump constructs (#91) Bumps the infra-minor-patch group in /infrastructure with 1 update: [constructs](https://github.com/aws/constructs). Updates `constructs` from 10.5.1 to 10.6.0 - [Release notes](https://github.com/aws/constructs/releases) - [Commits](https://github.com/aws/constructs/compare/v10.5.1...v10.6.0) --- updated-dependencies: - dependency-name: constructs dependency-version: 10.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: infra-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(messages): displayText support for RAG-augmented and file attachment messages (#107) * feat(messages): add displayText support for RAG-augmented messages - Add original_message parameter to stream_async and StreamCoordinator to preserve user input before RAG augmentation - Store displayText in message metadata when original message differs from augmented version - Add display_text field to MessageMetadata model with displayText alias for JSON serialization - Update chat_stream route to pass original message when RAG augmentation is applied - Enhance metadata retrieval to query both cost records (C#) and display text records (D#) from DynamoDB - Add store_user_display_text function to persist original message text for clean UI display - Update .gitignore to exclude local dev scripts (start.sh) - Improves user experience by showing original unaugmented messages in conversation UI while maintaining RAG-enhanced context for agent processing * feat(messages): add displayText support for file attachments and local runtime override - Add LOCAL_RUNTIME_ENDPOINT_URL environment variable support for development runtime override in auth routes - Extend displayText storage to handle file attachment content block modifications, not just RAG augmentation - Add message_will_be_modified logic to determine when original message should be stored as displayText - Implement showDebugOutput local settings signal for toggling debug information display - Update user message component to display original text when displayText is available - Add debug output toggle to chat preferences settings page - Update session metadata documentation to clarify displayText usage for all prompt modifications - Ensure original user message is preserved for UI display while augmented prompt remains in AgentCore Memory * test(metadata): add displayText (D# record) tests for store and retrieval * chore(deps): bump fast-check from 3.23.2 to 4.6.0 - Update fast-check to version 4.6.0 with caret constraint for minor/patch updates - Update pure-rand dependency to 4.6.0's requirement of ^8.0.0 (from ^6.1.0) - Increase minimum Node.js requirement from 8.0.0 to 12.17.0 - Migrate auth-pbt.spec.ts to use fast-check 4.x API (stringOf β†’ string with unit parameter) * chore(deps)(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#99) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump actions/download-artifact from 7.0.0 to 8.0.1 (#98) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7.0.0 to 8.0.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the python-minor-patch group in /backend with 10 updates (#96) * chore(deps)(deps): bump the python-minor-patch group Bumps the python-minor-patch group in /backend with 10 updates: | Package | From | To | | --- | --- | --- | | [uvicorn](https://github.com/Kludex/uvicorn) | `0.35.0` | `0.42.0` | | [boto3](https://github.com/boto/boto3) | `1.42.73` | `1.42.78` | | [strands-agents](https://github.com/strands-agents/sdk-python) | `1.32.0` | `1.33.0` | | [strands-agents-tools](https://github.com/strands-agents/tools) | `0.2.23` | `0.3.0` | | [aws-opentelemetry-distro](https://github.com/aws-observability/aws-otel-python-instrumentation) | `0.14.2` | `0.16.0` | | [bedrock-agentcore](https://github.com/aws/bedrock-agentcore-sdk-python) | `1.4.7` | `1.4.8` | | [openai](https://github.com/openai/openai-python) | `2.29.0` | `2.30.0` | | [google-genai](https://github.com/googleapis/python-genai) | `1.68.0` | `1.69.0` | | [hypothesis](https://github.com/HypothesisWorks/hypothesis) | `6.151.9` | `6.151.10` | | [ruff](https://github.com/astral-sh/ruff) | `0.15.7` | `0.15.8` | Updates `uvicorn` from 0.35.0 to 0.42.0 - [Release notes](https://github.com/Kludex/uvicorn/releases) - [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/uvicorn/compare/0.35.0...0.42.0) Updates `boto3` from 1.42.73 to 1.42.78 - [Release notes](https://github.com/boto/boto3/releases) - [Commits](https://github.com/boto/boto3/compare/1.42.73...1.42.78) Updates `strands-agents` from 1.32.0 to 1.33.0 - [Release notes](https://github.com/strands-agents/sdk-python/releases) - [Commits](https://github.com/strands-agents/sdk-python/compare/v1.32.0...v1.33.0) Updates `strands-agents-tools` from 0.2.23 to 0.3.0 - [Release notes](https://github.com/strands-agents/tools/releases) - [Commits](https://github.com/strands-agents/tools/compare/v0.2.23...v0.3.0) Updates `aws-opentelemetry-distro` from 0.14.2 to 0.16.0 - [Release notes](https://github.com/aws-observability/aws-otel-python-instrumentation/releases) - [Changelog](https://github.com/aws-observability/aws-otel-python-instrumentation/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-observability/aws-otel-python-instrumentation/compare/v0.14.2...v0.16.0) Updates `bedrock-agentcore` from 1.4.7 to 1.4.8 - [Release notes](https://github.com/aws/bedrock-agentcore-sdk-python/releases) - [Changelog](https://github.com/aws/bedrock-agentcore-sdk-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws/bedrock-agentcore-sdk-python/compare/v1.4.7...v1.4.8) Updates `openai` from 2.29.0 to 2.30.0 - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v2.29.0...v2.30.0) Updates `google-genai` from 1.68.0 to 1.69.0 - [Release notes](https://github.com/googleapis/python-genai/releases) - [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-genai/compare/v1.68.0...v1.69.0) Updates `hypothesis` from 6.151.9 to 6.151.10 - [Release notes](https://github.com/HypothesisWorks/hypothesis/releases) - [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.151.9...hypothesis-python-6.151.10) Updates `ruff` from 0.15.7 to 0.15.8 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.7...0.15.8) --- updated-dependencies: - dependency-name: uvicorn dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: boto3 dependency-version: 1.42.78 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: strands-agents dependency-version: 1.33.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: strands-agents-tools dependency-version: 0.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: aws-opentelemetry-distro dependency-version: 0.16.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: bedrock-agentcore dependency-version: 1.4.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: openai dependency-version: 2.30.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: google-genai dependency-version: 1.69.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: hypothesis dependency-version: 6.151.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: ruff dependency-version: 0.15.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] * chore(deps): downgrade cachetools to 6.2.4 - Downgrade cachetools from 7.0.5 to 6.2.4 in backend dependencies - Resolves compatibility issues with OAuth provider management --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: colinmxs * chore(deps): pin fast-check to exact version 4.6.0 - Remove caret (^) version constraint from fast-check dependency - Update package.json to use exact version 4.6.0 - Update package-lock.json to reflect pinned version - Ensure consistent dependency resolution across environments * feat: add fine-tuning cost dashboard and user cost breakdown (#108) * feat: add fine-tuning cost dashboard and user cost breakdown - Introduced new models for cost dashboard and user cost breakdown in the admin API. - Implemented endpoint to retrieve aggregated cost data for fine-tuning jobs. - Enhanced fine-tuning access control to support default monthly quota hours for users without explicit grants. - Added new routes and frontend components for displaying fine-tuning costs and usage statistics. - Updated infrastructure configuration to include default quota hours for fine-tuning. - Added tests to ensure proper functionality of new features and configurations. * fix(logging): improve log message formatting for cost dashboard request * fix(logging): sanitize period string in cost dashboard log message * check in share conversations specs * chore(docs): update versioning documentation and release notes for v1.0.0-beta.20 - Update versioning skill and rule documentation to include README.md version badge and "Current release" text in sync script scope - Update Kiro steering guide to document README.md and lockfile updates in version sync process - Bump version badge in README.md from v1.0.0-beta.19 to v1.0.0-beta.20 - Update "Current release" text in README.md to v1.0.0-beta.20 - Add comprehensive release notes for v1.0.0-beta.20 with highlights on document deletion, displayText system, fine-tuning cost dashboard, and dependency updates - Ensure all AI assistant rule files reflect current versioning workflow * ci(frontend): remove common scripts from workflow triggers and restrict CDK jobs to non-PR events - Remove 'scripts/common/**' from push and pull_request trigger paths - Add condition to synth-cdk job to skip execution on pull_request events - Update test-cdk job condition to exclude pull_request events while preserving skip_tests logic - Prevents unnecessary CDK synthesis and testing during pull requests to reduce workflow overhead * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Release 1.0.0-beta.20: Document soft-delete, displayText, fine-tuning costs, CodeQL remediation, dependency refresh (#118) Reliable document deletion, displayText for RAG-augmented messages, fine-tuning cost dashboard, assistant archive removal, and a full dependency refresh across Python, npm, and GitHub Actions. Features: - Soft-delete document lifecycle with background cleanup, retry logic, DynamoDB TTL backstop, and search filtering for mid-deletion docs - Upload failure reporting endpoint for client-side error tracking - DisplayText system preserving original user messages when RAG augmentation or file attachments modify the prompt sent to the agent - Debug output toggle in chat preferences for prompt inspection - Fine-tuning cost dashboard with per-user breakdowns and default monthly quota hours - Shared conversation cascade deletion on session delete Removals: - Assistant archive functionality (ARCHIVED status, archive endpoint, include_archived parameter) replaced with single delete operation Security & Code Quality: - All CodeQL findings resolved (180 log injection fixes, 5 silent exception fixes, cyclic import elimination, 13 unused variables) - Four Dependabot security patches (requests, picomatch, diff) CI/CD: - CDK synth skipped on PRs for app-api and frontend workflows - scripts/common/** removed from frontend workflow path triggers - GitHub Actions bumped (upload-artifact v7, download-artifact v8, setup-node v6, codeql-action latest) Testing: - Analog.js testing migration for frontend (vitest config removed) - fast-check v4.6.0 added for property-based frontend tests - 4,200+ lines of new backend tests for document deletion flows Tooling: - sync-version.sh now auto-updates README badge and current release text - Versioning steering docs updated across Kiro, Cursor, and Claude - Release notes steering doc added (fileMatch on RELEASE_NOTES.md) Dependencies: - Python: uvicorn 0.42.0, strands-agents 1.33.0, strands-agents-tools 0.3.0, aws-opentelemetry-distro 0.16.0, bedrock-agentcore 1.4.8, openai 2.30.0, cachetools downgraded to 6.2.4 for compatibility - Frontend: Angular 21.2.6, @angular/cdk 21.2.4 - Infrastructure: aws-cdk group bumped, constructs bumped * Purge outdated AI specs and documentation (#121) * spring cleaning. AI spec file and outdated documentation purge * spring cleanup --> purging old ai specs and outdated docs --------- Co-authored-by: colinmxs * Feat/cognito first boot auth (#125) * feat: replace multi-step auth bootstrap with Cognito first-boot experience - Add Cognito User Pool, App Client, and Domain to CDK infrastructure - Implement first-boot backend with race-condition-safe DynamoDB writes - Add CognitoJWTValidator replacing GenericOIDCJWTValidator - Add federated identity provider management via Cognito IdP APIs - Migrate frontend to Cognito OAuth 2.0 + PKCE flow - Add first-boot setup page with admin account creation - Update AgentCore Runtime to single Cognito JWT authorizer - Remove runtime-provisioner and runtime-updater Lambdas - Remove hardcoded Entra ID configuration from CDK and scripts - Remove auth provider seeding from bootstrap workflow - Wire SSM parameters across stacks for Cognito config - Update GitHub Actions workflows for Cognito context values * FOR TESTING ONLY< REVERT BEFORE MERGING * Feat/cognito first boot auth (#123) * test(auth-sweep): add system status endpoints to public route patterns - Add /system/status to PUBLIC_ROUTE_PATTERNS for unauthenticated access - Add /system/first-boot to PUBLIC_ROUTE_PATTERNS for unauthenticated access - These endpoints should be accessible without authentication for system initialization and health checks * chore(deps): add cognitoidp extra to moto dev dependency - Add cognitoidp extra to moto[dynamodb] in pyproject.toml dev dependencies - Update uv.lock to include cognitoidp extra across all moto references - Add joserfc package as transitive dependency for cognitoidp support - Enable Cognito IDP mocking capabilities for development and testing * test(auth-guard,config-service): add missing service mocks and config properties - Add SystemService mock to auth.guard.spec.ts test setup - Import SystemService dependency in auth guard test file - Add checkStatus mock method to systemService test double - Register SystemService provider in TestBed configuration - Add inferenceApiUrl property to validConfig test fixture in config.service.spec.ts - Ensure test doubles accurately reflect service dependencies for proper test isolation * feat(system-admin): add JWT role mapping for system_admin Cognito group - Add JWT_MAPPING#system_admin item to DynamoDB during bootstrap seeding - Update system_admin role jwtRoleMappings to include "system_admin" group - Implement add_user_to_group method in CognitoService to manage group membership - Add user to system_admin Cognito group during first_boot with rollback on failure - Update test assertions to verify JWT mapping creation and role configuration - Enables Cognito to include system_admin group in JWT cognito:groups claim for RBAC resolution * feat(infrastructure): add Cognito user and group management permissions - Add cognito-idp:AdminDeleteUser permission for user deletion operations - Add cognito-idp:AdminAddUserToGroup permission for group membership management - Add cognito-idp:CreateGroup permission for group creation operations - Enables system admin functionality for managing Cognito user pools and groups * refactor(auth): replace user email with name in logging and events - Replace user.email with user.name in quota event recorder metadata - Update admin cost dashboard logging to use user.name instead of email - Update admin users routes logging to use user.name instead of email - Update file upload routes logging to use user.name instead of email - Update model routes logging to use user.name instead of email - Update tools routes logging to use user.name instead of email - Update OAuth routes logging to use user.name instead of email - Update user models and routes logging to use user.name instead of email - Update auth service and user service in frontend to use name field - Standardize user identification across backend and frontend to use name for privacy and consistency * feat(frontend): update logos and URL-encode inference API ARN - Update logo-dark.png and logo-light.png assets - Add URL encoding for ARN portion in inferenceApiUrl computed signal - Prevent URL parsing errors caused by colons and slashes in AgentCore runtime ARNs - Improve config service documentation with encoding behavior explanation * fix(frontend): add /invocations path to inference API endpoints - Update preview-chat.service.ts to include /invocations path in runtime endpoint URL - Update chat-http.service.ts to include /invocations path in runtime endpoint URL - Fixes inference API calls by using correct endpoint path with qualifier parameter * feat(inference-api): add Authorization header to ALB request configuration - Add requestHeaderConfiguration to ALB listener rule - Include Authorization header in requestHeaderAllowlist - Enable proper header propagation for authenticated requests to inference API * refactor(auth): consolidate RBAC to AppRole-based authorization - Replace multiple role-checking functions with single require_app_roles dependency - Remove require_roles, require_all_roles, has_any_role, has_all_roles, and role-specific decorators (require_faculty, require_staff, require_developer, require_aws_ai_access) - Update rbac.py to resolve permissions through AppRoleService instead of hardcoded JWT groups - Simplify auth module exports to only expose require_app_roles and require_admin - Update admin routes to remove unused role imports - Add comprehensive docstring explaining AppRole system as single source of truth for permissions - Update tests to reflect new authorization flow via AppRoleService * feat(inference-api): add SSM parameters and environment variables to AgentCore runtime - Import DynamoDB table names from SSM parameters for users, RBAC, auth, OAuth, quota, cost tracking, and file uploads - Import S3 bucket and vector index names for RAG functionality - Import gateway URL and frontend CORS origins from SSM parameters - Add comprehensive environment variables to AgentCore runtime configuration including DynamoDB table mappings, authentication settings, OAuth configuration, AgentCore resource IDs, and directory paths - Enable authentication and quota enforcement in runtime environment - Configure frontend URL and CORS origins for cross-origin requests * feat(inference-api): remove gateway URL parameter and simplify CORS origins - Remove SSM parameter import for gateway URL from InferenceApiStack - Remove GATEWAY_URL environment variable from AgentCore runtime configuration - Replace SSM-imported CORS origins with config-based construction to avoid circular dependency between InferenceApiStack and FrontendStack - Construct CORS origins dynamically from config.domainName (https://{domain}) with localhost fallback for development - Eliminates circular dependency: InferenceApiStack ↔ FrontendStack by removing reliance on FrontendStack SSM parameters * chore(frontend): update favicon and logo assets - Remove redundant favicon PNG variants (android-chrome, apple-touch-icon, favicon-16x16, favicon-32x32) - Update favicon.ico with new design - Update logo-dark.png with refreshed branding - Consolidate favicon assets to reduce redundancy and improve maintainability * feat(inference-api): add AWS Marketplace permissions for Bedrock model access - Add MarketplaceModelAccess policy statement to runtime execution role - Grant aws-marketplace:ViewSubscriptions and aws-marketplace:Subscribe actions - Enable foundation model access for marketplace-gated models like Anthropic Claude - Required for subscription validation before Bedrock model invocation * Release 1.0.0-beta.19: Conversation sharing, session compaction, fine-tuning enhancements, CI optimization ## Features - Conversation sharing with public/email-restricted access via shareable URLs - Session compaction enabled by default (100K token threshold, 3 protected turns) - Fine-tuning: drag-and-drop dataset upload, custom HuggingFace model support ## Security - Resolve all CodeQL clear-text logging alerts (secrets, tokens, ARNs redacted) - OAuth redirect URL validation to prevent open redirects - Explicit read-only permissions on all 13 GitHub Actions workflows ## Performance - Frontend production build optimized: 8.85 MB β†’ 4.96 MB (871 KB gzipped) - PR workflows trimmed: skip Docker builds, CDK synth, and redundant jobs ## Infrastructure - New shared-conversations DynamoDB table with SessionShare and OwnerShare GSIs - Bedrock prompt caching temporarily disabled due to provider limitations ## Bug Fixes - Google Fonts moved to index.html to fix CI build failure - Private sharing support (owner-only shares) * Release 1.0.0-beta.20: Document soft-delete, displayText, fine-tuning costs, CodeQL remediation, dependency refresh Reliable document deletion, displayText for RAG-augmented messages, fine-tuning cost dashboard, assistant archive removal, and a full dependency refresh across Python, npm, and GitHub Actions. Features: - Soft-delete document lifecycle with background cleanup, retry logic, DynamoDB TTL backstop, and search filtering for mid-deletion docs - Upload failure reporting endpoint for client-side error tracking - DisplayText system preserving original user messages when RAG augmentation or file attachments modify the prompt sent to the agent - Debug output toggle in chat preferences for prompt inspection - Fine-tuning cost dashboard with per-user breakdowns and default monthly quota hours - Shared conversation cascade deletion on session delete Removals: - Assistant archive functionality (ARCHIVED status, archive endpoint, include_archived parameter) replaced with single delete operation Security & Code Quality: - All CodeQL findings resolved (180 log injection fixes, 5 silent exception fixes, cyclic import elimination, 13 unused variables) - Four Dependabot security patches (requests, picomatch, diff) CI/CD: - CDK synth skipped on PRs for app-api and frontend workflows - scripts/common/** removed from frontend workflow path triggers - GitHub Actions bumped (upload-artifact v7, download-artifact v8, setup-node v6, codeql-action latest) Testing: - Analog.js testing migration for frontend (vitest config removed) - fast-check v4.6.0 added for property-based frontend tests - 4,200+ lines of new backend tests for document deletion flows Tooling: - sync-version.sh now auto-updates README badge and current release text - Versioning steering docs updated across Kiro, Cursor, and Claude - Release notes steering doc added (fileMatch on RELEASE_NOTES.md) Dependencies: - Python: uvicorn 0.42.0, strands-agents 1.33.0, strands-agents-tools 0.3.0, aws-opentelemetry-distro 0.16.0, bedrock-agentcore 1.4.8, openai 2.30.0, cachetools downgraded to 6.2.4 for compatibility - Frontend: Angular 21.2.6, @angular/cdk 21.2.4 - Infrastructure: aws-cdk group bumped, constructs bumped * refactor(inference-api): remove underscore prefix from containerImageUri variable - Remove underscore prefix from containerImageUri variable name - Improve code clarity by following standard naming conventions - Variable is used throughout the stack and should follow public naming patterns * chore(deps): add cognitoidp moto extra and update infrastructure tests - Add cognitoidp extra to moto dependency in uv.lock for Cognito IDP mocking support - Update moto dependency extras to include both cognitoidp and dynamodb - Refactor IAM policy assertions in inference-api-stack tests to search both inline and managed policies - Simplify policy verification logic to use findResources and filter by policy attributes - Remove S3 bucket and S3 Vector Store test sections from app-api-stack tests - Update test assertions to be more flexible with policy resource types * chore(frontend): update favicon and logo assets - Add Android Chrome favicon variants (192x192 and 512x512) - Add Apple Touch Icon for iOS devices - Add favicon sizes for 16x16 and 32x32 resolutions - Update favicon.ico with new design - Update logo-dark.png with refreshed branding - Update logo-light.png with refreshed branding - Improve cross-platform icon support and visual consistency * fix(auth-providers): remove OIDC discovery endpoint and add JSON parsing error handling - Remove POST /discover endpoint for OIDC endpoint discovery from admin routes - Add try-except blocks to handle JSON parsing errors in AuthProviderRepository - Gracefully default to empty dict when SecretString is invalid or malformed - Improve resilience when retrieving auth provider secrets from AWS Secrets Manager --------- Co-authored-by: Colin * Feat/cognito first boot auth (#124) * test(auth-sweep): add system status endpoints to public route patterns - Add /system/status to PUBLIC_ROUTE_PATTERNS for unauthenticated access - Add /system/first-boot to PUBLIC_ROUTE_PATTERNS for unauthenticated access - These endpoints should be accessible without authentication for system initialization and health checks * chore(deps): add cognitoidp extra to moto dev dependency - Add cognitoidp extra to moto[dynamodb] in pyproject.toml dev dependencies - Update uv.lock to include cognitoidp extra across all moto references - Add joserfc package as transitive dependency for cognitoidp support - Enable Cognito IDP mocking capabilities for development and testing * test(auth-guard,config-service): add missing service mocks and config properties - Add SystemService mock to auth.guard.spec.ts test setup - Import SystemService dependency in auth guard test file - Add checkStatus mock method to systemService test double - Register SystemService provider in TestBed configuration - Add inferenceApiUrl property to validConfig test fixture in config.service.spec.ts - Ensure test doubles accurately reflect service dependencies for proper test isolation * feat(system-admin): add JWT role mapping for system_admin Cognito group - Add JWT_MAPPING#system_admin item to DynamoDB during bootstrap seeding - Update system_admin role jwtRoleMappings to include "system_admin" group - Implement add_user_to_group method in CognitoService to manage group membership - Add user to system_admin Cognito group during first_boot with rollback on failure - Update test assertions to verify JWT mapping creation and role configuration - Enables Cognito to include system_admin group in JWT cognito:groups claim for RBAC resolution * feat(infrastructure): add Cognito user and group management permissions - Add cognito-idp:AdminDeleteUser permissio… * Backmerge/main into develop (#514) * Release/v1.0.0 beta.25 (#282) * Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * docs(readme): update version badges and tech stack to v1.0.0-beta.18 - Update release badge from v1.0.0-beta.17 to v1.0.0-beta.18 - Bump Tailwind CSS version from v4.1 to v4.2 in all references - Update current release version in release notes section - Reflect latest dependency versions in architecture and tech stack documentation * feat(embeddings): add optional token validation bypass for search queries - Add skip_token_validation parameter to generate_embeddings function - Allow skipping tiktoken-based token validation for short inputs where tiktoken may not be installed - Update search_assistant_knowledgebase to skip validation for query embeddings - Enables embedding generation in environments where tiktoken is unavailable (e.g., search Lambda) * refactor(embeddings): extract shared embedding logic to separate module - Move core embedding generation and vector store operations to apis.shared.embeddings - Create new shared bedrock_embeddings module with generate_embeddings, store_embeddings_in_s3, search_assistant_knowledgebase, and delete_vectors_for_document - Extract vector search logic to new apis.shared.assistants.vector_search module - Keep ingestion-specific token validation (tiktoken-based) in app_api embeddings module - Update ingestion embeddings module to re-export shared functions for backward compatibility - Simplify bedrock_embeddings in ingestion pipeline to focus on chunk validation and splitting - Update imports across documents routes and rag_service to use new shared modules - Reduces code duplication and establishes clear separation between shared RAG infrastructure and ingestion-specific concerns * docs(release-notes): document v1.0.0-beta.19 features and fixes - Add Angular production build optimization section explaining minification and tree-shaking enablement - Document embeddings refactor extracting shared logic to apis.shared.embeddings module - Add skip_token_validation parameter documentation for generate_embeddings function - Update highlights section to mention Angular production build optimization - Clarify CodeQL workflow improvements and unused import/variable cleanup - Enable optimization flag in angular.json production configuration for reduced bundle size * docs(release-notes): remove Angular optimization section and revert config - Remove "Frontend Production Build Optimization" section from release notes - Revert optimization flag removal from angular.json production configuration - Align documentation with actual production build configuration state * feat: add API Keys section to README for programmatic access to AI models * fix(model_config): comment out caching configuration due to Bedrock limitations * feat(create-training-job): enhance file upload with drag-and-drop support and update dataset upload instructions * feat(create-training-job): add support for custom HuggingFace models and enhance model search functionality * fix(test_model_config): remove caching mock and update test for Bedrock config caching behavior * feat(create-training-job): add tests for custom HuggingFace model selection and submission * feat: refactor session compaction and enable by default (#86) * feat: update compaction configuration and enhance session manager tests * fix: update tests for compaction defaults and commented-out caching - Update compaction model test to expect enabled=True and protected_turns=3 - Fix caching test to reflect cache_config being commented out due to Bedrock limitations Co-Authored-By: Claude Opus 4.6 * feat(create-training-job): enhance file upload with drag-and-drop support and update dataset upload instructions * feat(create-training-job): add support for custom HuggingFace models and enhance model search functionality * fix(test_model_config): remove caching mock and update test for Bedrock config caching behavior * feat(create-training-job): add tests for custom HuggingFace model selection and submission * fix: update tests for compaction defaults and commented-out caching - Update compaction model test to expect enabled=True and protected_turns=3 - Fix caching test to reflect cache_config being commented out due to Bedrock limitations Co-Authored-By: Claude Opus 4.6 --------- Signed-off-by: Phil Merrell Co-authored-by: Claude Opus 4.6 * test(to_bedrock_config): add missing result assignment in caching disabled test * Potential fix for code scanning alert no. 41: Clear-text logging of s… (#85) * Potential fix for code scanning alert no. 41: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * ci: Add explicit read-only permissions to all workflows - Add `permissions: contents: read` to 13 GitHub Actions workflows - Workflows updated: app-api, bootstrap-data-seeding, codeql, frontend, gateway, inference-api, infrastructure, nightly-deploy-pipeline, nightly, rag-ingestion, release, sagemaker-fine-tuning, version-check - Implements principle of least privilege by explicitly declaring minimal required permissions - Improves security posture and aligns with GitHub Actions best practices * fix(security): Redact sensitive information from logs - Mask client ID in seed_auth_provider output, showing only first 8 characters - Redact full Secrets ARN in seed_auth_provider, displaying only resource name - Replace full exception objects with error codes in seed_bootstrap_data error messages - Downgrade MCP client configuration logging from info to debug level - Remove user ID from OAuth token retrieval and re-auth status log messages - Add URL validation to OAuth callback redirect to prevent open redirect vulnerabilities - Prevents accidental exposure of credentials and sensitive identifiers in application logs * fix(security): Resolve remaining CodeQL clear-text logging alerts - seed_auth_provider: Fully redact Secrets Manager ARN from output - external_mcp_client: Remove server URL from logs, decouple oauth_token from log expressions - oauth_tool_service: Isolate decrypted token into _try_get_token() to prevent taint bleed, use lazy log formatting - config.ts: Remove AWS account ID and CORS origins from CDK config log output * Potential fix for code scanning alert no. 499: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 496: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 498: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 497: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> --------- Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: colinmxs * feat(frontend): enable production optimization, branch-aware BUILD_CONFIG - Remove optimization: false from base options (was blocking prod override) - Production: optimization, no source maps, extract licenses - Fix anyComponentStyle budget from 4kB to 200kB for Tailwind - BUILD_CONFIG: mainβ†’production, developβ†’development, dispatchβ†’manual input Production build: 4.96 MB initial (871 KB gzip) vs 8.85 MB unoptimized * fix: move Google Fonts import to index.html to prevent CI build failure * ci: skip docker builds and CDK synth on pull requests * implement conversation sharing. (#87) * implement conversation sharing. * Potential fix for code scanning alert no. 509: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: ofilson * Potential fix for code scanning alert no. 510: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: ofilson * fix github warnings * fix log issue --------- Signed-off-by: ofilson Co-authored-by: Oscar Filson Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Allow for private share (only with yourself) * release: v1.0.0-beta.19 * fix float error on sharing * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * ci: skip redundant stack dependency checks on PRs (keep infrastructure only) * ci: skip install on PRs for rag-ingestion (no downstream jobs) * ci: revert check-stack-deps skip on workflows with PR jobs, skip entire gateway/sagemaker on PRs * fix(security): resolve CodeQL log-injection, unused-import, and unused-variable alerts - Remove user-controlled values from 180 log f-strings (py/log-injection) - Remove 87 unused Python imports (py/unused-import) - Remove 27 unused JS/TS variables (js/unused-local-variable) - Fix 3 useless assignments (js/useless-assignment-to-local) - Fix 1 incompatible type comparison (js/comparison-between-incompatible-types) * fix(tests): remove stale AgentCoreMemorySessionManager patch from session factory tests The CodeQL commit removed the unused AgentCoreMemorySessionManager import from session_factory.py, breaking two tests that patched it at that path. Removed the unnecessary patch decorator since TurnBasedSessionManager was already being patched separately. * chore(docker): add shared embeddings module to rag-ingestion Lambda image - Copy shared embeddings package to Lambda task root directory - Add apis/__init__.py to ensure proper Python package structure - Enable ingestion embeddings to access re-exported shared embeddings module - Resolves import errors when bedrock_embeddings.py loads shared embeddings * fix(quality): resolve all open CodeQL findings on develop Empty excepts (5 fixes): - url_fetcher: narrow bare except to Exception, add comment - code_interpreter_diagram_tool: narrow bare except to Exception - tool_result_processor: add explanatory comment to JSONDecodeError catch - users/service: log warning on invalid pagination cursor - event_formatter: log warning instead of silently swallowing errors Catch BaseException (2 fixes): - url_fetcher: narrowed to Exception (same fix as empty except) - code_interpreter_diagram_tool: narrowed to Exception Unreachable code (1 fix): - stream_processor: remove dead if result_seen: break (never set to True) Redundant assignment (1 fix): - fine_tuning/routes: remove unused job = on create_inference_job Print during import (1 fix): - inference_api/main: replace print() with logging Commented-out code (1 fix): - inference_api/chat/models: remove commented InvocationRequest class Unnecessary lambdas (2 fixes): - job_repository, inference_repository: lambda v: int(v) β†’ int Unused local variables (13 fixes): - Remove or rename: period, user_id, error_msg, matches, requested_set, exception_type, updated, limit, preferences, execution_output, next_month, next_year across 10 files Unused imports (3 fixes): - compaction_models: remove unused field import - bedrock_embeddings: remove dead re-exports, clean up __init__.py - timezone: use find_spec for pytz availability check Cyclic import (1 fix): - Move get_metadata_storage() factory from metadata_storage.py to storage/__init__.py, breaking the metadata_storage ↔ dynamodb_storage cycle. Update 3 callers to import from apis.app_api.storage. Dismissed as false positives (11 alerts): - 9x untrusted-checkout on nightly workflows (schedule/dispatch only) - 1x non-iterable for-loop (Enum is iterable) - 1x unused global _generic_validator_initialized (global stmt tracking) * fix(deps): patch Dependabot security vulnerabilities - requests 2.32.5 β†’ 2.33.0 (insecure temp file reuse, CVE) - picomatch 4.0.3 β†’ 4.0.4 (frontend, ReDoS + method injection, via override) - picomatch 2.3.1 β†’ 2.3.2 (infrastructure, method injection, via override) - diff 4.0.x β†’ patched (infrastructure, DoS in parsePatch, via audit fix) Unfixable: - yaml 1.10.2 bundled inside aws-cdk-lib 2.244.0 (latest) β€” awaiting AWS CDK update - Pygments 2.19.2 (latest) β€” no patched version released yet * fix(rag-ingestion): ensure Lambda uses latest image digest on deploy - Add FUNCTION_NAME variable to capture Lambda function identifier - Update Lambda function code explicitly after image push to force digest refresh - Add wait condition to ensure function update completes before deployment succeeds - Remove outdated next steps logging that duplicated deployment completion message - Resolve issue where CDK's SSM-resolved image tags don't trigger updates when underlying image layers change, causing CloudFormation to report no changes despite fresh image push * fix share issues and icon tweaks * release: v1.0.0-beta.20 * fix(rag-ingestion): restore shared embedding re-exports for Lambda handler The CodeQL fix removed re-exports from bedrock_embeddings.py, but the RAG ingestion Lambda handler imports generate_embeddings and store_embeddings_in_s3 from embeddings.bedrock_embeddings (Lambda task root path). Restored re-exports with __all__ and explanatory comments. * feat(documents): add upload failure reporting and assistant cleanup - Add ReportUploadFailureRequest model for client-side upload error reporting - Implement POST /{document_id}/upload-failed endpoint to mark documents as failed - Add update_document_status service function to update document status and error details - Implement background cleanup of vectors and S3 objects when assistant is deleted - Add delete_vectors_for_assistant function to remove embeddings from vector store - Update document routes to import new models and service functions - Add start.sh to .gitignore - Update bedrock_embeddings to support vector deletion by assistant ID - Enhance frontend document service to handle upload failure reporting - Improve assistant deletion flow with proper resource cleanup and error handling * feat(assistants): remove archive functionality and simplify deletion - Remove archive_assistant service function and endpoint - Simplify delete operation to single hard delete without archive option - Remove include_archived query parameter from list assistants endpoint - Remove ARCHIVED status from assistant status enum - Update frontend assistant model and services to remove archive references - Simplify assistant lifecycle by consolidating soft and hard delete into single delete operation - Update API documentation and test examples to reflect deletion changes * feat(frontend): upgrade Analog.js testing dependencies and remove vitest config - Add @analogjs/vite-plugin-angular and @analogjs/vitest-angular v3.0.0-alpha.18 - Update package-lock.json with new dependency tree and transitive dependencies - Remove vitest.config.ts in favor of Analog.js configuration - Update app.config.spec.ts and tool-rail.component.spec.ts test files - Modernize Angular testing setup with latest Analog.js tooling * feat(documents): implement reliable deletion with soft-delete and cleanup retries - Add deleting status to document lifecycle and TTL field for auto-expiry - Create cleanup_service.py with retry logic for S3 vectors and source file deletion - Implement soft-delete pattern: mark documents as deleting, return immediately, cleanup asynchronously - Update search path to filter out non-complete documents and prevent stale results - Add batch soft-delete for assistant deletion with background cleanup - Implement deterministic vector key generation for reliable cleanup - Add comprehensive property-based and integration tests for deletion flows - Update RAG service to cross-check document status during search - Configure DynamoDB TTL as backstop for failed cleanups (7-day expiry) - Add Kiro spec documentation for reliable document deletion design * test(assistants): remove archive assistant test and fix package dependencies - Remove test_archive_assistant test case as archive functionality was removed - Update package-lock.json to fix dependency flags for Angular DevKit and related packages - Change chokidar dev flag to devOptional to reflect optional development dependency - Remove unnecessary dev flags from multiple dependencies (ajv, chalk, cli-cursor, fast-deep-equal, and others) - Align package metadata with current project configuration * chore(frontend): pin Analog.js dependencies to exact versions - Remove caret (^) version specifiers from @analogjs/vite-plugin-angular - Remove caret (^) version specifiers from @analogjs/vitest-angular - Lock both packages to 3.0.0-alpha.18 for consistent builds - Prevent unexpected minor/patch updates that could introduce breaking changes * chore(frontend): pin Analog.js devDependencies to exact versions - Update @analogjs/vite-plugin-angular from ^3.0.0-alpha.18 to 3.0.0-alpha.18 - Update @analogjs/vitest-angular from ^3.0.0-alpha.18 to 3.0.0-alpha.18 - Remove caret (^) prefix to lock exact versions and ensure consistent builds * feat(fine-tuning-dashboard): add informational section about fine-tuning and update icons * chore(deps)(deps): bump the frontend-minor-patch group (#101) Bumps the frontend-minor-patch group in /frontend/ai.client with 10 updates: | Package | From | To | | --- | --- | --- | | [@ng-icons/core](https://github.com/ng-icons/ng-icons) | `33.1.0` | `33.2.0` | | [@ng-icons/heroicons](https://github.com/ng-icons/ng-icons) | `33.1.0` | `33.2.0` | | [katex](https://github.com/KaTeX/KaTeX) | `0.16.33` | `0.16.44` | | [marked](https://github.com/markedjs/marked) | `17.0.3` | `17.0.5` | | [mermaid](https://github.com/mermaid-js/mermaid) | `11.12.3` | `11.13.0` | | [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.2.1` | `4.2.2` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.0.18` | `4.1.2` | | [postcss](https://github.com/postcss/postcss) | `8.5.6` | `8.5.8` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.1` | `4.2.2` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.0.18` | `4.1.2` | Updates `@ng-icons/core` from 33.1.0 to 33.2.0 - [Release notes](https://github.com/ng-icons/ng-icons/releases) - [Changelog](https://github.com/ng-icons/ng-icons/blob/main/CHANGELOG.md) - [Commits](https://github.com/ng-icons/ng-icons/commits/v33.2.0) Updates `@ng-icons/heroicons` from 33.1.0 to 33.2.0 - [Release notes](https://github.com/ng-icons/ng-icons/releases) - [Changelog](https://github.com/ng-icons/ng-icons/blob/main/CHANGELOG.md) - [Commits](https://github.com/ng-icons/ng-icons/commits/v33.2.0) Updates `katex` from 0.16.33 to 0.16.44 - [Release notes](https://github.com/KaTeX/KaTeX/releases) - [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md) - [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.33...v0.16.44) Updates `marked` from 17.0.3 to 17.0.5 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v17.0.3...v17.0.5) Updates `mermaid` from 11.12.3 to 11.13.0 - [Release notes](https://github.com/mermaid-js/mermaid/releases) - [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.3...mermaid@11.13.0) Updates `@tailwindcss/postcss` from 4.2.1 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/@tailwindcss-postcss) Updates `@vitest/coverage-v8` from 4.0.18 to 4.1.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/coverage-v8) Updates `postcss` from 8.5.6 to 8.5.8 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.8) Updates `tailwindcss` from 4.2.1 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss) Updates `vitest` from 4.0.18 to 4.1.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest) --- updated-dependencies: - dependency-name: "@ng-icons/core" dependency-version: 33.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: "@ng-icons/heroicons" dependency-version: 33.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: katex dependency-version: 0.16.44 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: marked dependency-version: 17.0.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: mermaid dependency-version: 11.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: "@tailwindcss/postcss" dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: postcss dependency-version: 8.5.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: tailwindcss dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: vitest dependency-version: 4.1.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the aws-cdk group (#90) Bumps the aws-cdk group in /infrastructure with 2 updates: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk). Updates `aws-cdk-lib` from 2.244.0 to 2.245.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.245.0/packages/aws-cdk-lib) Updates `aws-cdk` from 2.1113.0 to 2.1115.0 - [Release notes](https://github.com/aws/aws-cdk-cli/releases) - [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1115.0/packages/aws-cdk) --- updated-dependencies: - dependency-name: aws-cdk-lib dependency-version: 2.245.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-cdk - dependency-name: aws-cdk dependency-version: 2.1115.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump actions/setup-node from 5.0.0 to 6.3.0 (#100) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5.0.0 to 6.3.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/a0853c24544627f65ddf259abe73b1d18a591444...53b83947a5a98c8d113130e565377fae1a50d02f) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump github/codeql-action (#95) Bumps the actions-minor-patch group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 4.34.1 to 4.35.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/38697555549f1db7851b81482ff19f1fa5c4fedc...c10b8064de6f491fea524254123dbe5e09572f13) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump @types/node in /infrastructure (#94) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.1 to 25.5.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.5.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump jsdom in /frontend/ai.client (#102) Bumps [jsdom](https://github.com/jsdom/jsdom) from 27.4.0 to 29.0.1. - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v27.4.0...v29.0.1) --- updated-dependencies: - dependency-name: jsdom dependency-version: 29.0.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump ng2-charts in /frontend/ai.client (#105) Bumps [ng2-charts](https://github.com/valor-software/ng2-charts) from 8.0.0 to 10.0.0. - [Release notes](https://github.com/valor-software/ng2-charts/releases) - [Commits](https://github.com/valor-software/ng2-charts/compare/v8.0.0...v10.0.0) --- updated-dependencies: - dependency-name: ng2-charts dependency-version: 10.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the angular group (#97) Bumps the angular group in /frontend/ai.client with 10 updates: | Package | From | To | | --- | --- | --- | | [@angular/cdk](https://github.com/angular/components) | `21.2.3` | `21.2.4` | | [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `21.2.5` | `21.2.6` | | [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `21.2.5` | `21.2.6` | | [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `21.2.5` | `21.2.6` | | [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `21.2.5` | `21.2.6` | | [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `21.2.5` | `21.2.6` | | [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `21.2.5` | `21.2.6` | | [@angular/build](https://github.com/angular/angular-cli) | `21.2.3` | `21.2.5` | | [@angular/cli](https://github.com/angular/angular-cli) | `21.2.3` | `21.2.5` | | [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `21.2.5` | `21.2.6` | Updates `@angular/cdk` from 21.2.3 to 21.2.4 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.3...v21.2.4) Updates `@angular/common` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/common) Updates `@angular/compiler` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/compiler) Updates `@angular/core` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/core) Updates `@angular/forms` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/forms) Updates `@angular/platform-browser` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/platform-browser) Updates `@angular/router` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/router) Updates `@angular/build` from 21.2.3 to 21.2.5 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.3...v21.2.5) Updates `@angular/cli` from 21.2.3 to 21.2.5 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.3...v21.2.5) Updates `@angular/compiler-cli` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/compiler-cli) --- updated-dependencies: - dependency-name: "@angular/cdk" dependency-version: 21.2.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/common" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/core" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/forms" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/router" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/build" dependency-version: 21.2.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cli" dependency-version: 21.2.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler-cli" dependency-version: 21.2.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump jest and @types/jest in /infrastructure (#92) Bumps [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) and [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest). These dependencies needed to be updated together. Updates `jest` from 29.7.0 to 30.3.0 - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.3.0/packages/jest) Updates `@types/jest` from 29.5.14 to 30.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest) --- updated-dependencies: - dependency-name: jest dependency-version: 30.3.0 dependency-type: direct:development update-type: version-update:semver-major - dependency-name: "@types/jest" dependency-version: 30.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * add conversation deleting handling for shared conversations + bug fix * chore(deps)(deps): bump constructs (#91) Bumps the infra-minor-patch group in /infrastructure with 1 update: [constructs](https://github.com/aws/constructs). Updates `constructs` from 10.5.1 to 10.6.0 - [Release notes](https://github.com/aws/constructs/releases) - [Commits](https://github.com/aws/constructs/compare/v10.5.1...v10.6.0) --- updated-dependencies: - dependency-name: constructs dependency-version: 10.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: infra-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(messages): displayText support for RAG-augmented and file attachment messages (#107) * feat(messages): add displayText support for RAG-augmented messages - Add original_message parameter to stream_async and StreamCoordinator to preserve user input before RAG augmentation - Store displayText in message metadata when original message differs from augmented version - Add display_text field to MessageMetadata model with displayText alias for JSON serialization - Update chat_stream route to pass original message when RAG augmentation is applied - Enhance metadata retrieval to query both cost records (C#) and display text records (D#) from DynamoDB - Add store_user_display_text function to persist original message text for clean UI display - Update .gitignore to exclude local dev scripts (start.sh) - Improves user experience by showing original unaugmented messages in conversation UI while maintaining RAG-enhanced context for agent processing * feat(messages): add displayText support for file attachments and local runtime override - Add LOCAL_RUNTIME_ENDPOINT_URL environment variable support for development runtime override in auth routes - Extend displayText storage to handle file attachment content block modifications, not just RAG augmentation - Add message_will_be_modified logic to determine when original message should be stored as displayText - Implement showDebugOutput local settings signal for toggling debug information display - Update user message component to display original text when displayText is available - Add debug output toggle to chat preferences settings page - Update session metadata documentation to clarify displayText usage for all prompt modifications - Ensure original user message is preserved for UI display while augmented prompt remains in AgentCore Memory * test(metadata): add displayText (D# record) tests for store and retrieval * chore(deps): bump fast-check from 3.23.2 to 4.6.0 - Update fast-check to version 4.6.0 with caret constraint for minor/patch updates - Update pure-rand dependency to 4.6.0's requirement of ^8.0.0 (from ^6.1.0) - Increase minimum Node.js requirement from 8.0.0 to 12.17.0 - Migrate auth-pbt.spec.ts to use fast-check 4.x API (stringOf β†’ string with unit parameter) * chore(deps)(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#99) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump actions/download-artifact from 7.0.0 to 8.0.1 (#98) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7.0.0 to 8.0.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the python-minor-patch group in /backend with 10 updates (#96) * chore(deps)(deps): bump the python-minor-patch group Bumps the python-minor-patch group in /backend with 10 updates: | Package | From | To | | --- | --- | --- | | [uvicorn](https://github.com/Kludex/uvicorn) | `0.35.0` | `0.42.0` | | [boto3](https://github.com/boto/boto3) | `1.42.73` | `1.42.78` | | [strands-agents](https://github.com/strands-agents/sdk-python) | `1.32.0` | `1.33.0` | | [strands-agents-tools](https://github.com/strands-agents/tools) | `0.2.23` | `0.3.0` | | [aws-opentelemetry-distro](https://github.com/aws-observability/aws-otel-python-instrumentation) | `0.14.2` | `0.16.0` | | [bedrock-agentcore](https://github.com/aws/bedrock-agentcore-sdk-python) | `1.4.7` | `1.4.8` | | [openai](https://github.com/openai/openai-python) | `2.29.0` | `2.30.0` | | [google-genai](https://github.com/googleapis/python-genai) | `1.68.0` | `1.69.0` | | [hypothesis](https://github.com/HypothesisWorks/hypothesis) | `6.151.9` | `6.151.10` | | [ruff](https://github.com/astral-sh/ruff) | `0.15.7` | `0.15.8` | Updates `uvicorn` from 0.35.0 to 0.42.0 - [Release notes](https://github.com/Kludex/uvicorn/releases) - [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/uvicorn/compare/0.35.0...0.42.0) Updates `boto3` from 1.42.73 to 1.42.78 - [Release notes](https://github.com/boto/boto3/releases) - [Commits](https://github.com/boto/boto3/compare/1.42.73...1.42.78) Updates `strands-agents` from 1.32.0 to 1.33.0 - [Release notes](https://github.com/strands-agents/sdk-python/releases) - [Commits](https://github.com/strands-agents/sdk-python/compare/v1.32.0...v1.33.0) Updates `strands-agents-tools` from 0.2.23 to 0.3.0 - [Release notes](https://github.com/strands-agents/tools/releases) - [Commits](https://github.com/strands-agents/tools/compare/v0.2.23...v0.3.0) Updates `aws-opentelemetry-distro` from 0.14.2 to 0.16.0 - [Release notes](https://github.com/aws-observability/aws-otel-python-instrumentation/releases) - [Changelog](https://github.com/aws-observability/aws-otel-python-instrumentation/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-observability/aws-otel-python-instrumentation/compare/v0.14.2...v0.16.0) Updates `bedrock-agentcore` from 1.4.7 to 1.4.8 - [Release notes](https://github.com/aws/bedrock-agentcore-sdk-python/releases) - [Changelog](https://github.com/aws/bedrock-agentcore-sdk-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws/bedrock-agentcore-sdk-python/compare/v1.4.7...v1.4.8) Updates `openai` from 2.29.0 to 2.30.0 - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v2.29.0...v2.30.0) Updates `google-genai` from 1.68.0 to 1.69.0 - [Release notes](https://github.com/googleapis/python-genai/releases) - [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-genai/compare/v1.68.0...v1.69.0) Updates `hypothesis` from 6.151.9 to 6.151.10 - [Release notes](https://github.com/HypothesisWorks/hypothesis/releases) - [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.151.9...hypothesis-python-6.151.10) Updates `ruff` from 0.15.7 to 0.15.8 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.7...0.15.8) --- updated-dependencies: - dependency-name: uvicorn dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: boto3 dependency-version: 1.42.78 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: strands-agents dependency-version: 1.33.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: strands-agents-tools dependency-version: 0.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: aws-opentelemetry-distro dependency-version: 0.16.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: bedrock-agentcore dependency-version: 1.4.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: openai dependency-version: 2.30.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: google-genai dependency-version: 1.69.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: hypothesis dependency-version: 6.151.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: ruff dependency-version: 0.15.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] * chore(deps): downgrade cachetools to 6.2.4 - Downgrade cachetools from 7.0.5 to 6.2.4 in backend dependencies - Resolves compatibility issues with OAuth provider management --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: colinmxs * chore(deps): pin fast-check to exact version 4.6.0 - Remove caret (^) version constraint from fast-check dependency - Update package.json to use exact version 4.6.0 - Update package-lock.json to reflect pinned version - Ensure consistent dependency resolution across environments * feat: add fine-tuning cost dashboard and user cost breakdown (#108) * feat: add fine-tuning cost dashboard and user cost breakdown - Introduced new models for cost dashboard and user cost breakdown in the admin API. - Implemented endpoint to retrieve aggregated cost data for fine-tuning jobs. - Enhanced fine-tuning access control to support default monthly quota hours for users without explicit grants. - Added new routes and frontend components for displaying fine-tuning costs and usage statistics. - Updated infrastructure configuration to include default quota hours for fine-tuning. - Added tests to ensure proper functionality of new features and configurations. * fix(logging): improve log message formatting for cost dashboard request * fix(logging): sanitize period string in cost dashboard log message * check in share conversations specs * chore(docs): update versioning documentation and release notes for v1.0.0-beta.20 - Update versioning skill and rule documentation to include README.md version badge and "Current release" text in sync script scope - Update Kiro steering guide to document README.md and lockfile updates in version sync process - Bump version badge in README.md from v1.0.0-beta.19 to v1.0.0-beta.20 - Update "Current release" text in README.md to v1.0.0-beta.20 - Add comprehensive release notes for v1.0.0-beta.20 with highlights on document deletion, displayText system, fine-tuning cost dashboard, and dependency updates - Ensure all AI assistant rule files reflect current versioning workflow * ci(frontend): remove common scripts from workflow triggers and restrict CDK jobs to non-PR events - Remove 'scripts/common/**' from push and pull_request trigger paths - Add condition to synth-cdk job to skip execution on pull_request events - Update test-cdk job condition to exclude pull_request events while preserving skip_tests logic - Prevents unnecessary CDK synthesis and testing during pull requests to reduce workflow overhead * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Release 1.0.0-beta.20: Document soft-delete, displayText, fine-tuning costs, CodeQL remediation, dependency refresh (#118) Reliable document deletion, displayText for RAG-augmented messages, fine-tuning cost dashboard, assistant archive removal, and a full dependency refresh across Python, npm, and GitHub Actions. Features: - Soft-delete document lifecycle with background cleanup, retry logic, DynamoDB TTL backstop, and search filtering for mid-deletion docs - Upload failure reporting endpoint for client-side error tracking - DisplayText system preserving original user messages when RAG augmentation or file attachments modify the prompt sent to the agent - Debug output toggle in chat preferences for prompt inspection - Fine-tuning cost dashboard with per-user breakdowns and default monthly quota hours - Shared conversation cascade deletion on session delete Removals: - Assistant archive functionality (ARCHIVED status, archive endpoint, include_archived parameter) replaced with single delete operation Security & Code Quality: - All CodeQL findings resolved (180 log injection fixes, 5 silent exception fixes, cyclic import elimination, 13 unused variables) - Four Dependabot security patches (requests, picomatch, diff) CI/CD: - CDK synth skipped on PRs for app-api and frontend workflows - scripts/common/** removed from frontend workflow path triggers - GitHub Actions bumped (upload-artifact v7, download-artifact v8, setup-node v6, codeql-action latest) Testing: - Analog.js testing migration for frontend (vitest config removed) - fast-check v4.6.0 added for property-based frontend tests - 4,200+ lines of new backend tests for document deletion flows Tooling: - sync-version.sh now auto-updates README badge and current release text - Versioning steering docs updated across Kiro, Cursor, and Claude - Release notes steering doc added (fileMatch on RELEASE_NOTES.md) Dependencies: - Python: uvicorn 0.42.0, strands-agents 1.33.0, strands-agents-tools 0.3.0, aws-opentelemetry-distro 0.16.0, bedrock-agentcore 1.4.8, openai 2.30.0, cachetools downgraded to 6.2.4 for compatibility - Frontend: Angular 21.2.6, @angular/cdk 21.2.4 - Infrastructure: aws-cdk group bumped, constructs bumped * Purge outdated AI specs and documentation (#121) * spring cleaning. AI spec file and outdated documentation purge * spring cleanup --> purging old ai specs and outdated docs --------- Co-authored-by: colinmxs * Feat/cognito first boot auth (#125) * feat: replace multi-step auth bootstrap with Cognito first-boot experience - Add Cognito User Pool, App Client, and Domain to CDK infrastructure - Implement first-boot backend with race-condition-safe DynamoDB writes - Add CognitoJWTValidator replacing GenericOIDCJWTValidator - Add federated identity provider management via Cognito IdP APIs - Migrate frontend to Cognito OAuth 2.0 + PKCE flow - Add first-boot setup page with admin account creation - Update AgentCore Runtime to single Cognito JWT authorizer - Remove runtime-provisioner and runtime-updater Lambdas - Remove hardcoded Entra ID configuration from CDK and scripts - Remove auth provider seeding from bootstrap workflow - Wire SSM parameters across stacks for Cognito config - Update GitHub Actions workflows for Cognito context values * FOR TESTING ONLY< REVERT BEFORE MERGING * Feat/cognito first boot auth (#123) * test(auth-sweep): add system status endpoints to public route patterns - Add /system/status to PUBLIC_ROUTE_PATTERNS for unauthenticated access - Add /system/first-boot to PUBLIC_ROUTE_PATTERNS for unauthenticated access - These endpoints should be accessible without authentication for system initialization and health checks * chore(deps): add cognitoidp extra to moto dev dependency - Add cognitoidp extra to moto[dynamodb] in pyproject.toml dev dependencies - Update uv.lock to include cognitoidp extra across all moto references - Add joserfc package as transitive dependency for cognitoidp support - Enable Cognito IDP mocking capabilities for development and testing * test(auth-guard,config-service): add missing service mocks and config properties - Add SystemService mock to auth.guard.spec.ts test setup - Import SystemService dependency in auth guard test file - Add checkStatus mock method to systemService test double - Register SystemService provider in TestBed configuration - Add inferenceApiUrl property to validConfig test fixture in config.service.spec.ts - Ensure test doubles accurately reflect service dependencies for proper test isolation * feat(system-admin): add JWT role mapping for system_admin Cognito group - Add JWT_MAPPING#system_admin item to DynamoDB during bootstrap seeding - Update system_admin role jwtRoleMappings to include "system_admin" group - Implement add_user_to_group method in CognitoService to manage group membership - Add user to system_admin Cognito group during first_boot with rollback on failure - Update test assertions to verify JWT mapping creation and role configuration - Enables Cognito to include system_admin group in JWT cognito:groups claim for RBAC resolution * feat(infrastructure): add Cognito user and group management permissions - Add cognito-idp:AdminDeleteUser permission for user deletion operations - Add cognito-idp:AdminAddUserToGroup permission for group membership management - Add cognito-idp:CreateGroup permission for group creation operations - Enables system admin functionality for managing Cognito user pools and groups * refactor(auth): replace user email with name in logging and events - Replace user.email with user.name in quota event recorder metadata - Update admin cost dashboard logging to use user.name instead of email - Update admin users routes logging to use user.name instead of email - Update file upload routes logging to use user.name instead of email - Update model routes logging to use user.name instead of email - Update tools routes logging to use user.name instead of email - Update OAuth routes logging to use user.name instead of email - Update user models and routes logging to use user.name instead of email - Update auth service and user service in frontend to use name field - Standardize user identification across backend and frontend to use name for privacy and consistency * feat(frontend): update logos and URL-encode inference API ARN - Update logo-dark.png and logo-light.png assets - Add URL encoding for ARN portion in inferenceApiUrl computed signal - Prevent URL parsing errors caused by colons and slashes in AgentCore runtime ARNs - Improve config service documentation with encoding behavior explanation * fix(frontend): add /invocations path to inference API endpoints - Update preview-chat.service.ts to include /invocations path in runtime endpoint URL - Update chat-http.service.ts to include /invocations path in runtime endpoint URL - Fixes inference API calls by using correct endpoint path with qualifier parameter * feat(inference-api): add Authorization header to ALB request configuration - Add requestHeaderConfiguration to ALB listener rule - Include Authorization header in requestHeaderAllowlist - Enable proper header propagation for authenticated requests to inference API * refactor(auth): consolidate RBAC to AppRole-based authorization - Replace multiple role-checking functions with single require_app_roles dependency - Remove require_roles, require_all_roles, has_any_role, has_all_roles, and role-specific decorators (require_faculty, require_staff, require_developer, require_aws_ai_access) - Update rbac.py to resolve permissions through AppRoleService instead of hardcoded JWT groups - Simplify auth module exports to only expose require_app_roles and require_admin - Update admin routes to remove unused role imports - Add comprehensive docstring explaining AppRole system as single source of truth for permissions - Update tests to reflect new authorization flow via AppRoleService * feat(inference-api): add SSM parameters and environment variables to AgentCore runtime - Import DynamoDB table names from SSM parameters for users, RBAC, auth, OAuth, quota, cost tracking, and file uploads - Import S3 bucket and vector index names for RAG functionality - Import gateway URL and frontend CORS origins from SSM parameters - Add comprehensive environment variables to AgentCore runtime configuration including DynamoDB table mappings, authentication settings, OAuth configuration, AgentCore resource IDs, and directory paths - Enable authentication and quota enforcement in runtime environment - Configure frontend URL and CORS origins for cross-origin requests * feat(inference-api): remove gateway URL parameter and simplify CORS origins - Remove SSM parameter import for gateway URL from InferenceApiStack - Remove GATEWAY_URL environment variable from AgentCore runtime configuration - Replace SSM-imported CORS origins with config-based construction to avoid circular dependency between InferenceApiStack and FrontendStack - Construct CORS origins dynamically from config.domainName (https://{domain}) with localhost fallback for development - Eliminates circular dependency: InferenceApiStack ↔ FrontendStack by removing reliance on FrontendStack SSM parameters * chore(frontend): update favicon and logo assets - Remove redundant favicon PNG variants (android-chrome, apple-touch-icon, favicon-16x16, favicon-32x32) - Update favicon.ico with new design - Update logo-dark.png with refreshed branding - Consolidate favicon assets to reduce redundancy and improve maintainability * feat(inference-api): add AWS Marketplace permissions for Bedrock model access - Add MarketplaceModelAccess policy statement to runtime execution role - Grant aws-marketplace:ViewSubscriptions and aws-marketplace:Subscribe actions - Enable foundation model access for marketplace-gated models like Anthropic Claude - Required for subscription validation before Bedrock model invocation * Release 1.0.0-beta.19: Conversation sharing, session compaction, fine-tuning enhancements, CI optimization ## Features - Conversation sharing with public/email-restricted access via shareable URLs - Session compaction enabled by default (100K token threshold, 3 protected turns) - Fine-tuning: drag-and-drop dataset upload, custom HuggingFace model support ## Security - Resolve all CodeQL clear-text logging alerts (secrets, tokens, ARNs redacted) - OAuth redirect URL validation to prevent open redirects - Explicit read-only permissions on all 13 GitHub Actions workflows ## Performance - Frontend production build optimized: 8.85 MB β†’ 4.96 MB (871 KB gzipped) - PR workflows trimmed: skip Docker builds, CDK synth, and redundant jobs ## Infrastructure - New shared-conversations DynamoDB table with SessionShare and OwnerShare GSIs - Bedrock prompt caching temporarily disabled due to provider limitations ## Bug Fixes - Google Fonts moved to index.html to fix CI build failure - Private sharing support (owner-only shares) * Release 1.0.0-beta.20: Document soft-delete, displayText, fine-tuning costs, CodeQL remediation, dependency refresh Reliable document deletion, displayText for RAG-augmented messages, fine-tuning cost dashboard, assistant archive removal, and a full dependency refresh across Python, npm, and GitHub Actions. Features: - Soft-delete document lifecycle with background cleanup, retry logic, DynamoDB TTL backstop, and search filtering for mid-deletion docs - Upload failure reporting endpoint for client-side error tracking - DisplayText system preserving original user messages when RAG augmentation or file attachments modify the prompt sent to the agent - Debug output toggle in chat preferences for prompt inspection - Fine-tuning cost dashboard with per-user breakdowns and default monthly quota hours - Shared conversation cascade deletion on session delete Removals: - Assistant archive functionality (ARCHIVED status, archive endpoint, include_archived parameter) replaced with single delete operation Security & Code Quality: - All CodeQL findings resolved (180 log injection fixes, 5 silent exception fixes, cyclic import elimination, 13 unused variables) - Four Dependabot security patches (requests, picomatch, diff) CI/CD: - CDK synth skipped on PRs for app-api and frontend workflows - scripts/common/** removed from frontend workflow path triggers - GitHub Actions bumped (upload-artifact v7, download-artifact v8, setup-node v6, codeql-action latest) Testing: - Analog.js testing migration for frontend (vitest config removed) - fast-check v4.6.0 added for property-based frontend tests - 4,200+ lines of new backend tests for document deletion flows Tooling: - sync-version.sh now auto-updates README badge and current release text - Versioning steering docs updated across Kiro, Cursor, and Claude - Release notes steering doc added (fileMatch on RELEASE_NOTES.md) Dependencies: - Python: uvicorn 0.42.0, strands-agents 1.33.0, strands-agents-tools 0.3.0, aws-opentelemetry-distro 0.16.0, bedrock-agentcore 1.4.8, openai 2.30.0, cachetools downgraded to 6.2.4 for compatibility - Frontend: Angular 21.2.6, @angular/cdk 21.2.4 - Infrastructure: aws-cdk group bumped, constructs bumped * refactor(inference-api): remove underscore prefix from containerImageUri variable - Remove underscore prefix from containerImageUri variable name - Improve code clarity by following standard naming conventions - Variable is used throughout the stack and should follow public naming patterns * chore(deps): add cognitoidp moto extra and update infrastructure tests - Add cognitoidp extra to moto dependency in uv.lock for Cognito IDP mocking support - Update moto dependency extras to include both cognitoidp and dynamodb - Refactor IAM policy assertions in inference-api-stack tests to search both inline and managed policies - Simplify policy verification logic to use findResources and filter by policy attributes - Remove S3 bucket and S3 Vector Store test sections from app-api-stack tests - Update test assertions to be more flexible with policy resource types * chore(frontend): update favicon and logo assets - Add Android Chrome favicon variants (192x192 and 512x512) - Add Apple Touch Icon for iOS devices - Add favicon sizes for 16x16 and 32x32 resolutions - Update favicon.ico with new design - Update logo-dark.png with refreshed branding - Update logo-light.png with refreshed branding - Improve cross-platform icon support and visual consistency * fix(auth-providers): remove OIDC discovery endpoint and add JSON parsing error handling - Remove POST /discover endpoint for OIDC endpoint discovery from admin routes - Add try-except blocks to handle JSON parsing errors in AuthProviderRepository - Gracefully default to empty dict when SecretString is invalid or malformed - Improve resilience when retrieving auth provider secrets from AWS Secrets Manager --------- Co-authored-by: Colin * Feat/cognito first boot auth (#124) * test(auth-sweep): add system status endpoints to public route patterns - Add /system/status to PUBLIC_ROUTE_PATTERNS for unauthenticated access - Add /system/first-boot to PUBLIC_ROUTE_PATTERNS for unauthenticated access - These endpoints should be accessible without authentication for system initialization and health checks * chore(deps): add cognitoidp extra to moto dev dependency - Add cognitoidp extra to moto[dynamodb] in pyproject.toml dev dependencies - Update uv.lock to include cognitoidp extra across all moto references - Add joserfc package as transitive dependency for cognitoidp support - Enable Cognito IDP mocking capabilities for development and testing * test(auth-guard,config-service): add missing service mocks and config properties - Add SystemService mock to auth.guard.spec.ts test setup - Import SystemService dependency in auth guard test file - Add checkStatus mock method to systemService test double - Register SystemService provider in TestBed configuration - Add inferenceApiUrl property to validConfig test fixture in config.service.spec.ts - Ensure test doubles accurately reflect service dependencies for proper test isolation * feat(system-admin): add JWT role mapping for system_admin Cognito group - Add JWT_MAPPING#system_admin item to DynamoDB during bootstrap seeding - Update system_admin role jwtRoleMappings to include "system_admin" group - Implement add_user_to_group method in CognitoService to manage group membership - Add user to system_admin Cognito group during first_boot with rollback on failure - Update test assertions to verify JWT mapping creation and role configuration - Enables Cognito to include system_admin group in JWT cognito:groups claim for RBAC resolution * feat(infrastructure): add Cognito user and group management permissions - Add cognito-idp:AdminDelete… * feat(assistants): re-enable tool use for assistants (revert KB-only) (#517) Assistants previously ran tool-free (KB-grounded only) β€” the consumer chat forced enabled_tools=[] and the system prompt told the model it had no external tools (#382). This reverts that so assistants can leverage the user's enabled MCP/tools again. Backend (inference_api/chat/routes.py): - Drop the enabled_tools=[] override in the rag_assistant_id branch so the client's tool selection flows through to the agent. - Remove the "Knowledge Base Grounding / no external tools" directive from both the with-instructions and no-instructions prompt paths, restoring the pre-#382 system prompt composition. KB context is still pre-stuffed into the user message; assistant instructions still apply. Frontend (chat-request.service.ts): - Stop force-sending enabled_tools=[] on assistant turns; the user's tool-picker selection now rides along (skills mode stays gated on non-assistant turns). Preview parity (preview-chat.service.ts): - Forward the owner's enabled tools instead of [] so the editor preview exercises tools exactly like a consumer chat. - Build the streaming assistant message as ordered content blocks (text interleaved with tool use) and wire onToolUse/onToolResult so the shared message-list renders tool cards in the preview. Specs updated to assert tools are forwarded on assistant + preview turns. Note: the backend test_chat_endpoint is dead code (no caller) and still passes enabled_tools=None; left for a separate cleanup/removal. Co-authored-by: Claude Opus 4.8 * fix(nightly): repoint test/install jobs to post-refactor script paths (#518) The single-stack refactor (#396) removed scripts/stack-app-api/ and scripts/stack-frontend/, but nightly.yml's coverage jobs still called them, so test-backend/test-frontend/install-frontend failed with 'No such file or directory'. repo-shape.test.ts forbids recreating the stack-* dirs, so port the three scripts to the sanctioned new layout: scripts/stack-app-api/test.sh -> scripts/backend/test.sh scripts/stack-frontend/install.sh -> scripts/frontend/install.sh scripts/stack-frontend/test.sh -> scripts/frontend/test.sh Behavior preserved 1:1 (uv install+sync + pytest --cov for backend; npm ci for frontend+infra; ng test --no-watch --coverage). Only the self-referential help-text paths were updated. * fix(deps): remediate 6 open Dependabot alerts (#520) docs-site (npm): - astro 6.3.1 -> 6.4.8: fixes reflected XSS via slot name (#241, GHSA-8hv8-536x-4wqp), host-header SSRF in prerendered error page (#242, GHSA-2pvr-wf23-7pc7), and XSS via unescaped spread attribute names (#243, GHSA-jrpj-wcv7-9fh9). - esbuild -> 0.28.1 via overrides: fixes dev-server arbitrary file read (#240, GHSA-g7r4-m6w7-qqqr). frontend/ai.client (npm): - esbuild -> 0.28.1 via overrides (transitive through @angular/build 21.2.16): fixes dev-server arbitrary file read (#149, GHSA-g7r4-m6w7-qqqr). backend (pip/uv): - pydantic-settings 2.13.1 -> 2.14.2 (transitive): fixes NestedSecretsSettingsSource symlink traversal / local file read (#234, GHSA-4xgf-cpjx-pc3j). Verified: docs-site astro build OK; frontend prod build + 1239 unit tests OK; backend 4003 tests OK; npm audit clean for both npm projects. * fix(security): remediate CodeQL alerts (HIGH/MEDIUM + NOTE cleanups) (#521) Security fixes (with regression tests): - HIGH py/incomplete-url-substring-sanitization: external_mcp_client now parses the URL host (urlparse) and matches an anchored suffix instead of substring-checking the whole URL, so a marker in a path/query/userinfo can't trick SigV4 signing into attaching IAM creds to a non-AWS host. Added TestAwsUrlHostSanitization (adversarial URLs). - HIGH js/regex/missing-regexp-anchor: admin-tool.model parses the host (new URL) and anchors the AWS-endpoint regexes ($). Added admin-tool.model.spec.ts (13 cases incl. spoofed hosts). - MEDIUM py/log-injection (24 sites/16 files): new apis.shared.security.scrub_log() neutralizes CR/LF/control chars; wrapped user-controlled values at every flagged log call. Added test_log_sanitize.py. - MEDIUM actions/untrusted-checkout: added persist-credentials:false to all inputs.ref checkouts in nightly-deploy-pipeline.yml. - WARNING py/regex/duplicate-in-character-class: removed a bare '[' from a re.VERBOSE comment that the regex parser misread as a character class. NOTE cleanups: removed unused imports (py + 17 TS constructs), a dead local, redundant '...' after abstractmethod docstrings, unused test imports; added explanatory comments to 6 intentional empty-except blocks; reworded commented-out code. Left intentionally: 4 py/unused-global-variable alerts are FALSE POSITIVES (_cached_signing_key/_cached_bucket/_client_secret_cache are working lazy caches β€” verified read+written); recommend dismissing in the UI. Verified: backend 4045 passed (3 pre-existing test_backup_coverage failures are unrelated β€” a skill-resources backup-coverage gap on develop); frontend build OK + 1252 unit tests; infra tsc + 406 jest tests; npm/ruff clean for touched files. * Release/1.0.2 (#522) (#523) #517 (headline) β€” assistants can use tools again, reverting the 1.0.0 KB-only restriction (#382). Filed under ✨ Improved, with a deployment-note callout since it's a user-visible behavior change. - #521 β€” CodeQL sweep (2 HIGH URL/host findings, 24-site log-injection pass, hardened CI checkout) β†’ πŸ”’ Security. - #520 β€” 6 Dependabot CVEs (Astro, esbuild, pydantic-settings) β†’ πŸ“¦ Dependencies table. - #518 β€” nightly pipeline script-path fix β†’ πŸ› Fixed. * chore: prune dead workflow tracks, dedupe test gates, fix/fork-gate d… (#524) * chore: prune dead workflow tracks, dedupe test gates, fix/fork-gate docs deploy - nightly: remove never-useful AI coverage analysis + merge-validation tracks (relevant only to the old multi-stack era); delete orphaned ai-coverage-analysis.py - remove dead source-project-prefix input from nightly-deploy-pipeline and the now-orphaned promote-ecr-image.sh it fed - extract duplicated test gates into a reusable tests.yml; wire into ci, platform, backend, frontend-deploy, and nightly-deploy-pipeline - docs-deploy: publish from main (was develop); fork-gate so forks don't auto-publish - release: fork-gate so forks syncing main don't auto-create releases Verified: repo-shape jest 49/49, supply_chain pytest 31/31. * ci: re-enable push-triggered deploys, path-scoped Restore auto-deploy on push to develop/main for the platform, backend, and frontend workflows (was workflow_dispatch-only since v1.0.0). Each push trigger is scoped to that workflow's own surface so unrelated changes don't redeploy: - backend: backend/**, scripts/build/**, the workflow file - platform: infrastructure/lib (stack/constructs/config), bin, bootstrap-assets, scripts/platform/**, the workflow file - frontend: frontend/**, scripts/frontend/**, the workflow file develop -> development env, main -> production env (existing per-job mapping). Forks without AWS secrets fail safely at the credential step; forks that configured their own secrets get the intended fork-and-deploy behavior, and manual workflow_dispatch remains available everywhere. docs-deploy was already scoped (docs-site/** + its workflow file). * docs: trim verbose/misleading workflow comments Cut the trigger and fork-gate comments down to one-liners and drop the inaccurate 'secrets make it fork-safe' framing (forks must explicitly enable Actions regardless). * ci: serialize platform + backend deploys via shared concurrency group (#525) Put platform.yml and backend.yml in one repo-global concurrency group (deploy-) so a CloudFormation deploy and the API-driven backend code deploys can't run at the same time and stomp on the same ECS service / AgentCore Runtime / Lambda. They queue instead; order doesn't matter post-initial-deploy since either order converges. Frontend stays independent. cancel-in-progress stays false. * Chore/cleanup dependabot codeql 2026 06 (#526) * fix(deps,codeql): bump joserfc to 1.7.2; drop unused imports - joserfc 1.6.3->1.7.2 (backend/uv.lock), 1.6.5->1.7.2 (scripts/backup-data/uv.lock) remediates Dependabot GHSA-wphv-vfrh-23q5 / CVE-2026-48990 (#244, #245). Targeted 'uv lock --upgrade-package joserfc'; no other packages changed. - Remove unused 'Optional' import in agents/main_agent/agent_types.py (CodeQL #721). - Remove unused 'ssm' import in app-api/app-api-environment.ts (CodeQL #722). * fix(ci): render reusable test-gate job names statically The reusable tests.yml jobs used an inline label expression in their 'name:'. GitHub does not evaluate name expressions for SKIPPED jobs, so callers that run only one suite (backend.yml, platform.yml, frontend-deploy.yml, nightly-deploy-pipeline.yml) showed the raw ${{ ... }} text as job labels for the unselected (skipped) jobs. Make the three job names static and relocate the nightly track-label prefix onto the (never-skipped) caller jobs in nightly-deploy-pipeline.yml, which matches the existing '[label] Deploy PlatformStack' convention and evaluates reliably. Drops the now-unused 'label' input from tests.yml. * Release/1.0.3 Patch release: CI/CD pipeline cleanup, re-enabled path-scoped auto-deploys, serialized platform+backend deploys, and a joserfc CVE / CodeQL dependency sweep. No application code or user-facing behavior changes. - Bump VERSION to 1.0.3; sync manifests + lockfiles - Brief RELEASE_NOTES.md and CHANGELOG.md entries (#524, #525, #526) - Steering: scale release-notes depth to release size (brief patches, deep features) * fix: add missing iam permission for agent core gateway * fix: add lambda:InvokeFunction to gateway role for MCP targets AWS docs require an identity-based policy on the gateway service role in addition to the per-target resource policy (lambda:AddPermission). Without this the Gateway gets 403 when trying to invoke Lambda targets. * fix(iam): grant bedrock-agentcore:GetMemory to app-api + runtime roles AgentCore Memory strategy discovery (MemoryClient.get_memory_strategies) calls bedrock-agentcore:GetMemory, but neither the App API task role nor the AgentCore Runtime execution role allowed it β€” every other memory data-plane action was granted. The denied GetMemory made strategy discovery fail silently: - App API: GET /memory returned empty facts/preferences with 200, so the Settings memories/preferences page rendered blank despite stored records. - Runtime: _discover_strategy_ids() failed, leaving retrieval config empty, so the agent kept writing events (CreateEvent) but never recalled long-term memories. Add bedrock-agentcore:GetMemory to the AgentCoreMemoryAccess statement on both roles (app-api scoped to the memory ARN; runtime scoped to memory/*). No other actions changed. Validated against the AWS Service Authorization Reference (GetMemory = Read on the memory resource type) and the live AccessDenied in app-api logs. Bumps VERSION to 1.0.4 + brief RELEASE_NOTES/CHANGELOG entries. Infra (IAM) change β€” deploys via the platform (CDK) pipeline. * fix: keep tool card after OAuth/tool-approval resume An interrupt-resume turn (OAuth consent or tool approval) has no new user message, and the resumed Strands stream does not replay the interrupted tool_use block β€” it emits only the tool_result plus a fresh assistant message with the final text. The default sync truncates everything after the last user message and replaces it with the resumed stream, discarding the assistant message that holds the paused tool card. The reset parser also drops the incoming tool_result (no matching tool_use in its fresh builder). Net: the tool card disappears even though the call succeeded. Both resume paths now: - use beginContinuationStreaming() instead of startStreaming(), pinning the existing messages (including the tool card) as a prefix and appending the resume after them β€” same pattern as the max_tokens "Continue" flow. - reconcile from persisted memory after the resume stream closes via the new MessageMapService.reloadMessagesForSession(), so the card flips from "Running..." to its completed result (memory holds tool_use + tool_result). reloadMessagesForSession force re-fetches (bypasses the "already loaded" guard) without flipping the skeleton loading state; the shared fetch/reconcile logic is extracted into fetchAndApplyMessages(). Removed the now-unused StreamParserService injection from ChatRequestService. Co-Authored-By: Claude Opus 4.8 * chore(kaizen): weekly research scan 2026-07-03 Generated by the kaizen-research skill. Top 5 ideas appended to docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(kaizen): weekly review prep 2026-07-03 Catch-up review consuming this morning's fresh research/2026-07-03.md (no 06-12/06-19 reviews ran). 10 proposals ranked; top item is the bedrock-agentcore 1.17.0 bump (closes the #482 SSE deadlock we're exposed to today + retires a queued guard). Queue trimmed: superseded Strands/agentcore bump duplicates consolidated into the two 07-03 bumps, Fable 5 un-withdrawn (reinstated Jul 1), starlette + PR-gate marked shipped. Stacked on the research PR (#534). Co-Authored-By: Claude Opus 4.8 (1M context) * feat: isolate chat streaming per conversation The chat streaming layer used global singletons that broke when two conversations streamed concurrently (ask in A, navigate to B, ask again). Make streaming state per-session end to end, and fix the UX gaps that surfaced once background streaming actually worked. Per-session streaming: - ChatStateService holds per-session state (loading, stop reason, cost/context aggregates, Continue affordance, AbortController) behind a viewed-session facade, so existing consumers are unchanged. - StreamParserService keeps a Map with a per-stream id; ChatHttpService captures it per request and drops late events from a superseded stream (same-session resubmit guard). - MessageMapService runs one sync effect per active stream; endStreaming, abort, and loading are all keyed by session. Stop and double-submit only affect their own conversation; navigating away no longer aborts the in-flight stream (backend still completes and persists the turn). Navigation scroll policy: - New ScrollPositionService remembers per-conversation scroll offset for the SPA session. Returning restores where you were; first open (or after a reload) anchors the latest turn instead of the top. Streaming-text replay fix: - StreamingTextComponent seeds its display from whatever text already exists at mount, so navigating back to a streaming conversation shows the current state instead of re-typing the whole response from character zero. Sidebar in-progress indicator: - Conversation rows show a pulsing dot while a response streams (reads the same per-session loading state), replacing the hover ellipsis menu while in progress so the two never overlap and actions can't target a mid-response conversation. Co-Authored-By: Claude Fable 5 * feat: default export to messages only Include only user and assistant messages by default in conversation exports. Tool calls, images, and citations must now be explicitly selected by the user. Co-Authored-By: Claude Haiku 4.5 * feat: expose session options menu on top-nav title Add a dropdown to the top-nav session title (arrow/chevron trigger) that offers the same actions as the sidenav ellipsis menu β€” Rename, Share, Save to…, and Delete β€” reusing SessionService and the existing dialogs. Also fixes title-transition polish surfaced while building this: - Consolidate the two instances (skeleton vs loaded branch) in chat-container into a single persistent instance so the fixed .chat-topnav-wrapper no longer remounts on skeletonβ†’loaded, which was restarting its left-transition and sweeping the whole bar ~288px. - Optimistically set currentSession on sidenav click so the title updates instantly instead of lingering until metadata loads. - Optimistic inline rename (revert on failure) to remove the save lag, and reload the sessions resource so the sidenav list reflects the new title. - Gentle keyed slide+fade title entry animation (component-scoped). - Title skeleton while the session metadata is loading (hard refresh) or a brand-new chat's title is still generating. Co-Authored-By: Claude Opus 4.8 * test: update onSessionClick spec for optimistic currentSession set onSessionClick now takes the clicked session and optimistically sets it as currentSession; update the spec to pass a session and assert the set. Co-Authored-By: Claude Opus 4.8 * fix: clear OAuth consent state on session switch The OAuth "Authorization needed" banner is held in a root-singleton OAuthConsentService keyed by providerId (not sessionId), and its pending() signal drives the banner regardless of the originating session. Every other per-session UI surface (compaction, artifacts, MCP app frames/cards/consent) is reset in the route subscription on conversation change, but OAuthConsentService was not β€” so a prompt raised in one session persisted onto the next, including the blank welcome screen. Clear it fail-closed alongside the other resets. This runs before loadMessagesForSession, which re-seeds the new session's own pending interrupts from persisted server metadata, so a session that legitimately needs authorization still shows its banner. Clearing seenInterruptIds also fixes a latent bug where a genuinely-needed re-prompt for the same provider in a later session was silently suppressed by the dedup guard. Co-Authored-By: Claude Opus 4.8 * feat: push session title mid-stream via session_title SSE event New sessions were only renamed from "New Conversation" after the agent stream closed, even though the backend already generates the title concurrently with the stream. This surfaces that title while the response is still pending. - inference-api keeps the concurrent title-generation task handle and interleaves a one-shot `session_title` SSE event between agent events once it resolves (non-blocking done-check, same drain pattern as the MCP Apps broker). Never emits the "New Conversation" placeholder. - Fix latent bug: generate_conversation_title ran sync boto3 converse on the event loop, stalling the live agent stream for the whole Nova Micro round-trip. Now runs via asyncio.to_thread. - SPA parses session_title, applying it to both the sidebar cache and the currentSession signal (new SessionService.applyServerTitle) so the top-nav header renames too. Allowlisted past Completed-state gating since it can arrive just after `done`. - Post-close refreshTitleFromServer remains the fallback for streams that outrun generation. Documented the new event in the CLAUDE.md SSE contract table. Adds backend + frontend test coverage. Co-Authored-By: Claude Fable 5 * feat: skeleton + fallback for pending session titles in sidebar and top-nav Surfaces the "title generating" state cleanly now that titles arrive mid-stream via the session_title SSE event. Sidebar (session-list): - Show a shimmer skeleton instead of "Untitled Session" while a new conversation's title is still generating (titleless + streaming). - Darken the shimmer only on the selected row, whose active highlight (bg-gray-200 / dark:bg-white/5) would otherwise hide a light skeleton. Keyed off a plain .session-row--active sentinel added to the same routerLinkActive that paints the highlight, coloured in component CSS (no arbitrary Tailwind variant), with class-based dark handled via :host-context(html.dark). - Row is now flex/min-h-8 so the skeletonβ†’title swap causes no vertical jank, and the generated title reveals with the top-nav's slide+fade (session-title-enter), kept truncatable via min-w-0. Top-nav: - Gate the title skeleton on a proper titlePending signal (metadata loading OR the session actively streaming) so it can no longer shimmer forever; once resolved with no title it falls back to "Untitled Session" instead of an eternal skeleton. Adds/updates topnav + session-list specs. Co-Authored-By: Claude Fable 5 * feat: persist interrupted-turn context so aborted responses aren't orphaned When a response is interrupted (user Stop, refresh, or dropped socket) the user turn was already persisted but the assistant reply was not, leaving a dangling user turn β€” no context on reload and malformed history the model must paper over. Backend: - set/clear_interrupted_turn markers (metadata.py) with race-safe reason precedence: user_stopped (authoritative client signal) always wins over the connection_lost cancellation fallback. clear is an atomic pop (ReturnValues=UPDATED_OLD) returning the settled reason. - stream_coordinator: (CancelledError, GeneratorExit) backstop persists the IN-FLIGHT partial assistant text (accumulator scoped to the current message so already-persisted mid-turn messages aren't duplicated) via asyncio.shield; empty-partial placeholder gated on a user-tail to repair role alternation only where needed. - app-api POST /sessions/{id}/interrupt: authoritative user_stopped carrier (cookie auth; fetch keepalive + X-CSRF-Token, not sendBeacon). Lives on app-api because the AgentCore Runtime data plane only proxies /invocations + /ping. - inference-api: pop the marker at turn start and prepend a reason-specific ephemeral interruption note at the pending_ctx_block seam (invisible to the user via the displayText split; ages out via compaction). Remaining: SPA stop signal in cancelChatRequest + reload chip/Continue. Co-Authored-By: Claude Opus 4.8 * feat(spa): surface interrupted-turn state β€” stop signal + reload chip Completes the interrupted-turn feature on the SPA side: - cancelChatRequest fires a best-effort keepalive fetch to POST /sessions/{id}/interrupt with {reason:'user_stopped'} + X-CSRF-Token (not sendBeacon β€” it can't set the CSRF header) before aborting, and reflects the interruption locally so the chip shows without a reload. - lastTurnInterrupted + reason are per-session ChatState signals (mirroring lastTurnContinuable): hydrated from session metadata on reload (set-true only), cleared beside every continuable clear (new send / continuation / new streamed turn). - message-actions renders a chip on the last message: connection_lost β†’ "Response interrupted" + Continue (reuses continueTruncatedTurn against the persisted partial); user_stopped β†’ "You stopped this response", no Continue. Gated on last-message + not-loading; max_tokens Continue wins. Specs cover both chip variants, Continue emit + precedence, the keepalive signal shape, local reflect, failure isolation, and continuation clears. Full app build + affected unit specs green. Co-Authored-By: Claude Opus 4.8 * docs: add assistant KB sync (scheduled re-index) design spec Sweeper-not-scheduler architecture with layered runaway guards; all five open questions resolved against AgentCore Identity docs, Drive API docs, and code inspection. Co-Authored-By: Claude Fable 5 * feat(kb-sync): add SyncPolicy data layer, DueSyncIndex GSI, delete cascades PR-1 of the KB sync feature (docs/specs/assistant-kb-sync.md): - SyncPolicy model + repository in apis.shared.sync_policies (adjacency list SYNCPOL# items on the assistants table) - Sparse DueSyncIndex (GSI4) β€” keys present only while state=active, so paused policies are physically invisible to the dispatcher sweep - Conditional re-arm for idempotent dispatch; breaker counters (consecutive failures / source-gone) maintained by record_sync_result - Assistant and document delete paths eagerly cascade sync policies so no schedule outlives its source - Document gains contentHash/lastSyncedAt/syncPolicyId; Assistant gains lastUsedAt (inactivity-pause input) Inert until the PR-2 dispatcher exists: nothing reads DueSyncIndex yet. Co-Authored-By: Claude Fable 5 * feat(kb-sync): dispatcher + worker Lambdas, EventBridge sweep, kb-sync image pipeline PR-2 of the KB sync feature (docs/specs/assistant-kb-sync.md): Backend: - kb-sync dispatcher (apis/app_api/kb_sync/dispatcher.py) β€” sweeps the sparse DueSyncIndex each tick and applies the runaway guards in order: kill switch, liveness (orphan policies hard-deleted), circuit breaker (failure/not-found streaks -> paused_error), 30-day inactivity pause, in-flight skip via syncRunStartedAt, re-arm-with-backoff BEFORE async-invoking the worker - worker stub (records "skipped", clears the run stamp); Drive sync lands in PR-3, web re-crawl in PR-4 - rearm_policy gains mark_run_started so claiming the in-flight slot is atomic with winning the re-arm - dispatcher reads assistant/source records by raw adjacency-list key (keeps the image surface to apis.shared.sync_policies + kb_sync); tests create them through the real services so schema drift breaks Infra: - KbSyncConstruct: two DockerImageFunctions sharing one image with ImageConfig.Command overrides; rate(15 min) EventBridge rule β€” DISABLED unless CDK_KB_SYNC_ENABLED=true (dark by default), env kill switch mirrors the flag; namespace-conditioned PutMetricData; error alarms; function-name SSM params for the code-deploy step - platform-as-bootstrap: byte-stable bootstrap-assets/kb-sync stub Pipeline: - backend/Dockerfile.kb-sync (lightweight: boto3+pydantic, no ML deps) - build-one.sh kb-sync case, deploy-image-lambda-one.sh kb-sync-dispatcher/worker cases sharing one image tag - deploy-image-lambda-one.sh: first-deploy grace skip when the function-name SSM param doesn't exist yet (backend.yml runs before the platform deploy on the introducing PR) - backend.yml build+deploy jobs; nightly scan-images + supply-chain test lists include the new Dockerfile Co-Authored-By: Claude Fable 5 * feat(kb-sync): Drive-file sync path β€” vault token, change detection, staged re-ingest PR-3 of the KB sync feature (docs/specs/assistant-kb-sync.md Β§6.1): Worker (replaces PR-2 stub for drive_file policies): - resolves the policy creator's stored Google token from the AgentCore Identity vault (GetWorkloadAccessTokenForUserId -> GetResourceOauth2Token, no live user session; same customParameters as consent β€” vault-key rule) - two-gate change detection: Drive files.get version vs stored sourceEtag (continuous with import provenance), then sha256 vs contentHash β€” identical bytes never reach Docling/Titan - changed bytes staged to the document's EXISTING S3 key -> the untouched ingestion pipeline re-chunks/re-embeds via its S3 event - pause semantics per spec Β§7: requires_consent / Google 401 -> paused_reauth (not a failure streak); Drive 404 (deleted-or-unshared, indistinguishable) -> not_found strike, dispatcher pauses at 2; trashed=true -> grace skip; provider/adapter/provenance gone -> paused_error; every path records the run so the stamp always clears Shrinkage cleanup: - worker stashes previousChunkCount before staging; ingestion completion atomically pops it (REMOVE + UPDATED_OLD β€” duplicate S3 events can't double-delete) and deletes stale tail vectors {doc}#{new..prev-1} via new delete_vector_tail; uses post-split len(chunks), the true vector count this run wrote Adapter/image/infra: - GoogleDriveAdapter.get_file_metadata (cheap files.get, trashed + version/md5Checksum/modifiedTime fields) - kb-sync image gains apis.shared.oauth + file_sources (FastAPI-free, verified by import-surface simulation); httpx + bedrock-agentcore pins - worker IAM: vault token read (two Get* actions only β€” no consent completion, no provider CRUD), oauth-providers table read, documents bucket put; env: workload identity name + callback URL (same values app-api uses) Co-Authored-By: Claude Fable 5 * feat(kb-sync): web re-crawl path β€” refresh/upsert crawls, miss accounting, TTL fix PR-4 of the KB sync feature (docs/specs/assistant-kb-sync.md Β§6.2): Crawler refresh mode (opt-in via RefreshState; normal crawls unchanged): - upsert-by-URL: known pages reuse their document records β€” a re-crawl never duplicates documents - conditional GET with the stored ETag (304 = no body, no re-extract) plus a content-hash gate; identical bytes never re-embed - 'changed' emitted BEFORE the S3 overwrite so the worker stashes the previous chunk count ahead of the ingestion event (shrinkage cleanup) - transient fetch errors never flip an existing indexed doc to failed; its last-good content keeps serving - link-enqueue block deduplicated into a nested helper CrawlJob TTL fix (the silent-kill trap): terminal crawl jobs carry a 30-day TTL β€” a sync-covered job auto-expiring would trip the dispatcher's liveness check and delete the policy. finalize_crawl gains set_ttl; sync re-crawls finalize with the ttl REMOVED, and reset_crawl_for_refresh rearms the job (running, zeroed counters, no ttl) before each run. Worker web path: - re-runs the policy's crawl with its stored (already-capped) settings, 13-min crawler budget inside the 15-min Lambda so finalize + miss accounting always complete - miss accounting: pages absent from 2 CONSECUTIVE re-crawls are soft-deleted with cleanup run inline; fetch failures count as seen (outage != gone), robots-disallowed pages do not; misses reset on reappearance; missing root doc is recreated route-style Image: web_sources + documents + embeddings trees; beautifulsoup4, trafilatura, lxml pins. Worker Lambda timeout 10 -> 15 min. Co-Authored-By: Claude Fable 5 * feat(kb-sync): sync-policy API + resume hooks β€” CRUD routes, run-now, reauth/inactivity resume - New /assistants/{id}/sync-policies router (edit-gated like documents): create/list/patch/delete + POST run-now (202, atomic 10-min cooldown on lastManualRunAt). Resume of paused_reauth is 409 β€” only a fresh OAuth consent resumes it. - Reauth markers: worker pause writes USER#/SYNCREAUTH# marker so complete_consent() resumes exactly the right policies with one query; markers are advisory (resume re-verifies state) and self-clean. - Inactivity resume: throttled lastUsedAt bump on chat use (conditional write, one winner/day) wakes paused_inactive policies due-immediately. - restore_crawl_ttl: deleting a crawl's policy puts the job back on normal 30-day expiry (running jobs untouched). - drive_file policies keep a syncPolicyId back-pointer on the document, cleared on policy delete. Co-Authored-By: Claude Fable 5 * feat(kb-sync): SPA sync-policy controls on assistant knowledge page PR-6 (final) of the KB-sync series. Per-source "Keep in sync" controls on the assistant knowledge editor for imported Drive documents and web crawls: interval select (Manual only/Daily/Weekly/Monthly), status line (state, reason, last/next sync), pause/resume, Sync now (cooldown-aware), and a Reconnect affordance for paused_reauth policies that routes through the existing OAuth consent popup (a fresh consent auto-resumes server-side). Controls are owner/editor-only; device uploads show no control. Backend (additive, same-PR per cross-package contract): - DocumentResponse now exposes sourceConnectorId/sourceAdapterKey/ sourceFileId/syncPolicyId/lastSyncedAt so the SPA can tell imported docs from device uploads and route reconnect - GET /web-sources/crawls without ?active=true returns full history (list_all_crawls) β€” completed crawls are the syncable web sources Co-Authored-By: Claude Fable 5 * feat(interrupted-turn): persist stopped-turn metadata so cost + message badges survive A Stop aborts the fetch before the stream's terminal `metadata` SSE (usage / cost / context) can arrive, so both the per-message metadata badges and the session cost badge stayed blank for that turn β€” and blank even after reload, because the cancellation path persisted only the partial text + interrupted marker, never any metadata. The abort is load-bearing (killing the socket is what stops generation and billing), so the backend can't push the metadata to the dead socket. Instead: - Backend: `_persist_interruption` now also calls `_store_message_metadata` (the same call the `done` path uses), which writes the per-message row AND bumps the denormalized session aggregates that hydrate the cost badge on reload. Keyed to the interrupted message's odd-position index. A cut generation often never delivered Bedrock's terminal usage event, so new `_projected_input_usage` falls back to the context-attribution projection for input-side tokens/cost (output unknown β†’ priced at zero). - Frontend: `refreshAggregatesAfterStop` (delayed + one retry, mirroring refreshTitleFromServer, since the write races the abort) re-pulls session metadata and re-seeds the cost badge live. Per-message badges hydrate from the same persisted row on the next reload. Known limitation: the per-message contextBreakdown partition badge is not a persisted field (live-only even for normal turns), so it stays blank on a stopped/reloaded turn β€” a separate change affecting all turns. Tests: backend test_interrupted_turn_persistence.py (+2) and full streaming dir (175) green; frontend chat-http.service.spec.ts (+2) green. Co-Authored-By: Claude Opus 4.8 * fix(kb-sync): grant worker read on the vault's backing OAuth secrets The KB sync worker resolves the policy creator's Google token from the AgentCore Identity vault via GetResourceOauth2Token, but that call reads the refresh token *through* the Secrets Manager secret the vault auto-creates per provider (bedrock-agentcore-identity!default/oauth2/). The worker role granted only the two bedrock-agentcore:* actions, so the call failed with AccessDenied on secretsmanager:GetSecretValue and every Drive sync returned "failed". Add the read-only AgentCoreIdentityOAuthSecrets grant (GetSecretValue + DescribeSecret on ...!default/oauth2/*) β€” the same grant app-api and inference-api already carry, minus the write lifecycle a background fetcher never needs. Covered by a new assertion in kb-sync.test.ts. Verified live in dev-ai: with the grant, the worker gets past the vault read (the remaining paused_reauth is a genuine expired-refresh-token, not this IAM gap). Co-Authored-By: Claude Opus 4.8 * refactor(oauth): forward admin customParameters, drop hardcoded vendor baseline `custom_parameters_for` previously injected vendor-specific OAuth params (Google `access_type=offline`, plus `prompt=consent` on the force-auth path) on top of the connector's admin-configured extras, keyed off `provider_type`. That hid a documented requirement inside the code and made the `customParameters` map depend on the call site (grant vs retrieval), which AgentCore factors into its vault key. Simplify to a single rule: every call site forwards the connector's admin-configured `customParameters` verbatim via `custom_parameters_for(admin_extras)`. Admins set `access_type=offline` and `prompt=consent` in the connector's "Custom OAuth Parameters" field (the seeded Google connector already carries both), so the same map is sent on consent and on every retrieval β€” no baseline merge, no `provider_type`/`force_authentication` branching, no per-call-site drift. Removes `_vendor_baseline_params`, the `provider_type_lookup` plumbing on the consent hook, and the `force_authentication=True` customParameters argument at all read sites (the SDK-level `force_authentication` flag is unchanged). Behaviour for the existing Google connector is identical (same resulting map); the win is that the vault key is now provably consistent and there are no hidden customizations in the token path. Co-Authored-By: Claude Opus 4.8 * docs: add tool-search token-bloat + per-user markdown memory specs Two design specs drafted alongside recent exploration work: - tool-search-token-bloat-strategy: cross-source tool-search plan for MCP token bloat (tiered discovery, AWS Agent Registry tier). - user-markdown-memory: per-user markdown "second brain" memory, re-scoped skills reference-file mechanism, prompt-cache injection. Docs only; no code or behaviour change. Co-Authored-By: Claude Opus 4.8 * feat(kb-sync): log workload identity + userId on a reauth pause A reauth pause was opaque β€” "credentials need re-consent" gave no hint why. The vault token is keyed by (workload identity, userId), so the overwhelmingly common cause is that the token was vaulted under a DIFFERENT workload identity than the worker queries: e.g. a consent done through local dev (AGENTCORE_RUNTIME_WORKLOAD_NAME=local_dev_inference) can't be read by the deployed worker (platform-workload), and vice versa. That exact mismatch cost hours to diagnose. Surface the workload name, userId, and provider the lookup used in the pause warning so the mismatch is obvious in one log line. Co-Authored-By: Claude Opus 4.8 * chore(kb-sync): plumb CDK_KB_SYNC_ENABLED into the platform deploy KB sync ships dark: the EventBridge rule is created disabled and the KB_SYNC_ENABLED kill switch is false unless CDK synths with CDK_KB_SYNC_ENABLED=true (config.ts). platform.yml never forwarded that var, so a CDK deploy always synthed the feature off β€” the only way to enable it was an out-of-band CLI flip that reverts on the next deploy. Forward it as an environment-scoped `${{ vars.CDK_KB_SYNC_ENABLED }}` like every other CDK_ var. The development environment variable is set to "true" (enables the rule + kill switch in dev-ai durably); production leaves it unset, so it stays dark until validated there. Co-Authored-By: Claude Opus 4.8 * chore(kb-sync): default the feature ON with a kill switch KB sync shipped opt-in (default off), a holdover from ship-dark incremental development. The feature is complete and guarded, so a deployer/cloner of the public stack should get it working by default β€” a hidden default-off flag is bad DX. Invert to opt-out: enabled unless CDK_KB_SYNC_ENABLED=false (or a `kbSync.enabled: false` cdk.json context). The workflow forwards `${{ vars.CDK_KB_SYNC_ENABLED }}`, which is an empty string when unset β€” a plain `?? false` fallback wouldn't flip the default, so config.ts treats empty/unset as "use the default (on)" and only the literal "false" as the kill switch. Adds config-resolution tests covering unset / empty / "true" / "false" / context-disable. Co-Authored-By: Claude Opus 4.8 * feat(kb-sync): clarify the auto-sync control and always show last-synced The knowledge-base sync control was a bare "Manual only" dropdown that never explained what syncing does, and its status line only appeared while a schedule was active β€” a manual-only file showed nothing. - Lead the control with an "Auto-sync from " label (with a descriptive title tooltip) so every row states what it is and where it pulls from. - Rename the options to self-describing verbs: "Don't auto-sync", "Sync daily/weekly/monthly". - Always surface a "Last synced" line, including manual-only sources, via a new lastSyncedAt input fed from Document.lastSyncedAt. - Pair a relative age with an absolute date/time ("Synced 2h ago Β· Jul 3, 2:14 PM") for both "how long ago" and "exactly when". Display-only; the timestamps already exist in the data model. Co-Authored-By: Claude Opus 4.8 * fix(kb-sync): tidy the sync row layout and repair blank sync timestamps Aligns the knowledge-base sync control with the approved mockup and fixes a bug that rendered the status line as a bare "Synced Β· next sync". Layout: - Move the "Auto-sync from " context out of the control's button row (where it forced "Pause" to wrap) and into the file's meta line. - Compact the control to just the schedule select + actions. - Add a status-line dot (green healthy / amber attention / grey idle). - Top-align the status icon and download/trash actions with the filename (items-start) instead of floating against the taller row. Timestamp bug: several generators built ISO strings as `datetime.isoformat() + "Z"`, yielding "…+00:00Z" (offset AND Z). That is invalid ISO 8601 and parses to Invalid Date in strict engines (Safari), so last-sync / next-sync rendered blank. Normalize to a single trailing Z in service, dispatcher, and worker; harden the dispatcher parser to always return a UTC-aware datetime; and harden the SPA to tolerate the legacy "+00:00Z" so already-persisted policies still render. Download and delete controls on documents are preserved. Co-Authored-By: Claude Opus 4.8 * fix(kb-sync): give web sources the same sync treatment as documents The web-source (crawl) row shared the sync control with documents but was missing the parity bindings the document row got: no source-context line and no persistent last-synced timestamp, so a manual web source rendered as a bare dropdown while an identical document showed full context. - Add the meta-line context "Auto-sync from the web" (guarded by isCrawlSyncable), mirroring the document's "Auto-sync from ". - Feed the control lastSyncedAt from crawl.completedAt so a manual web source still shows when it last refreshed (the component already prefers the policy's lastSyncAt when a schedule is active). - Make the crawl meta line wrap-safe (flex-wrap), matching the doc row. Co-Authored-By: Claude Opus 4.8 * feat(kb-sync): unified skeleton loading for the knowledge base lists The Web sources list had no loading flag, so it popped into existence after its network round-trip, while Uploaded Documents showed a lone centered spinner β€” two async lists with inconsistent, jarring loads. Introduce one initial-load gate for both lists: - Add isLoadingCrawls, set from loadSyncData (cleared on the viewer early-return and in finally), mirroring isLoadingDocuments. - isLoadingKnowledge computed gates both lists so they reveal together; raise both flags synchronously in ngOnInit so the first paint is the skeleton, not an empty flash. - Replace the documents spinner with a skeleton list (pulsing icon + two text bars, varied widths) that mirrors the final row shape β€” no layout shift, and consistent with the existing connector-button skeleton. Co-Authored-By: Claude Opus 4.8 * feat(kb-sync): show a "Saving…" indicator while a sync change applies Changing the schedule (e.g. Don't auto-sync β†’ Sync weekly) held the busy state for the whole create/update/delete round-trip but only disabled the controls β€” there was no positive sign anything was happening, and the select eagerly reverts to its old value until the mutation confirms, so it read as "nothing happened". While busy, the status slot now shows a spinner + "Saving…" (role=status), taking precedence over the sync status line β€” including for a manual-only source being enabled, where no policy or status exists yet. Co-Authored-By: Claude Opus 4.8 * fix(users): normalize sync timestamps to strict ISO 8601 (single Z) UserSyncService built timestamps as `datetime.isoformat() + "Z"`, yielding "…+00:00Z" β€” both an offset AND a Z. That is invalid ISO 8601 and parses to Invalid Date in strict engines (Safari), so the admin user-list "Last login" and user-detail "Created"/"Last login" dates (rendered via `new Date()`) showed "Never". Same class of bug just fixed across KB-sync (service/dispatcher/worker); this applies the identical normalization to the users domain. - Write path: add `_iso()` helper, normalize `created_at`/`last_login_at`. - Read path: add `_heal_iso()` and apply in `_item_to_profile` / `_item_to_list_item` so legacy "+00:00Z" rows render correctly. Necessary because `created_at` is preserved across logins forever (never rewritten), so pre-fix users would otherwise show "Never" in Safari permanently. - Regression tests for the write-path invariant and read-path heal. `last_login_at` also backs GSI2SK/GSI3SK (sort-by-last-login); the suffix change is lexicographically safe β€” ordering is dominated by the microsecond datetime prefix and a transient mix of "+00:00Z"/"Z" rows does not corrupt it. Co-Authored-By: Claude Opus 4.8 * feat(assistant-editor): group knowledge base into a contrasted inset panel Wrap the Knowledge base section on the Assistant editor in a rounded, bordered inset with a subtle gray-100/60 fill instead of the flat border-t divider shared by other sections. Against the gray-50 form column this reads as a mildly contrasted group, and the white inner lists (Web sources / Uploaded Documents) now float on the tint to reinforce the grouping. Drops the border-t/pt-8 divider since the card provides its own separation; the form's space-y-8 preserves the top gap. Co-Authored-By: Claude Opus 4.8 * feat(connectors): add Google connector logo SVGs Add Drive, Docs, Gmail, and Calendar icon assets under public/logos. Co-Authored-By: Claude Opus 4.8 * docs(specs): agentic platform primitives plan + scheduled-runs design + spike brief Reframe the proactive-agent effort from a single "Oliver" feature into a primitive-enablement plan, mapped onto the Harness (headless run entrypoint) and Registry (catalog + governance) explorations. - agentic-platform-primitives.md: primitive maturity + gap ledger, the six fundamentals (F1 headless run entrypoint .. F6 registry/governance), phased plan, and Harness/Registry overlap. Oliver demoted to one validation use case among several. - scheduled-agent-runs.md: detailed design for F1+F2+F3 (renamed from the earlier Oliver draft; generalized so any config/prompt/cadence works). - harness-entrypoint-spike-brief.md: tight spike brief for the F1 keystone (unattended-as-user auth + server-side SSE), to run in dev-ai. Resolved decisions: F1 = minimal internal run_agent_headless(), A2A-ready; governance floor (F6a) pulled forward into Phase A, Registry discovery (F6b) deferred. Open: KB decoupling appetite, floor depth, sequencing. Co-Authored-By: Claude Opus 4.8 * feat(harness): spike headless agent-run entrypoint (F1) β€” proven in dev-ai run_agent_headless in apis/shared/harness: per-owner Cognito bearer mint (workload-token + SigV4 front-door paths proven dead at the runtime gateway), server-side SSE drain pinned to live wire shapes, F6a audit records + guardrails/classification seams, delivery via the runtime's own session materialization + title override. Driver script reproduces the dev-ai proof and the negative auth probes. Findings + Phase A design in docs/specs/harness-entrypoint-spike-findings.md. Co-Authored-By: Claude Opus 4.8 * docs(specs): record F1 spike findings + lock decision-gate calls The headless-run entrypoint spike (Fable 5) is GO β€” proven end-to-end in dev-ai. Bring its findings doc onto develop as the decision record and lock the four gate decisions into the plan of record. - Add harness-entrypoint-spike-findings.md (design deliverables + Phase A punch list; full spike code lives on branch spike/harness-headless-entrypoint). - agentic-platform-primitives.md Β§6: resolve act-as-user auth policy (Cognito per-owner token behind an explicit headless-grant record), F6a floor depth (audit fail-closed now; PII checkpoint required before unattended schedules), KB decoupling (defer), sequencing (proactive-spine-first confirmed). - scheduled-agent-runs.md Β§8: mark unattended-auth resolved with the chosen path. Co-Authored-By: Claude Opus 4.8 * feat(harness): headless-grant record + production hardening of the F1 primitive Replace the spike's BFF-table Scan with an explicit headless-grant record (apis/shared/harness/grants.py): create-on-enable from an attended session, per-owner lookup via the sparse HeadlessGrantUserIndex GSI, revocation that deletes the stored credential, and a documented "must have logged in within 30 days" policy (TTL anchored to the login that issued the pinned refresh token, matching the Cognito refresh-token validity). CognitoRefreshBearerAuth now mints from the grant (rotation-aware: a rotated refresh token is persisted back before the mint returns β€” punch-list #5). Dedupe build_invocations_url into the harness as the single canonical resolver; the chat proxy imports it (punch-list #4). Document the enabled_tools=None = all-RBAC-allowed semantic and the schedule-snapshot rule (punch-list #7). Governance docstrings updated to the locked F6a decision: audit-only fail-closed + wired no-op guardrail/classification seams, implementations gated to the scheduled phase. Add an _build_http_client seam and tests covering the grant lifecycle, grant-backed minting, audit fail-closed ordering, and stream outcomes against a MockTransport runtime. Co-Authored-By: Claude Opus 4.8 * feat(runs): cookie-authed "Run now" surface behind flag + RBAC capability POST /runs/now executes one agent turn through the exact unattended path a scheduled run will use (create-on-enable grant -> per-owner Cognito mint -> runtime /invocations -> server-side SSE drain -> governance floor -> session materialization) β€” the PR-1 validation surface from docs/specs/scheduled-agent-runs.md Β§7. GET/DELETE /runs/grant expose grant status and total revocation. Gating is two independent controls (spec Β§6): the SCHEDULED_RUNS_ENABLED kill switch (default ON; only the literal "false" disables β€” empty workflow vars can't dark-stop prod) and a new `scheduled-runs` RBAC capability resolved through the mature tools grant axis (apis/shared/rbac/capabilities.py) β€” GA = grant the id to the default role. Auth is the standard SPA cookie dependency per the CLAUDE.md app-api rule; mint failures surface as 409, never 401, so the SPA is not bounced through the login redirect. Co-Authored-By: Claude Opus 4.8 * feat(infra): SCHEDULED_RUNS_ENABLED flag + HeadlessGrantUserIndex GSI Thread the scheduled-runs kill switch through CDK: scheduledRuns.enabled in config.ts (the CDK_KB_SYNC_ENABLED empty-string-safe ternary, copied exactly) -> SCHEDULED_RUNS_ENABLED on the app-api container -> CDK_SCHEDULED_RUNS_ENABLED forwarded by platform.yml. Default ON with a kill switch; nightly inherits the default. Add the sparse HeadlessGrantUserIndex GSI (grant_user_id / created_at) to the BFF sessions table backing apis/shared/harness/grants.py β€” only HEADLESS-GRANT# items carry the partition attribute, so session rows never project into it. App-api's existing table grant already covers index/*, so no IAM change. Tests mirror the kbSync flag matrix and pin the GSI shape. Co-Authored-By: Claude Opus 4.8 * docs(specs): Phase B scoping brief β€” scheduler + PII-ordering prerequisite Records the work breakdown, model tiering, and the one design fork gating scheduled delivery: the F6a classify_output checkpoint must run in-loop (or as a post-hoc scrub), not pre-delivery, because the runtime turn persists the session during the turn. B1 (inert schedule CRUD) is safe to start now. Co-Authored-By: Claude Opus 4.8 * docs(specs): governance for scheduled runs is role/auth-based; drop B0 PII gate A headless run executes as the owner with the owner's RBAC and delivers only to the owner's own session list, so it crosses no new access boundary and introduces no new recipient. Governance = RBAC (run-as-user) + grant lifecycle + quota + fail-closed audit β€” all already built. The content classification pass adds nothing for deliver-to-self, so B0 collapses and B2 delivery is unblocked. Revises primitives Β§6-4 and the Phase B brief Β§1. Co-Authored-By: Claude Opus 4.8 * feat(schedules): B1 β€” schedule data model + CRUD (inert) Add ScheduledPrompt model + ScheduledPromptService (apis/shared, mirroring sync_policies) and app-api CRUD under /schedules β€” create/list/get/update/ pause/resume/delete, gated by SCHEDULED_RUNS_ENABLED + the scheduled-runs RBAC capability. Cadence (daily/weekday/weekly) -> next_run_at is computed timezone-aware in the service so the future dispatcher stays a dumb "who's due" query. enabled_tools is snapshotted at creation (Phase A punch #7). Storage rides the existing sessions-metadata table (PK=USER#{user_id}, SK=SCHEDPROMPT#{schedule_id}), with a new sparse DueScheduleIndex GSI (GSI3_PK/GSI3_SK, distinct from SessionLookupIndex's GSI_PK/GSI_SK to avoid attribute collision) projected only while state=="active". app-api already holds CRUD+index/* IAM grants on this table, so no IAM changes were needed. Deliberately inert: nothing fires yet. The dispatcher/worker that reads DueScheduleIndex and calls run_agent_headless is B2. Co-Authored-By: Claude Opus 4.8 * feat(schedules): B3 β€” schedule management SPA + enablement Adds the user-facing schedules feature: a signal-based list/create/edit page under frontend/ai.client/src/app/schedules/ (bounded cadence UI β€” daily/weekday/weekly + hour + IANA timezone, optional assistant + tool snapshot, pause/resume/delete with confirm), the headless-grant enablement UX (status banner, "Enable scheduled runs", paused_error reauth_required / oauth_required affordances), and capability-gated nav visibility that rides the /schedules list call's 403/404 rather than a dedicated client signal. Backend: adds POST /runs/grant to apis/app_api/runs/routes.py, sharing the existing _resolve_grant create-on-enable logic with /runs/now so a user can turn on scheduled runs without running a prompt first. Same kill-switch + scheduled-runs capability gating. Co-Authored-By: Claude Opus 4.8 * feat(schedules): B2 β€” scheduler dispatcher + worker EventBridge rate(5m) -> dispatcher (sweeps the sparse DueScheduleIndex, runaway guard, conditional re-arm before fire-and-forget) -> worker (mints a per-owner Cognito bearer, run_agent_headless(trigger="schedule"), records the outcome, pauses on reauth_required / oauth_required / repeated_failures). Two Docker Lambdas share one image (Dockerfile.scheduled-runs) via ImageConfig.Command; bootstrap-stub + out-of-band image deploy mirroring kb-sync. IAM scoped to sessions-metadata RW, BFF-grant table RW, app-client secret read, and AgentCore vault token + oauth-secret read; no SigV4/InvokeAgentRuntime (the minted bearer is the front door, matching the Run-now path). Delivery flows straight through β€” governance is role/auth-based. Fixes the failure breaker: added a persistent consecutive_failures counter (model + record_run_result, mirroring sync_policies) so repeated_failures pauses at the production default of 3. The original last-status proxy capped the streak at 2 and could never trip at the default; unified the worker's two error paths on the returned streak and added regression tests at the default threshold. Co-Authored-By: Claude Opus 4.8 * fix(schedules): register nav routes in specs to fix CI NG04002 The schedule-form and schedules-list specs used provideRouter([]), but the components navigate programmatically (router.navigate(['/schedules']) etc.) on save/cancel/create/edit. With no matching route the navigation rejects (NG04002) AFTER the test body, surfacing as unhandled rejections that fail the whole vitest process even though all 1378 tests pass. Register the target routes so the real router resolves them. Scoped ng test: 46 passed, 0 errors. Co-Authored-By: Claude Opus 4.8 * fix(schedules): make scheduled-runs worker image importable The worker Lambda crashed at INIT with ImportModuleError: the lean scheduled-runs image (Dockerfile.scheduled-runs) never installed cryptography or cachetools, which the worker pulls in transitively via apis.shared.harness -> apis.shared.sessions_bff (cookie.py AESGCM, cache.py TTLCache). Even past that, apis/shared/sessions/metadata.py imported agents.main_agent...is_preview_session at module top level, dragging the agents+strands packages (deliberately absent from the lean image) into the worker's runtime delivery path. - Defer the preview-session import in sessions/metadata.py to call time. The two functions the headless delivery path uses (ensure_session_metadata_exists / update_session_title) never call it, so the agents import is never triggered in a headless run. Public name and behavior unchanged for live app_api/inference_api callers. - Add cryptography==48.0.1 + cachetools==6.2.4 to the shared image requirements (dispatcher requirements.txt is the file the Dockerfile installs; worker kept in sync). Verified: worker + dispatcher import cleanly in an isolated lean-image simulation (image pins + bundled modules only, no agents/strands). Surfaced by dogfooding a real scheduled run in dev-ai. Co-Authored-By: Claude Opus 4.8 * fix(schedules): make preview-session check importable in lean worker image Follow-up to the worker-image import fix (#566). Dogfooding the deployed worker surfaced a second, deferred failure: the headless delivery path (runner -> ensure_session_metadata_exists, metadata.py:773) DOES call is_preview_session, so the previous lazy-import wrapper only moved the ModuleNotFoundError: No module named 'agents' from INIT to run time. The run still 'completed' (the runtime materializes the session during the turn), but the idempotent session-row ensure + title override were skipped ('result delivery failed' in the worker log). is_preview_session is a trivial startswith('preview-') check; its weight came only from agents/strands imports in agents.main_agent.session.preview_session_manager. Move a dependency-free copy into apis.shared.sessions.preview (mirroring what apis.inference_api.chat.routes already does with its own local copy) and import it in metadata.py. A drift-guard test keeps the literal in lockstep with agents...Prefixes.PREVIEW_SESSION. Verified: lean-image simulation now imports metadata + runs is_preview_session with no agents/strands; 77 targeted tests pass. Co-Authored-By: Claude Opus 4.8 * fix(scheduled-runs): intersect client enabled_tools with RBAC on headless paths Client-supplied `enabled_tools` was trusted end to end on the headless surfaces: schedule create/update and "Run now" froze/forwarded the request body verbatim, and inference-api's tool filter performs no RBAC check of its own (registry membership is its only gate). A user holding the scheduled-runs capability could therefore craft a request enabling a tool outside their AppRole, and β€” for schedules β€” persist it into an unattended, repeating run. Add `AppRoleService.filter_requested_tools`, a narrow-never-grant intersection of a requested tool list against the caller's resolved RBAC grant (honors the `*` wildcard; admits a scoped `base::tool` id when its base server is granted), mirroring the existing `_apply_enabled_skills_filter` contract on the skills axis. Apply it at the three app-api write/entry points where a full User (with roles) is in hand: schedule create, schedule update (PATCH must not bypass the create-time check), and run-now. `None` is preserved as "resolve to defaults". Note: fire-time re-intersection against *current* RBAC (so a later role revocation disarms a sleeping schedule) is deferred β€” the worker/dispatcher only carry a bare user_id, not the owner's live roles. Tracked as follow-up. Co-Authored-By: Claude Opus 4.8 * test(schedules): guard the scheduled-runs lean image against agents/strands imports Add an AST-based test asserting the modules bundled into backend/Dockerfile.scheduled-runs (harness/, scheduled_prompts/, sessions_bff/, sessions/ + the two lambda handlers) never import agents/ or strands β€” top level OR lazy, since a deferred import still crashes at call time in the lean image. This is the guard that would have caught the ModuleNotFoundError shipped in the first cut of the worker. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): scope managed-Harness build-vs-adopt spike for the headless lane Surfaced while dogfooding scheduled runs: we use AgentCore Runtime (BYO container), not the newly-GA managed Harness. Brief evaluates adopting the managed Harness as the backing for the headless/scheduled/proactive lane only (interactive chat stays build β€” it needs hooks/custom-loop/MCP-Apps the managed Harness forbids). Captures pros (managed memory fixing the write-only-in-cloud gap, versioned endpoints, Step Functions, export escape hatch), cons, and three gating spike questions. Queued for kaizen review. Co-Authored-By: Claude Opus 4.8 * feat(schedules): let schedule edits clear assistantId/enabledTools B1's update_scheduled_prompt skipped None, so a PATCH could never detach a schedule's assistant or reset its tool restriction β€” the SPA's clear checkboxes were inert (a bare null reads as 'leave unchanged'). Add an explicit clear contract: an UNSET sentinel in the service distinguishes 'omitted' (leave) from None (clear -> REMOVE the attribute). UpdateScheduleRequest gains clearAssistant/clearTools booleans (rejected if combined with a value). clearAssistant reverts to the default agent; clearTools re-snapshots the caller's current RBAC-allowed tools, mirroring creation so a schedule never stores an unresolved None. SPA sends the flags. Co-Authored-By: Claude Opus 4.8 * fix(app-api): grant bedrock:ListFoundationModels to task role The admin GET /admin/bedrock/models endpoint calls the Bedrock control plane's ListFoundationModels, but the App API Fargate task role only had bedrock:InvokeModel. In deployed environments the call raised AccessDeniedException, which the app-wide AWS-error handler maps to a generic 502 ("Upstream service error."). It only worked locally because local dev runs with the developer's broader AWS credentials. Add a BedrockListFoundationModels statement granting bedrock:ListFoundationModels and bedrock:GetFoundationModel. These are account-level list/read actions that do not support resource-level permissions, so they are granted on `*`. Requires a platform.yml (CDK) deploy to take effect. Co-Authored-By: Claude Opus 4.8 * feat(sessions): unread indicator for scheduled-run deliveries A scheduled (unattended) run delivers a session the user wasn't watching. Surface it: the sidebar shows an unread dot until they open it. - sessions/metadata.py: set_session_unread / mark_session_read β€” a targeted single-attribute UpdateExpression on the row's current SK (GSI-resolved), concurrent-safe with the title/activity writes; preview-session guarded; best-effort (never raises). - harness/runner.py: mark the delivered session unread only on a *completed* run with trigger == "schedule" β€” attended "Run now" and failed/consent- blocked runs never set the dot (the user is present / there's nothing to read). - app_api POST /sessions/{id}/read: clears the durable flag when the user opens the session; idempotent, ownership-enforced via the GSI lookup. - SPA: durable server-persisted unread on SessionMetadata (survives reload, reaches other devices) ORed with ChatStateService's ephemeral in-tab unread for interactive background completions; session-list renders the dot. Backend 108 + frontend 25 tests pass. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): queue Bedrock Mantle endpoint watchlist item Phil-initiated kaizen focus: scope a future bedrock-runtime (Converse) -> bedrock-mantle endpoint migration. Watchlist/Defer β€” strategic alignment with where Bedrock capability lands first, not near-term need; Claude-on- Mantle is Messages-API-only today and lacks cross-region + native CountTokens + Guardrails, so the primary chat path stays on bedrock-runtime. Interim low-risk value = finishing the already-scaffolded non-Claude OpenAI-compatible Mantle lane. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): managed-Harness spike findings β€” 3 gating questions answered Complete the #570 build-vs-adopt spike for the headless/scheduled lane. Answered from the now-GA AWS managed Harness docs cross-checked against our code and the proven F1 entrypoint spike: - Q1 RBAC -> allowedTools: qualified yes (per-invoke globs; we already snapshot the RBAC-narrowed set statically at the app-api boundary). Non-membership gates relocate (quota/cost -> dispatcher; approval -> exclude on headless; consent -> Identity outbound). - Q2 per-user tokens: yes on mechanism (OAuth-inbound customJWTAuthorizer == our authorizer; Gateway outbound == our USER_FEDERATION exchange). SigV4 cannot do per-user identity. One residual: customParameters vault-key pinning through the Gateway-managed exchange -> live probe. - Q3 lose MCP Apps + SSE on headless: yes (interactive-only affordances; SPA loads the delivered session, not the harness stream). Recommendation: green-light a narrow InvokeHarness probe to close the Q2 residual; interactive inference-api untouched. Co-Authored-By: Claude Opus 4.8 * feat(sidenav): gate Scheduled Runs nav entry to system_admin Hide the "Scheduled Runs" menu option from non-admins while the feature is still maturing. Adds an isAdmin() check to the existing capability gate, mirroring the admin-dashboard nav pattern. isAdmin is already wired into the sidenav component from UserService; showSchedules keeps its accessibility-probe behavior. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): managed-Harness Q2 live-probe result β€” GO-with-boundary Records the live dev-ai probe (2026-07-06) that closes the Q2 customParameters residual from the spike findings. Confirmed live (real CreateHarness/InvokeHarness via boto3): our exact customJWTAuthorizer is accepted (harness READY); outboundAuth.oauth customParameters is a first-class field persisted verbatim on GetHarness (so we CAN pin the same params the consent flow uses); OAuth-inbound runs as the owner (HTTP 200); the exchange calls the same GetResourceOauth2Token our get_token_for_user uses; a failed exchange surfaces legibly as a typed runtimeClientError stream event (maps to paused_reauth). Boundary found: the managed Gateway 3LO (AUTHORIZATION_CODE) exchange fails with "must provide a ResourceOauth2ReturnUrl" and does not source that URL from defaultReturnUrl / OAuth2CallbackUrl header / workload AllowedResourceOauth2ReturnUrl β€” a GA wiring gap. Cross-workload token visibility (platform vs harness-own workload identity) unreached past it. Decision: GO to adopt Harness on the headless lane, but keep customParameters-sensitive / all 3LO connectors on our own get_token_for_user until the return-URL wiring is resolved with AWS. Aside: managed memory is on by default per harness (relevant to F5). Co-Authored-By: Claude Opus 4.8 * feat(sessions): add mark-as-read/unread toggle with sidebar dot fixes Adds a "Mark as read / Mark as unread" toggle to the session options menu in both the top nav and the sidebar session list, backed by a new POST /sessions/{id}/unread endpoint (mark_session_unread) that mirrors the existing /read verb. The client surfaces the dot instantly via the ChatStateService unread signal while the durable server flag lands async. Fixes two sidebar-only bugs where the in-row options trigger lives inside the row's group, so the CDK menu restoring focus to it on close tripped group-focus-within: - Bug 1: the ellipsis trigger stayed visible after a menu action. Switch its reveal from :focus / group-focus-within to :focus-visible, so mouse-restored focus no longer reveals it while keyboard nav still does. - Bug 2: a freshly marked-unread dot didn't appear until the next browser interaction. The dot was being hidden by group-focus-within (restored trigger focus); scope that to group-has-[button:focus-visible] instead, and kick a synchronous refreshSessions() in the mark-unread branch (mirroring mark-read) so the OnPush row re-renders immediately. The top-nav toggle was unaffected because its trigger sits outside the row. Co-Authored-By: Claude Opus 4.8 * feat(schedules): interval cadence + "Run now" with background-task toasts Two additions to the Scheduled Runs feature: - Interval cadence: schedules can now run every N minutes/hours in addition to daily/weekly. Adds interval_value/interval_unit to the scheduled-prompt model and API, a MIN_INTERVAL_MINUTES floor enforced on create/update/ resume, and interval_to_minutes() feeding compute_next_run_at. The schedule form gains the interval option with matching validation. - "Run now": run a schedule's prompt headlessly on demand from the schedule form. RunNowService fires POST /runs/now (existing app-api endpoint) as fire-and-forget and reports progress through a new app-wide toast system β€” BackgroundTaskService (signal-backed task list) rendered by the BackgroundTaskToastsComponent mounted in app.html. On completion the run materializes as a real session; the list is refreshed and the toast offers a "View" affordance. Tests: backend test_schedules_routes / test_scheduled_prompts / test_harness_runner (100 passed); frontend schedule-form, run-api, run-now, background-task, and background-task-toasts specs (30 passed). Co-Authored-By: Claude Opus 4.8 * docs(memory): reframe spec as Memory Spaces β€” bindable, templated, shareable Reframes the per-user markdown memory spec into the Memory Space primitive: a named, first-class, bindable, templated, and shareable markdown wiki. Oliver becomes a Chief-of-Staff template + a bound agent rather than a special-cased feature. - Adds the three-layer abstraction (Agent / Memory Space / declarative binding) and the structural-config vs. semantic-MEMORY.md split. - Space-keyed storage (SPACE#{id} + membership records + UserSpacesIndex GSI) so sharing is expressible from day one. - Entry types (entity / episodic / fact), Space Templates, and manifest- indexed fields for relational/temporal queries ("who owes what"). - Sharing via owner/editor/viewer grants mirroring assistant collab-edit, with run-as-user write attribution. - Data governance is proportionate: same data class as sessions/artifacts behind the same Entra JWT + RBAC β€” identity-based, no content-inspection gate. Deletion-purge + inherited encryption are the only real items. - 8-PR phasing; PR-1 (data layer) is the next buildout phase. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): recover resourceOauth2ReturnUrl shape section into harness findings PR #576 merged from a state prior to commit 908f7e18, orphaning the resourceOauth2ReturnUrl parameter-shape + harness-security cross-check subsection. This re-applies it onto develop. Co-Authored-By: Claude Opus 4.8 * docs(memory): bake in full-ownership zip export of a Memory Space Add a first-class "download the entire space as a .zip of raw markdown" capability (index + all entries, structure preserved, metadata.json), framed as the user-ownership / zero-lock-in property. Promotes the stubbed export endpoint into Β§9 with contents, access, streaming mechanics, and an import-friendly round-trip note; folds the zip export into PR-5. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces data layer (PR-1) Adds the F5 Memory Space primitive's data layer β€” no runtime wiring, gated by MEMORY_SPACES_ENABLED (default off). Backend (apis/shared/memory/): - store.py: S3 content-addressed byte store (sibling of the skills store) - models.py: MemorySpace / MemoryIndex / MemoryEntryRef / SpaceMember - templates.py: Blank / Chief-of-Staff / Research-Notebook presets - repository.py: dedicated memory-spaces table CRUD (META/INDEX/MEMBER rows, OwnerIndex + MemberIndex GSIs, Decimal handling) - service.py: permission-gated lifecycle + sharing + entry/index I/O, resolve_permission chokepoint (viewer reads, editor writes, owner shares/deletes), content-addressed writes with GC-on-replace - feature_flags.py: memory_spaces_enabled() (default off) - 47 moto-backed tests; import boundaries clean Infrastructure: - MemorySpacesConstruct: S3 bucket + dedicated memory-spaces DynamoDB table (OwnerIndex/MemberIndex GSIs), a per-domain table matching the project's actual pattern - Threaded via PlatformComputeRefs to both compute roles (readwrite S3 + DynamoDB); env vars S3_MEMORY_SPACES_BUCKET_NAME / DYNAMODB_MEMORY_SPACES_TABLE_NAME / MEMORY_SPACES_ENABLED - config.ts flag default-off; resource-count assertions updated - tsc + 429 jest tests pass Spec updated to reflect the dedicated-table + two-GSI decisions. Co-Authored-By: Claude Opus 4.8 * docs(memory): re-slice phasing into primitive vs. agent-consumption workstreams Splits the plan into two workstreams to keep the Memory Space a clean bindable primitive: Workstream A (this epic) delivers the primitive + the user-facing "own your data" surface (data layer, app-api CRUD, export, sharing, SPA panel, consolidation); Workstream B (Agent/Harness layer) delivers agent-consumption β€” the memory_* tools, declarative binding, and system-prompt index injection β€” so any run surface can bind the same primitive rather than welding it to inference-api. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces user surface β€” /memory/spaces CRUD (A2) Workstream A2 of the re-sliced memory epic: the user-facing "own your data" surface over the Memory Space primitive. No agent-consumption (tools / binding / prompt injection) β€” that's the Agent/Harness workstream. app-api (apis/app_api/memory_spaces/): - routes.py: /memory/spaces CRUD over MemorySpaceService β€” list (with templates + accurate per-space role), create-from-template, get (index + entry manifest), delete-or-leave, entry read/list/upsert/delete, index read/update. Sync handlers (FastAPI threadpools the sync boto3 service). - Gated by require_memory_spaces_user: 404 while MEMORY_SPACES_ENABLED off (surface behaves as unmounted); cookie auth via get_current_user_from_session. - Service errors translated NotFound->404, Permission->403, Error->400. - models.py: camelCase request/response models. - Mounted before the existing /memory (AgentCore Memory) router; paths are non-overlapping (/memory/spaces vs /memory/{record_id}). shared service: - leave_space(): a member drops their own grant (the shared-in forget-me case); owner cannot leave. - list_spaces_for_user() now returns (space, role) so shared-in spaces carry the member's real viewer/editor grant (only consumer is the new route). Tests: 12 route tests (moto-backed real service, flag gate, CRUD, 403/404, member-leaves-via-delete) + leave_space service tests. Full memory suite + import boundaries green (69 passing). Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Space zip export β€” /memory/spaces/{id}/export (A3) The "own your data" leg of Workstream A: a loss-free `.zip` download of a space's raw markdown (Β§9). `MemorySpaceService.export_space` gathers the corpus once (index + every entry's bytes) behind the viewer+ permission gate, including the member grant list only for editor+ callers (mirrors `list_members`). The app-api route builds the archive in a `SpooledTemporaryFile` β€” spilling to disk beyond 8 MiB so a large space never pins memory β€” and streams it back. Zip mirrors the S3 layout (`{name}/MEMORY.md`, `entries//.md`, `metadata.json`) so it is self-contained and re-importable later. Archive path components are sanitized against zip-slip. Route tests cover layout, verbatim frontmatter, owner-vs-viewer member disclosure, 403/404/flag-off, and the hostile-slug case. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Space sharing + optimistic manifest concurrency (A4) The sharing leg of Workstream A, plus the concurrency guarantee that makes multi-editor spaces safe. Sharing surface (app-api, over the existing service grant methods): - GET /memory/spaces/{id}/shares list grants (editor+) - POST /memory/spaces/{id}/shares grant viewer|editor (owner) - PATCH /memory/spaces/{id}/shares/{email} change a grant's role (owner) - DELETE /memory/spaces/{id}/shares/{email} revoke (owner, idempotent) New `MemorySpaceService.update_share` gives PATCH proper not-found semantics and preserves the grant's original createdAt (distinct from share's upsert). Optimistic manifest concurrency (the real design content): - `MemorySpaceRepository.put_index(expected_version=…)` does a conditional DynamoDB write on the manifest `version`, raising the repository-local `OptimisticLockError` on a mismatch. - `write_entry`/`delete_entry` route through a new `_mutate_index` helper: a bounded read-modify-conditional-write retry loop. Because an entry write touches a single slug, re-reading the fresh manifest and re-applying is safe; it converges on transient races and raises `MemorySpaceConcurrencyError` (β†’ 409) only on a sustained one. Behavior is unchanged for single-writer spaces. Tests: 6 route tests (share CRUD, member gains access, non-owner 403, viewer can't list, PATCH-unknown 404, owner-role 422) + 7 service tests (update_share role/origin/owner-gate + version-increments, stale-write rejected, retry converges, gives-up-after-max). 76 memory + import-boundary tests green. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces SPA panel β€” list/detail/create/share/export (A5) The user-facing "Memory" surface for the Memory Space primitive, under frontend/ai.client/src/app/memory-spaces/. Makes A2–A4 visible to users. - List page: owned + shared-in spaces as cards with role/template badges; per-card open, share (owner), download .zip, delete/leave. Empty, loading, error, and feature-unavailable states. - Detail page: view/edit the MEMORY.md index (editor+) and the entry list; entries open in a dialog to view (viewer) or edit/create (editor+); delete per entry. Header carries share/download/delete-or-leave. - Create-from-template dialog and a share dialog (add-by-email + per-row role + delta-on-save over the A4 /shares endpoints). Viewer access is read-only throughout; the share dialog fails soft for non-owners. - Signal facade (MemorySpaceService) + thin API service mirror the assistants/schedules pattern. The nav entry rides a live accessible$ probe: a 404 (MEMORY_SPACES_ENABLED off) hides it, matching showSchedules. - Routes memory-spaces + memory-spaces/:id (authGuard); redesign-tokens and @angular/cdk/dialog conventions throughout. Facade spec (7 tests) green; dev build + tsc clean; sidenav specs still pass. Co-Authored-By: Claude Opus 4.8 * test(memory): mock MemorySpaceService in sidenav spec (fix unhandled rejection) The A5 sidenav now injects MemorySpaceService and probes `loadSpaces()` in the auth effect. The sidenav spec mocked ScheduleService but not the new service, so the authenticated-probe test constructed the real MemorySpaceService, which fired a real XHR to /memory/spaces (status 0, no backend); `loadSpaces` then re-threw, surfacing as a Vitest "unhandled rejection" at suite level. Mock MemorySpaceService exactly as ScheduleService is mocked, and add matching coverage: probe fires once authenticated (not while unauthenticated) and the `showMemorySpaces` nav gate resolves nullβ†’false, falseβ†’false, trueβ†’true. Full suite: 1422 passed, 0 errors. Co-Authored-By: Claude Opus 4.8 * fix(memory): wire memory-spaces table/bucket names onto app-api app-api owns the Memory Spaces CRUD surface (`/memory/spaces/*`) but its container environment only set MEMORY_SPACES_ENABLED β€” never the table or bucket names the service reads (DYNAMODB_MEMORY_SPACES_TABLE_NAME / S3_MEMORY_SPACES_BUCKET_NAME). Without them the repository falls back to the default "memory-spaces" table name, which doesn't exist, so every read throws a boto3 ResourceNotFoundException that the centralized handler maps to a 502 "Upstream service error." (inference-api already sets the identical trio, but per the service-boundary rule it isn't the one serving these routes.) Thread `refs.memorySpacesTable`/`.memorySpacesBucket` through AppApiSsmParams and emit both names next to the flag in buildAppApiEnvironment. Names are always wired (read lazily); only MEMORY_SPACES_ENABLED gates route mounting, so flipping the switch on later needs no env change. New unit test guards the wiring; tsc + 431 infra jest tests green. Co-Authored-By: Claude Opus 4.8 * docs(cdk): capture the "wire resource name to every compute" rule Fold the lesson from the app-api env-wiring fix into the cdk-infrastructure skill so the next construct-author sees it while wiring, not after a 502. Adds a "Cross-Construct References" subsection: set a resource's name env var on every compute that reads it (one doesn't imply the other), the silent-502 failure mode (default-name fallback β†’ ResourceNotFoundException β†’ generic 502, invisible to synth/CI), and the env-map test guard + service-boundary caveat. Co-Authored-By: Claude Opus 4.8 * feat(memory): deterministic consolidation health pass (A6) The safe, non-LLM slice of Workstream A6. `MemorySpaceService.consolidate` (editor+) + `POST /memory/spaces/{id}/consolidate` β†’ a `ConsolidationReport`. Auto-fixes only storage hygiene: orphaned content-addressed objects β€” keys under a space's prefix that no manifest entry or the index pointer references (leaks from crashed/raced writes) β€” are GC'd (new `MemorySpaceStore.list_keys` drives it). Everything that needs a judgment call is *reported, not mutated*: - duplicate content across slugs (same content hash) β€” which slug survives is semantic, so it's flagged, never auto-merged; - dead `[[slug]]` wikilinks in MEMORY.md β€” reported; opt-in `stripDeadLinks` unlinks them (they point nowhere) while preserving the surrounding prose; - over-cap entry counts (`MEMORY_SPACE_INDEX_CAP`, default 200) β€” flagged, never auto-evicted. This deliberately does not merge/evict/rewrite durable memory β€” that's deferred to the LLM consolidation pass (Workstream B era), which extends this exact `consolidate()` seam once agentic writes create real duplication/staleness to act on. On-demand only for now; scheduler/threshold auto-run and SPA surfacing are follow-ups. Tests: 8 service (healthy report, orphan GC + skip, dup-report-no-merge, dead-link report + strip-keeps-prose, over-cap flag, editor gate) + 4 route (report shape, no-body, viewer 403, flag-off 404) + 3 store (list_keys prefix scoping / empty / disabled). 104 memory + boundary tests green. Co-Authored-By: Claude Opus 4.8 * docs(agent): Agent Designer spec β€” unified primitive-binding surface Captures the strategy for the "Agent Designer" (Agent Harness Editor): a new authoring surface that composes an Agent from RBAC-governed primitives (instructions, model, KBs, tools, skills, Memory Spaces, + future), replacing the term/feature "Assistant." Locks the load-bearing decisions: own a primitive-agnostic Agent contract and federate AgentCore Registry later rather than build on it (adopt-with-boundary precedent); evolve the assistant store in place (no parallel table); a uniform bindings[] model with the model as a governed single-select; RBAC = compose the five existing per-primitive access checks (incl. ModelAccessService), not a new system; design-time filter + run-time re-resolution per invoker with block-on- missing v1; ship memory-consumption as a thin vertical slice before the full Designer. Phasing 0–5 + later AWS federation; supersedes the memory spec's "extend the Assistant" Β§B1 framing. Co-Authored-By: Claude Opus 4.8 * feat(agents): Agent contract + compat mapping in shared assistants models Phase 1 (PR-1) of the Agent Designer. Pure library, zero behavior change: legacy Assistants read unchanged and no caller passes the new fields yet. - AgentModelConfig (D3 governed single-select; field is model_settings/ alias modelConfig to dodge pydantic's reserved model_config β€” R3) - AgentBinding (open kind on read, KNOWN_BINDING_KINDS for request validation) - optional model_settings + bindings on Assistant (additive) - compat.effective_bindings/to_agent_view (D2): absent bindings synthesize a knowledge_base binding reffing the assistant id (KB's only stable identity, F4 deferred β€” R4); absent model maps to None, never fabricated (R1) - Decimal-safe serialization for modelConfig.params floats Co-Authored-By: Claude Opus 4.8 * feat(agents): persist bindings + modelConfig with design-time validation Phase 1 (PR-2). The Agent fields now round-trip through the rag-assistants store and are validated at write time by composing existing RBAC checks (D4). Legacy clients are unaffected: the SPA sends none of the new fields and the AssistantResponse surface is unchanged. - service.create_assistant/update_assistant thread bindings + model_settings; to_ddb_safe on write / from_ddb on read so modelConfig.params floats survive DynamoDB (Decimal); explicit [] replaces bindings, absent leaves them untouched - app_api/agents/services/binding_validation.py composes model access (ModelAccessService), memory resolve_permission (viewer+/editor+), the implicit-KB rejection, and inert shape-only checks for tool/skill (D4/D5) - assistants POST/PUT validate then pass through; validation raises 4xx outside the create handler's generic except so it isn't masked as 500 - tests: validation matrix (incl. inert no-RBAC guarantee), persistence round trip, legacy no-field read Co-Authored-By: Claude Opus 4.8 * feat(agents): /agents alias router behind AGENTS_API_ENABLED (dark) Phase 1 (PR-3). A governed Agent read/write surface over the evolved assistant store: same shared service functions and identity-based access gates as /assistants, but returning the Agent shape (compat.to_agent_view -> AgentResponse) so callers see modelConfig + bindings. Legacy ids valid unchanged. - feature_flags.agents_enabled(): AGENTS_API_ENABLED, default OFF (memory-spaces pattern) β€” surface 404s while off, ships incrementally, /assistants unaffected - app_api/agents/routes.py: require_agents_enabled 404-gate; draft/create/list/ get/update/delete + 4 shares endpoints, delegating to apis.shared.assistants service and reprojecting via to_agent_view; create/update run binding_validation - AgentResponse/AgentsListResponse/AgentSharesResponse (agentId == assistantId) - main.py mounts the router - test-chat + document sub-routes deliberately excluded (would force a 2nd architecture import-boundary exception); list is owner+shared (public/pagination parity deferred to the Phase-4 Designer) - tests: 404-gate, agentId/bindings projection, CRUD permission gating, shares Co-Authored-By: Claude Opus 4.8 * feat(agents): wire AGENTS_API_ENABLED through CDK + Phase 1 docs Phase 1 (PR-4). Completes Phase 1 deployability: the /agents surface can now be turned on per environment. No new AWS resources β€” the flag only gates whether the routes 404 (the assistant store it reads is always present). - config.ts: AgentsConfig { enabled }; CDK_AGENTS_API_ENABLED (default off, empty-string-safe) or an `agents.enabled` cdk.json context, mirroring memorySpaces exactly - app-api-environment.ts: AGENTS_API_ENABLED env on app-api - infra tests: default-off / opt-on assertion; mock-config default - docs: agent-designer.md Phase-1 status (+ the two refinements and the Oliver-dogfood-gated-on-Phase-3 note); CHANGELOG [Unreleased] The live Oliver dogfood (D6) is deliberately NOT included: it needs Phase 3 harness resolution + Memory Spaces deployed to the target env before a memory_space binding resolves at invocation. Tracked as the Phase 3 payoff. Co-Authored-By: Claude Opus 4.8 * feat(agents): thread AGENTS_API_ENABLED to the inference runtime (Phase 3 PR-0) Phase 3 harness resolution runs inside inference-api, so the runtime needs the same flag the app-api surface got in #593. Default off, mirrors the app-api wiring; without it the harness ignores Agent bindings entirely (today's behavior). Co-Authored-By: Claude Opus 4.8 * feat(agents): resolve Agent modelConfig at invocation, per invoker (Phase 3 PR-A) The Harness now re-resolves an Agent's governed modelConfig against the INVOKING user (D5) and applies it to model selection. Absent modelConfig β‡’ the model resolves exactly as today; gated on AGENTS_API_ENABLED (off in all envs still). - agent_binding_resolver.py: resolve_agent_invocation() checks the pinned model against AppRoleService.can_access_model for the invoker (R2 β€” same gate the harness uses elsewhere), returns a model_override or raises AgentBindingBlockedError. inference-api imports apis.shared only (boundary-safe) - routes.py /invocations: resolve after assistant load, before the KB search; on block, stream a conversational stream_error via stream_conversational_message (D5 block-with-message, no silent downgrade). Override wins at model resolution; agent params sit beneath request params, still flowing through admin bounds/locks - tests: allowedβ†’override, deniedβ†’block (checked vs invoker), no-modelConfigβ†’ empty plan (no RBAC call); 57 existing inference/chat tests unchanged Co-Authored-By: Claude Opus 4.8 * feat(agents): Memory-Space hydration helper for prompt injection (Phase 3 PR-B) Shared, sync helper that resolves a memory_space binding's alwaysLoad specs into injectable text fragments β€” the read side of Workstream B. - resolve_always_load(): MEMORY.md β†’ index; latest:/ β†’ most-recent matching manifest entry (defines that scheme, which had no resolver); bare slug β†’ entry. Missing entries skipped (never fails a turn). Byte-budgeted with a truncation marker pointing at memory_read (MEMORY_INJECTION_MAX_BYTES, ~24KB) - render_memory_block(): delimited system-prompt block; empty for a fresh space - reads go through MemorySpaceService (re-checks viewer+ internally) β€” no leak - 11 unit tests against a fake service Co-Authored-By: Claude Opus 4.8 * feat(agents): inject bound Memory Space into the prompt, per invoker (Phase 3 PR-C) The Harness now resolves an Agent's memory_space binding against the invoking user and injects the space's alwaysLoad content (read-only) into the system prompt β€” the first half of the Workstream B / Oliver payoff. - agent_binding_resolver: _resolve_memory() checks the invoker's grant via MemorySpaceService.resolve_permission (D4); blocks (D5) when the flag is off, the space is gone, or a readwrite binding meets a below-editor invoker (no silent read-only downgrade). Returns ResolvedMemoryBinding (v1: first binding) - routes.py: after prompt assembly, hydrate via resolve_always_load (asyncio.to_ thread; MemorySpaceService re-checks viewer+) and append render_memory_block; best-effort β€” a memory-read hiccup never fails the turn - tests: memory grant matrix (none/flag-off/missing/read-viewer/readwrite-viewer- block/readwrite-editor/invoker-identity); 78 inference+compat tests green Co-Authored-By: Claude Opus 4.8 * fix(agents): rename app_api.agents package to avoid shadowing top-level agents run-app-api.sh launches app-api with `cd src/apis/app_api && python main.py`, putting that directory on sys.path[0]. The new apis/app_api/agents/ package (Agent Designer surface, #591/#592) then shadowed the top-level `agents` package, so `admin/quota/routes.py`'s `from agents.main_agent...` resolved into it and crashed startup with `ModuleNotFoundError: No module named 'agents.main_agent'`. Tests never caught it β€” pytest runs from backend/ where `agents` resolves correctly. Production is unaffected (the container runs `uvicorn apis.app_api.main:app` from WORKDIR /app, so sys.path[0] is /app). Rename the package apis/app_api/agents β†’ apis/app_api/agent_designer (and its test dir) so its name can't collide with the top-level `agents` package. Pure rename: the /agents URL surface, router, and behavior are unchanged. Verified: `import agents.main_agent.quota.repository` and the full app-api module now load from src/apis/app_api; 34 agent/boundary tests pass. Co-Authored-By: Claude Opus 4.8 * feat(agents): memory_* tools scoped to an Agent's bound Memory Space (Phase 3) Completes the Workstream B write side: an Agent with a memory_space binding now gets memory_list / memory_read (always) and memory_write (readwrite bindings only) at invocation β€” Oliver can read AND write his space. - agents/builtin_tools/memory_spaces/: closure-scoped factories capturing the binding's space id + invoker identity, MemorySpaceService via asyncio.to_thread (artifact-tools pattern). Every call re-checks the grant inside the service (viewer+ read / editor+ write), so a revoked grant becomes an error tool-result mid-session, never a leak - routes.py _build_memory_tools(): appended to the extra_tools seam only when a memory binding resolved; write tool gated on access==readwrite. extra_tools agents are never cached β†’ tools closed over user A can't be served to user B - not gated on enabled_tools: the governing capability is the Agent's binding, not the user's tool picker (same reasoning as artifact tools) - tests: tool success/permission-error/not-found matrix + seam counts (none=0, read=2, readwrite=3); 84 inference+tool tests green Co-Authored-By: Claude Opus 4.8 * chore(memory): default Memory Spaces ON with a kill switch Memory Spaces is a complete feature (CRUD + SPA panel + agent binding), so it should ship enabled for every deployer/forker β€” opt-out, not opt-in β€” matching the kbSync / scheduledRuns convention. The table + bucket are already provisioned unconditionally in PlatformStack, so this only flips the runtime MEMORY_SPACES_ENABLED env var; no new infra footprint. - config.ts: memorySpaces.enabled default true (empty/unset workflow var = on, only literal "false" disables); interface + block docs updated - platform.yml: forward CDK_MEMORY_SPACES_ENABLED (kill switch) and CDK_AGENTS_API_ENABLED (per-env enable for the still-default-off /agents surface, so dev can turn it on for dogfooding without a code change) - app-api-environment.ts: comment reflects default-on - config.test.ts: 5 tests locking default-on + kill-switch + context override Agent Designer (AGENTS_API_ENABLED) stays default OFF until the Phase-4 Designer UI ships β€” a headless /agents surface helps no forker. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): Phase 4 β€” Agent Designer UI + bindable catalog API Ships the Agent Designer authoring surface (Phase 4) and its Phase-2 precursor, the bindable-primitives catalog. The pickers can't exist without the catalog, so both land together. Backend (Phase 2 catalog): - GET /agents/bindable?kind=model|tool|skill|knowledge_base|memory_space returns an RBAC-filtered palette, composing the 5 existing per-primitive access services (D4); no new RBAC invented. Uniform BindableItem shape so every picker consumes one contract. Route declared before /{agent_id} so the literal path isn't captured. knowledge_base β†’ empty (welded/synthesized); skill/memory_space β†’ empty when their feature flag is off. Behind AGENTS_API_ENABLED. - Fix binding_validation._validate_model: it resolved models via get_managed_model() β€” a primary-key lookup on the internal UUID β€” but modelConfig.modelId is the Bedrock model_id that the runtime resolver, RBAC (permissions.models) and invocation all key on. A valid model would have been rejected 400 on save the moment the picker set one. Now matches by model_id, consistent with the whole chain. Frontend (Phase 4 UI): - New agents/ feature dir (separate from the Assistants editor): Agent + Binding + BindableItem TS contracts, a thin AgentApiService, and an AgentService signal facade with the accessible$ 404-probe idiom + a per-kind bindable cache. - Agents list page (model + binding-count badges) and an agent-form page: persona/emoji/tags/starters, a required single-select model picker (D3), tool/skill multi-select chips, and a memory-space picker with access (read/read+write β€” write disabled unless editor+ on the space, per D5) and an alwaysLoad MEMORY.md toggle. KB shown read-only. Sharing reuses the assistants share dialog (agentId == assistantId). - Routes agents / agents/new / agents/:id/edit, plus a sidenav "Agents" entry gated on the accessible$ probe. Tests: backend 1552 pass (9 new catalog + 4 new route + 3 updated model-validation); SPA build + tsc clean, ng test 7 AgentService + 11 sidenav specs pass. Co-Authored-By: Claude Opus 4.8 * test(sidenav): stub AgentService probe to fix unhandled HTTP rejection The sidenav constructor now probes agent accessibility (void agentService.loadAgents()), but sidenav.spec.ts didn't provide a mock AgentService, so the real service fired an unstubbed HTTP GET /agents that rejected with status 0 β€” vitest fails the run on unhandled errors even though all assertions passed. Provide a mock AgentService (accessible$ signal + no-op loadAgents) mirroring the schedule/memory stubs, plus parity tests for the probe + showAgents gate. Co-Authored-By: Claude Opus 4.8 * fix(agent-designer): align model write-check with the bindable catalog The catalog lists models via ModelAccessService.filter_accessible_models, but design-time write validation used can_access_model β€” and the two disagree. filter_accessible_models grants access whenever the model id is in the user's AppRole permissions.models; can_access_model only honors that membership when the model record ALSO carries a non-empty allowed_app_roles. So a model granted purely via the user's AppRole (empty allowed_app_roles) was listed by the picker but rejected on save with a 403 β€” and the runtime resolver (membership-based) would actually have allowed it. Validate the model with the same filter_accessible_models predicate the catalog uses, so 'if the palette offers it, the write accepts it' holds by construction. Adds a regression test for the empty-allowed_app_roles grant. Co-Authored-By: Claude Opus 4.8 * feat(sidenav): gate Memory Spaces + Agents to system-admin, add Preview badges Match the Scheduled Runs treatment for the two other preview surfaces: Memory Spaces and Agents now also require the system_admin AppRole (showX() && isAdmin()) in addition to their accessibility probe. Adds a small amber 'Preview' badge to all three nav entries (Agents, Memory Spaces, Scheduled Runs) so their preview status is visible. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): resolve tool bindings at invocation (replace + per-invoker RBAC) An Agent's `tool` bindings were stored by the Designer but inert at run time β€” the free-select tool picker fully drove the toolset regardless of what the Agent bound. This resolves them, mirroring the shipped `modelConfig` override: - Run-time (inference-api): `resolve_agent_invocation` now returns `plan.tools` (`ResolvedTools`). When an Agent binds tools they *replace* the request's `enabled_tools` for the turn; each bound tool is re-checked against the INVOKING user via `AppRoleService.can_access_tool` (the same AppRole gate the harness uses for model, R2) and a missing tool blocks the turn with a message (D5). No tool binding β‡’ `plan.tools is None` β‡’ the request drives the toolset exactly as today. Wired at the existing `extra_tools`/`get_agent` seam via `effective_enabled_tools` (also feeds the spreadsheet/artifact tool gates + attachment guidance/inventory). - Design-time (app-api): `tool` dropped from `_INERT_KINDS`; a bound tool must be in the author's palette (`ToolCatalogService.get_user_accessible_tools`, the same source the picker fetches β€” "if the palette offers it, the write accepts it", cf. the model check). The palette is resolved once per write. `skill` bindings stay inert here (their run-time fold interacts with agent_type/skill resolution β€” a follow-up slice). Tests: 6 resolver cases (override, dedupe, block-on-missing, per-invoker, noneβ†’passthrough) + 5 validation cases (accessible/inaccessible/empty-ref/fetch-once/lazy). Full backend suite green (4621 passed). Co-Authored-By: Claude Opus 4.8 * feat(topnav): surface active assistant in the top nav Move the assistant/agent indicator out of the chat-input footer and into the top nav, beside the session title, so an attached assistant is visible throughout the conversation. - Add a compact 'variant' to app-assistant-indicator: a subtle name-only pill (emoji + name) that opens the same actions menu (New session / Edit / Share) on click. The full card style is preserved behind variant="card". - Add a menuPlacement input so the actions dropdown opens downward in the top nav instead of clipping off-screen. - Thread the assistant/owner/loading state and action outputs from the chat container into app-topnav; render the pill (with a loading shimmer) to the right of the title. - Remove the now-orphaned footer indicator and loading skeletons from the full-page chat container (embedded preview footer left intact). - Assistant card: move conversation starters into a collapsible accordion (expanded by default) to keep the card compact. Co-Authored-By: Claude Opus 4.8 * Release/1.1.0 Feature release adding four capabilities on shared, RBAC-governed primitives β€” Scheduled Runs (unattended/proactive agent runs), Memory Spaces (bindable, shareable markdown memory), the Agent Designer (primitive-binding authoring surface), and Knowledge Base Sync (scheduled re-index) β€” plus a chat/session UX overhaul (per-conversation streaming, interrupted-turn persistence, mid-stream session titles). All new surfaces are flag-gated; no breaking changes and no migration. - Bump VERSION to 1.1.0; sync manifests + lockfiles - CHANGELOG.md and RELEASE_NOTES.md 1.1.0 entries - Requires a platform (CDK) deploy for new resources (memory-spaces table/bucket, GSIs, kb-sync + scheduled-runs Lambda images, EventBridge rules) --------- Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> Signed-off-by: Phil Merrell Signed-off-by: ofilson Signed-off-by: dependabot[bot] Signed-off-by: colinmxs Co-authored-by: Phil Merrell Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Oscar Filson Co-authored-by: Colin Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: colinmxs Co-authored-by: derrickfink Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: ofilson Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Derrick Fink Co-authored-by: Colin Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- .claude/skills/cdk-infrastructure/SKILL.md | 23 + .github/workflows/backend.yml | 144 ++++ .github/workflows/nightly.yml | 12 + .github/workflows/platform.yml | 23 + CHANGELOG.md | 53 ++ CLAUDE.MD | 1 + README.md | 4 +- RELEASE_NOTES.md | 191 +++++ VERSION | 2 +- backend/Dockerfile.kb-sync | 46 ++ backend/Dockerfile.scheduled-runs | 43 ++ backend/pyproject.toml | 2 +- backend/scripts/spike_headless_run.py | 249 ++++++ .../builtin_tools/memory_spaces/__init__.py | 13 + .../builtin_tools/memory_spaces/tools.py | 130 ++++ backend/src/agents/main_agent/base_agent.py | 20 +- .../main_agent/session/hooks/oauth_consent.py | 56 +- .../streaming/stream_coordinator.py | 229 ++++++ .../apis/app_api/agent_designer/__init__.py | 0 .../src/apis/app_api/agent_designer/routes.py | 353 +++++++++ .../agent_designer/services/__init__.py | 0 .../services/bindable_catalog.py | 180 +++++ .../services/binding_validation.py | 171 +++++ backend/src/apis/app_api/assistants/routes.py | 34 +- backend/src/apis/app_api/chat/proxy_routes.py | 32 +- backend/src/apis/app_api/connectors/routes.py | 54 +- .../app_api/documents/ingestion/handler.py | 13 + .../app_api/documents/ingestion/status.py | 34 + backend/src/apis/app_api/documents/models.py | 13 + backend/src/apis/app_api/documents/routes.py | 6 + .../apis/app_api/export_targets/service.py | 17 +- .../file_sources/adapters/google_drive.py | 19 + .../src/apis/app_api/file_sources/service.py | 21 +- backend/src/apis/app_api/kb_sync/__init__.py | 14 + .../src/apis/app_api/kb_sync/dispatcher.py | 252 ++++++ backend/src/apis/app_api/kb_sync/records.py | 122 +++ .../src/apis/app_api/kb_sync/requirements.txt | 10 + backend/src/apis/app_api/kb_sync/worker.py | 429 +++++++++++ backend/src/apis/app_api/main.py | 10 + .../apis/app_api/memory_spaces/__init__.py | 6 + .../src/apis/app_api/memory_spaces/models.py | 201 +++++ .../src/apis/app_api/memory_spaces/routes.py | 472 ++++++++++++ backend/src/apis/app_api/runs/__init__.py | 1 + backend/src/apis/app_api/runs/routes.py | 344 +++++++++ .../src/apis/app_api/schedules/__init__.py | 1 + backend/src/apis/app_api/schedules/models.py | 151 ++++ backend/src/apis/app_api/schedules/routes.py | 297 ++++++++ backend/src/apis/app_api/sessions/routes.py | 90 +++ .../apis/app_api/sync_policies/__init__.py | 1 + .../src/apis/app_api/sync_policies/models.py | 82 ++ .../src/apis/app_api/sync_policies/routes.py | 194 +++++ .../app_api/web_sources/crawl_repository.py | 80 +- .../src/apis/app_api/web_sources/crawler.py | 229 +++++- .../src/apis/app_api/web_sources/routes.py | 8 +- .../chat/agent_binding_resolver.py | 212 +++++ backend/src/apis/inference_api/chat/routes.py | 255 ++++++- .../src/apis/inference_api/chat/service.py | 11 +- backend/src/apis/shared/assistants/compat.py | 72 ++ backend/src/apis/shared/assistants/models.py | 144 +++- .../apis/shared/assistants/serialization.py | 40 + backend/src/apis/shared/assistants/service.py | 72 +- .../shared/embeddings/bedrock_embeddings.py | 36 + backend/src/apis/shared/feature_flags.py | 61 +- backend/src/apis/shared/harness/__init__.py | 52 ++ backend/src/apis/shared/harness/auth.py | 140 ++++ backend/src/apis/shared/harness/governance.py | 186 +++++ backend/src/apis/shared/harness/grants.py | 331 ++++++++ backend/src/apis/shared/harness/models.py | 66 ++ backend/src/apis/shared/harness/runner.py | 262 +++++++ backend/src/apis/shared/harness/sse.py | 227 ++++++ backend/src/apis/shared/memory/__init__.py | 67 ++ backend/src/apis/shared/memory/hydration.py | 147 ++++ backend/src/apis/shared/memory/models.py | 138 ++++ backend/src/apis/shared/memory/repository.py | 302 ++++++++ backend/src/apis/shared/memory/service.py | 721 ++++++++++++++++++ backend/src/apis/shared/memory/store.py | 243 ++++++ backend/src/apis/shared/memory/templates.py | 109 +++ .../apis/shared/oauth/agentcore_identity.py | 73 +- backend/src/apis/shared/oauth/models.py | 13 +- backend/src/apis/shared/rbac/capabilities.py | 44 ++ backend/src/apis/shared/rbac/service.py | 28 + .../apis/shared/scheduled_prompts/__init__.py | 46 ++ .../apis/shared/scheduled_prompts/models.py | 87 +++ .../apis/shared/scheduled_prompts/service.py | 558 ++++++++++++++ backend/src/apis/shared/sessions/metadata.py | 218 +++++- backend/src/apis/shared/sessions/models.py | 58 ++ backend/src/apis/shared/sessions/preview.py | 29 + .../src/apis/shared/sync_policies/__init__.py | 49 ++ .../src/apis/shared/sync_policies/models.py | 59 ++ .../src/apis/shared/sync_policies/service.py | 480 ++++++++++++ backend/src/apis/shared/users/repository.py | 18 +- backend/src/apis/shared/users/sync.py | 14 +- .../scheduled_runs_dispatcher/dispatcher.py | 204 +++++ .../requirements.txt | 13 + .../scheduled_runs_worker/requirements.txt | 12 + .../lambdas/scheduled_runs_worker/worker.py | 175 +++++ .../builtin_tools/memory_spaces/__init__.py | 0 .../memory_spaces/test_memory_tools.py | 100 +++ .../session/test_oauth_consent_hook.py | 78 +- .../test_interrupted_turn_persistence.py | 336 ++++++++ .../agent_designer/test_bindable_catalog.py | 134 ++++ .../agent_designer/test_binding_validation.py | 284 +++++++ .../apis/app_api/test_connectors_routes.py | 55 +- .../apis/app_api/test_memory_spaces_routes.py | 476 ++++++++++++ .../tests/apis/app_api/test_runs_routes.py | 449 +++++++++++ .../web_sources/test_crawl_repository.py | 46 ++ .../apis/app_api/web_sources/test_crawler.py | 192 ++++- .../apis/app_api/web_sources/test_routes.py | 21 + .../test_agent_binding_resolver.py | 237 ++++++ .../inference_api/test_build_memory_tools.py | 28 + .../inference_api/test_interruption_note.py | 34 + .../shared/oauth/test_agentcore_identity.py | 106 +-- .../tests/apis/shared/test_harness_grants.py | 319 ++++++++ .../tests/apis/shared/test_harness_runner.py | 326 ++++++++ backend/tests/apis/shared/test_harness_sse.py | 157 ++++ .../architecture/test_import_boundaries.py | 48 ++ backend/tests/lambdas/conftest.py | 127 +++ .../tests/lambdas/test_kb_sync_dispatcher.py | 325 ++++++++ backend/tests/lambdas/test_kb_sync_worker.py | 568 ++++++++++++++ .../lambdas/test_scheduled_runs_dispatcher.py | 209 +++++ .../lambdas/test_scheduled_runs_worker.py | 237 ++++++ backend/tests/rbac/test_app_role_service.py | 75 ++ backend/tests/routes/test_agents.py | 234 ++++++ backend/tests/routes/test_delete_endpoints.py | 61 ++ backend/tests/routes/test_inference.py | 87 +++ backend/tests/routes/test_schedules_routes.py | 643 ++++++++++++++++ backend/tests/routes/test_sessions.py | 119 +++ backend/tests/routes/test_sync_policies.py | 450 +++++++++++ backend/tests/shared/conftest.py | 6 + backend/tests/shared/test_agent_compat.py | 122 +++ .../shared/test_assistants_agent_fields.py | 65 ++ .../tests/shared/test_assistants_extended.py | 63 ++ backend/tests/shared/test_memory_hydration.py | 117 +++ backend/tests/shared/test_memory_spaces.py | 569 ++++++++++++++ backend/tests/shared/test_memory_store.py | 136 ++++ .../shared/test_preview_prefix_consistency.py | 22 + .../tests/shared/test_scheduled_prompts.py | 515 +++++++++++++ .../tests/shared/test_sessions_metadata.py | 171 +++++ backend/tests/shared/test_sync_policies.py | 401 ++++++++++ backend/tests/shared/test_users.py | 64 ++ .../supply_chain/test_docker_scanning.py | 1 + .../supply_chain/test_dockerfile_pinning.py | 2 + backend/uv.lock | 2 +- docs/kaizen/research/2026-07-03.md | 218 ++++++ docs/kaizen/review-queue.md | 206 +++-- docs/kaizen/reviews/2026-07-03.md | 204 +++++ ...26-07-06-managed-harness-build-vs-adopt.md | 59 ++ ...26-07-06-managed-harness-spike-findings.md | 173 +++++ docs/specs/agent-designer.md | 242 ++++++ docs/specs/agentic-platform-primitives.md | 126 +++ docs/specs/assistant-kb-sync.md | 432 +++++++++++ docs/specs/harness-entrypoint-spike-brief.md | 63 ++ .../harness-entrypoint-spike-findings.md | 131 ++++ docs/specs/interrupted-turn-context.md | 124 +++ docs/specs/scheduled-agent-runs.md | 199 +++++ docs/specs/scheduled-runs-phase-b-brief.md | 52 ++ .../specs/tool-search-token-bloat-strategy.md | 123 +++ docs/specs/user-markdown-memory.md | 621 +++++++++++++++ frontend/ai.client/package-lock.json | 4 +- frontend/ai.client/package.json | 2 +- .../public/logos/google-calendar.svg | 24 + .../ai.client/public/logos/google-docs.svg | 20 + .../ai.client/public/logos/google-drive.svg | 19 + .../ai.client/public/logos/google-gmail.svg | 17 + .../app/agents/agent-form/agent-form.page.css | 1 + .../agents/agent-form/agent-form.page.html | 260 +++++++ .../app/agents/agent-form/agent-form.page.ts | 409 ++++++++++ .../ai.client/src/app/agents/agents.page.css | 1 + .../ai.client/src/app/agents/agents.page.html | 136 ++++ .../ai.client/src/app/agents/agents.page.ts | 130 ++++ .../src/app/agents/models/agent.model.ts | 119 +++ .../app/agents/services/agent-api.service.ts | 82 ++ .../app/agents/services/agent.service.spec.ts | 138 ++++ .../src/app/agents/services/agent.service.ts | 108 +++ frontend/ai.client/src/app/app.html | 5 +- frontend/ai.client/src/app/app.routes.ts | 40 + frontend/ai.client/src/app/app.ts | 7 + .../assistant-form/assistant-form.page.html | 121 ++- .../assistant-form/assistant-form.page.ts | 364 ++++++++- .../components/assistant-card.component.ts | 101 ++- .../sync-policy-control.component.spec.ts | 250 ++++++ .../sync-policy-control.component.ts | 290 +++++++ .../app/assistants/models/document.model.ts | 13 + .../assistants/models/sync-policy.model.ts | 56 ++ .../services/sync-policy.service.spec.ts | 191 +++++ .../services/sync-policy.service.ts | 156 ++++ .../assistants/services/web-source.service.ts | 19 + .../background-task-toasts.component.spec.ts | 76 ++ .../background-task-toasts.component.ts | 184 +++++ .../components/session-list/session-list.css | 53 ++ .../components/session-list/session-list.html | 105 ++- .../session-list/session-list.spec.ts | 127 ++- .../components/session-list/session-list.ts | 107 ++- .../src/app/components/sidenav/sidenav.html | 51 ++ .../app/components/sidenav/sidenav.spec.ts | 113 ++- .../src/app/components/sidenav/sidenav.ts | 54 +- .../src/app/components/topnav/topnav.css | 45 ++ .../src/app/components/topnav/topnav.html | 128 +++- .../src/app/components/topnav/topnav.spec.ts | 73 ++ .../src/app/components/topnav/topnav.ts | 286 ++++++- .../create-space-dialog.component.ts | 175 +++++ .../components/entry-dialog.component.ts | 235 ++++++ .../share-space-dialog.component.ts | 328 ++++++++ .../memory-space-detail.page.html | 183 +++++ .../memory-spaces/memory-space-detail.page.ts | 238 ++++++ .../app/memory-spaces/memory-spaces.page.html | 138 ++++ .../app/memory-spaces/memory-spaces.page.ts | 160 ++++ .../models/memory-space.model.ts | 95 +++ .../services/memory-space-api.service.ts | 132 ++++ .../services/memory-space.service.spec.ts | 144 ++++ .../services/memory-space.service.ts | 141 ++++ .../src/app/schedules/models/grant.model.ts | 20 + .../app/schedules/models/schedule.model.ts | 119 +++ .../schedule-form/schedule-form.page.html | 293 +++++++ .../schedule-form/schedule-form.page.spec.ts | 290 +++++++ .../schedule-form/schedule-form.page.ts | 376 +++++++++ .../src/app/schedules/schedules.page.html | 178 +++++ .../src/app/schedules/schedules.page.spec.ts | 229 ++++++ .../src/app/schedules/schedules.page.ts | 265 +++++++ .../services/grant-api.service.spec.ts | 72 ++ .../schedules/services/grant-api.service.ts | 32 + .../schedules/services/grant.service.spec.ts | 69 ++ .../app/schedules/services/grant.service.ts | 71 ++ .../services/run-api.service.spec.ts | 62 ++ .../app/schedules/services/run-api.service.ts | 25 + .../services/run-now.service.spec.ts | 121 +++ .../app/schedules/services/run-now.service.ts | 83 ++ .../services/schedule-api.service.spec.ts | 145 ++++ .../services/schedule-api.service.ts | 51 ++ .../services/schedule.service.spec.ts | 154 ++++ .../schedules/services/schedule.service.ts | 126 +++ .../background-task.service.spec.ts | 58 ++ .../background-task.service.ts | 88 +++ .../assistant-indicator.component.ts | 170 ++++- .../chat-container.component.html | 75 +- .../chat-container.component.ts | 29 + .../export-dialog.component.spec.ts | 4 +- .../message-actions.component.spec.ts | 62 ++ .../components/message-actions.component.ts | 27 +- .../streaming-text.component.spec.ts | 71 ++ .../components/streaming-text.component.ts | 19 + .../renderers/mcp-app-frame.component.ts | 14 +- .../message-list/message-list.component.html | 1 + .../message-list/message-list.component.ts | 46 +- .../services/chat/chat-http.service.spec.ts | 97 ++- .../services/chat/chat-http.service.ts | 408 ++++++---- .../chat/chat-request.service.spec.ts | 92 ++- .../services/chat/chat-request.service.ts | 117 +-- .../services/chat/chat-state.service.spec.ts | 210 +++-- .../services/chat/chat-state.service.ts | 276 +++++-- .../chat/stream-parser.service.spec.ts | 332 ++++++-- .../services/chat/stream-parser.service.ts | 711 ++++++++++------- .../session/services/export/export.model.ts | 6 +- .../services/models/session-metadata.model.ts | 17 + .../session/message-map.service.spec.ts | 103 ++- .../services/session/message-map.service.ts | 200 +++-- .../session/scroll-position.service.spec.ts | 45 ++ .../session/scroll-position.service.ts | 33 + .../services/session/session.service.spec.ts | 56 ++ .../services/session/session.service.ts | 111 +++ .../ai.client/src/app/session/session.page.ts | 156 +++- .../app/shared/utils/stream-parser/index.ts | 2 + .../stream-parser/stream-parser-core.spec.ts | 47 ++ .../utils/stream-parser/stream-parser-core.ts | 33 + .../stream-parser/stream-parser-types.ts | 22 +- .../bootstrap-assets/kb-sync/Dockerfile | 26 + .../bootstrap-assets/kb-sync/dispatcher.py | 22 + .../bootstrap-assets/kb-sync/worker.py | 21 + .../scheduled-runs/Dockerfile | 27 + .../scheduled-runs/dispatcher.py | 22 + .../bootstrap-assets/scheduled-runs/worker.py | 22 + infrastructure/lib/config.ts | 99 +++ .../constructs/app-api/app-api-environment.ts | 23 + .../constructs/app-api/app-api-iam-grants.ts | 40 + .../app-api/app-api-service-construct.ts | 4 + .../constructs/data/auth-tables-construct.ts | 12 + .../data/cost-tracking-tables-construct.ts | 17 +- .../gateway/agentcore-gateway-construct.ts | 24 +- .../inference-agentcore-construct.ts | 12 + .../inference-api/inference-api-iam-roles.ts | 19 + .../constructs/kb-sync/kb-sync-construct.ts | 256 +++++++ .../memory/memory-spaces-construct.ts | 92 +++ .../lib/constructs/platform-compute-refs.ts | 4 + .../lib/constructs/rag/rag-data-construct.ts | 13 +- .../scheduled-runs-construct.ts | 316 ++++++++ infrastructure/lib/platform-stack.ts | 53 ++ infrastructure/package-lock.json | 4 +- infrastructure/package.json | 2 +- .../test/app-api-environment.test.ts | 54 ++ infrastructure/test/config.test.ts | 115 +++ infrastructure/test/helpers/mock-config.ts | 12 + infrastructure/test/kb-sync.test.ts | 156 ++++ infrastructure/test/platform-stack.test.ts | 18 +- infrastructure/test/tables-detailed.test.ts | 37 +- scripts/build/build-one.sh | 48 +- scripts/build/deploy-image-lambda-one.sh | 47 +- 296 files changed, 36138 insertions(+), 1496 deletions(-) create mode 100644 backend/Dockerfile.kb-sync create mode 100644 backend/Dockerfile.scheduled-runs create mode 100644 backend/scripts/spike_headless_run.py create mode 100644 backend/src/agents/builtin_tools/memory_spaces/__init__.py create mode 100644 backend/src/agents/builtin_tools/memory_spaces/tools.py create mode 100644 backend/src/apis/app_api/agent_designer/__init__.py create mode 100644 backend/src/apis/app_api/agent_designer/routes.py create mode 100644 backend/src/apis/app_api/agent_designer/services/__init__.py create mode 100644 backend/src/apis/app_api/agent_designer/services/bindable_catalog.py create mode 100644 backend/src/apis/app_api/agent_designer/services/binding_validation.py create mode 100644 backend/src/apis/app_api/kb_sync/__init__.py create mode 100644 backend/src/apis/app_api/kb_sync/dispatcher.py create mode 100644 backend/src/apis/app_api/kb_sync/records.py create mode 100644 backend/src/apis/app_api/kb_sync/requirements.txt create mode 100644 backend/src/apis/app_api/kb_sync/worker.py create mode 100644 backend/src/apis/app_api/memory_spaces/__init__.py create mode 100644 backend/src/apis/app_api/memory_spaces/models.py create mode 100644 backend/src/apis/app_api/memory_spaces/routes.py create mode 100644 backend/src/apis/app_api/runs/__init__.py create mode 100644 backend/src/apis/app_api/runs/routes.py create mode 100644 backend/src/apis/app_api/schedules/__init__.py create mode 100644 backend/src/apis/app_api/schedules/models.py create mode 100644 backend/src/apis/app_api/schedules/routes.py create mode 100644 backend/src/apis/app_api/sync_policies/__init__.py create mode 100644 backend/src/apis/app_api/sync_policies/models.py create mode 100644 backend/src/apis/app_api/sync_policies/routes.py create mode 100644 backend/src/apis/inference_api/chat/agent_binding_resolver.py create mode 100644 backend/src/apis/shared/assistants/compat.py create mode 100644 backend/src/apis/shared/assistants/serialization.py create mode 100644 backend/src/apis/shared/harness/__init__.py create mode 100644 backend/src/apis/shared/harness/auth.py create mode 100644 backend/src/apis/shared/harness/governance.py create mode 100644 backend/src/apis/shared/harness/grants.py create mode 100644 backend/src/apis/shared/harness/models.py create mode 100644 backend/src/apis/shared/harness/runner.py create mode 100644 backend/src/apis/shared/harness/sse.py create mode 100644 backend/src/apis/shared/memory/__init__.py create mode 100644 backend/src/apis/shared/memory/hydration.py create mode 100644 backend/src/apis/shared/memory/models.py create mode 100644 backend/src/apis/shared/memory/repository.py create mode 100644 backend/src/apis/shared/memory/service.py create mode 100644 backend/src/apis/shared/memory/store.py create mode 100644 backend/src/apis/shared/memory/templates.py create mode 100644 backend/src/apis/shared/rbac/capabilities.py create mode 100644 backend/src/apis/shared/scheduled_prompts/__init__.py create mode 100644 backend/src/apis/shared/scheduled_prompts/models.py create mode 100644 backend/src/apis/shared/scheduled_prompts/service.py create mode 100644 backend/src/apis/shared/sessions/preview.py create mode 100644 backend/src/apis/shared/sync_policies/__init__.py create mode 100644 backend/src/apis/shared/sync_policies/models.py create mode 100644 backend/src/apis/shared/sync_policies/service.py create mode 100644 backend/src/lambdas/scheduled_runs_dispatcher/dispatcher.py create mode 100644 backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt create mode 100644 backend/src/lambdas/scheduled_runs_worker/requirements.txt create mode 100644 backend/src/lambdas/scheduled_runs_worker/worker.py create mode 100644 backend/tests/agents/builtin_tools/memory_spaces/__init__.py create mode 100644 backend/tests/agents/builtin_tools/memory_spaces/test_memory_tools.py create mode 100644 backend/tests/agents/main_agent/streaming/test_interrupted_turn_persistence.py create mode 100644 backend/tests/apis/app_api/agent_designer/test_bindable_catalog.py create mode 100644 backend/tests/apis/app_api/agent_designer/test_binding_validation.py create mode 100644 backend/tests/apis/app_api/test_memory_spaces_routes.py create mode 100644 backend/tests/apis/app_api/test_runs_routes.py create mode 100644 backend/tests/apis/inference_api/test_agent_binding_resolver.py create mode 100644 backend/tests/apis/inference_api/test_build_memory_tools.py create mode 100644 backend/tests/apis/inference_api/test_interruption_note.py create mode 100644 backend/tests/apis/shared/test_harness_grants.py create mode 100644 backend/tests/apis/shared/test_harness_runner.py create mode 100644 backend/tests/apis/shared/test_harness_sse.py create mode 100644 backend/tests/lambdas/conftest.py create mode 100644 backend/tests/lambdas/test_kb_sync_dispatcher.py create mode 100644 backend/tests/lambdas/test_kb_sync_worker.py create mode 100644 backend/tests/lambdas/test_scheduled_runs_dispatcher.py create mode 100644 backend/tests/lambdas/test_scheduled_runs_worker.py create mode 100644 backend/tests/routes/test_agents.py create mode 100644 backend/tests/routes/test_schedules_routes.py create mode 100644 backend/tests/routes/test_sync_policies.py create mode 100644 backend/tests/shared/test_agent_compat.py create mode 100644 backend/tests/shared/test_assistants_agent_fields.py create mode 100644 backend/tests/shared/test_memory_hydration.py create mode 100644 backend/tests/shared/test_memory_spaces.py create mode 100644 backend/tests/shared/test_memory_store.py create mode 100644 backend/tests/shared/test_preview_prefix_consistency.py create mode 100644 backend/tests/shared/test_scheduled_prompts.py create mode 100644 backend/tests/shared/test_sync_policies.py create mode 100644 docs/kaizen/research/2026-07-03.md create mode 100644 docs/kaizen/reviews/2026-07-03.md create mode 100644 docs/kaizen/scoping/2026-07-06-managed-harness-build-vs-adopt.md create mode 100644 docs/kaizen/scoping/2026-07-06-managed-harness-spike-findings.md create mode 100644 docs/specs/agent-designer.md create mode 100644 docs/specs/agentic-platform-primitives.md create mode 100644 docs/specs/assistant-kb-sync.md create mode 100644 docs/specs/harness-entrypoint-spike-brief.md create mode 100644 docs/specs/harness-entrypoint-spike-findings.md create mode 100644 docs/specs/interrupted-turn-context.md create mode 100644 docs/specs/scheduled-agent-runs.md create mode 100644 docs/specs/scheduled-runs-phase-b-brief.md create mode 100644 docs/specs/tool-search-token-bloat-strategy.md create mode 100644 docs/specs/user-markdown-memory.md create mode 100644 frontend/ai.client/public/logos/google-calendar.svg create mode 100644 frontend/ai.client/public/logos/google-docs.svg create mode 100644 frontend/ai.client/public/logos/google-drive.svg create mode 100644 frontend/ai.client/public/logos/google-gmail.svg create mode 100644 frontend/ai.client/src/app/agents/agent-form/agent-form.page.css create mode 100644 frontend/ai.client/src/app/agents/agent-form/agent-form.page.html create mode 100644 frontend/ai.client/src/app/agents/agent-form/agent-form.page.ts create mode 100644 frontend/ai.client/src/app/agents/agents.page.css create mode 100644 frontend/ai.client/src/app/agents/agents.page.html create mode 100644 frontend/ai.client/src/app/agents/agents.page.ts create mode 100644 frontend/ai.client/src/app/agents/models/agent.model.ts create mode 100644 frontend/ai.client/src/app/agents/services/agent-api.service.ts create mode 100644 frontend/ai.client/src/app/agents/services/agent.service.spec.ts create mode 100644 frontend/ai.client/src/app/agents/services/agent.service.ts create mode 100644 frontend/ai.client/src/app/assistants/components/sync-policy-control.component.spec.ts create mode 100644 frontend/ai.client/src/app/assistants/components/sync-policy-control.component.ts create mode 100644 frontend/ai.client/src/app/assistants/models/sync-policy.model.ts create mode 100644 frontend/ai.client/src/app/assistants/services/sync-policy.service.spec.ts create mode 100644 frontend/ai.client/src/app/assistants/services/sync-policy.service.ts create mode 100644 frontend/ai.client/src/app/components/background-task-toasts/background-task-toasts.component.spec.ts create mode 100644 frontend/ai.client/src/app/components/background-task-toasts/background-task-toasts.component.ts create mode 100644 frontend/ai.client/src/app/memory-spaces/components/create-space-dialog.component.ts create mode 100644 frontend/ai.client/src/app/memory-spaces/components/entry-dialog.component.ts create mode 100644 frontend/ai.client/src/app/memory-spaces/components/share-space-dialog.component.ts create mode 100644 frontend/ai.client/src/app/memory-spaces/memory-space-detail.page.html create mode 100644 frontend/ai.client/src/app/memory-spaces/memory-space-detail.page.ts create mode 100644 frontend/ai.client/src/app/memory-spaces/memory-spaces.page.html create mode 100644 frontend/ai.client/src/app/memory-spaces/memory-spaces.page.ts create mode 100644 frontend/ai.client/src/app/memory-spaces/models/memory-space.model.ts create mode 100644 frontend/ai.client/src/app/memory-spaces/services/memory-space-api.service.ts create mode 100644 frontend/ai.client/src/app/memory-spaces/services/memory-space.service.spec.ts create mode 100644 frontend/ai.client/src/app/memory-spaces/services/memory-space.service.ts create mode 100644 frontend/ai.client/src/app/schedules/models/grant.model.ts create mode 100644 frontend/ai.client/src/app/schedules/models/schedule.model.ts create mode 100644 frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.html create mode 100644 frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.spec.ts create mode 100644 frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.ts create mode 100644 frontend/ai.client/src/app/schedules/schedules.page.html create mode 100644 frontend/ai.client/src/app/schedules/schedules.page.spec.ts create mode 100644 frontend/ai.client/src/app/schedules/schedules.page.ts create mode 100644 frontend/ai.client/src/app/schedules/services/grant-api.service.spec.ts create mode 100644 frontend/ai.client/src/app/schedules/services/grant-api.service.ts create mode 100644 frontend/ai.client/src/app/schedules/services/grant.service.spec.ts create mode 100644 frontend/ai.client/src/app/schedules/services/grant.service.ts create mode 100644 frontend/ai.client/src/app/schedules/services/run-api.service.spec.ts create mode 100644 frontend/ai.client/src/app/schedules/services/run-api.service.ts create mode 100644 frontend/ai.client/src/app/schedules/services/run-now.service.spec.ts create mode 100644 frontend/ai.client/src/app/schedules/services/run-now.service.ts create mode 100644 frontend/ai.client/src/app/schedules/services/schedule-api.service.spec.ts create mode 100644 frontend/ai.client/src/app/schedules/services/schedule-api.service.ts create mode 100644 frontend/ai.client/src/app/schedules/services/schedule.service.spec.ts create mode 100644 frontend/ai.client/src/app/schedules/services/schedule.service.ts create mode 100644 frontend/ai.client/src/app/services/background-tasks/background-task.service.spec.ts create mode 100644 frontend/ai.client/src/app/services/background-tasks/background-task.service.ts create mode 100644 frontend/ai.client/src/app/session/components/message-list/components/streaming-text.component.spec.ts create mode 100644 frontend/ai.client/src/app/session/services/session/scroll-position.service.spec.ts create mode 100644 frontend/ai.client/src/app/session/services/session/scroll-position.service.ts create mode 100644 infrastructure/bootstrap-assets/kb-sync/Dockerfile create mode 100644 infrastructure/bootstrap-assets/kb-sync/dispatcher.py create mode 100644 infrastructure/bootstrap-assets/kb-sync/worker.py create mode 100644 infrastructure/bootstrap-assets/scheduled-runs/Dockerfile create mode 100644 infrastructure/bootstrap-assets/scheduled-runs/dispatcher.py create mode 100644 infrastructure/bootstrap-assets/scheduled-runs/worker.py create mode 100644 infrastructure/lib/constructs/kb-sync/kb-sync-construct.ts create mode 100644 infrastructure/lib/constructs/memory/memory-spaces-construct.ts create mode 100644 infrastructure/lib/constructs/scheduled-runs/scheduled-runs-construct.ts create mode 100644 infrastructure/test/app-api-environment.test.ts create mode 100644 infrastructure/test/kb-sync.test.ts diff --git a/.claude/skills/cdk-infrastructure/SKILL.md b/.claude/skills/cdk-infrastructure/SKILL.md index 7e794b58..000c4c32 100644 --- a/.claude/skills/cdk-infrastructure/SKILL.md +++ b/.claude/skills/cdk-infrastructure/SKILL.md @@ -82,6 +82,29 @@ new AlbConstruct(this, 'Alb', { config, vpc: network.vpc }); SSM parameters are published **only for runtime consumption** by ECS tasks and Lambdas β€” never for CDK-to-CDK references within the same stack. +### Wire a resource's name to every compute that reads it + +When a construct exposes a table/bucket that backend code reads via +`os.environ.get("X_NAME", "default")`, you must set `X_NAME` in the container +environment of **every** compute that runs that code β€” thread the typed ref +through that compute's env builder (e.g. `buildAppApiEnvironment` for app-api, +the inference-agentcore construct's `environment` for inference-api). Wiring one +does **not** wire the other. + +**Why this bites (silent 502):** the backend's default fallback hides the +omission. If the env var is missing, the code queries the *default* name +(e.g. `"memory-spaces"` instead of `{prefix}-memory-spaces`), the resource +isn't found, boto3 raises `ResourceNotFoundException`, and the centralized +handler (`apis/shared/security/error_handler.py`) maps it to a generic +**502 `{"detail":"Upstream service error."}`**. Nothing in `cdk synth` or CI +catches it β€” the stack is valid, the IAM grant may even exist; only a runtime +read fails. (Real instance: PR #588 β€” memory-spaces names were wired to +inference-api but not app-api, which owns the CRUD routes.) + +**Guard it:** add an env-map unit test asserting the key is emitted (see +`test/app-api-environment.test.ts`). **Mind the boundary:** app-api owns +user-facing CRUD; granting IAM or wiring inference-api does not cover it. + ## DynamoDB Tables - Always use PK + SK for flexibility diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index c00f0eff..e934884b 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -165,6 +165,150 @@ jobs: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + build-kb-sync: + name: Build kb-sync image + needs: test-backend + # Native ARM64 runner β€” both kb-sync Lambdas are arm64 (see the + # kb-sync CDK construct), matching the rag-ingestion pattern. + runs-on: ubuntu-24.04-arm + environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'development' }} + + permissions: + id-token: write + contents: read + + env: + CDK_AWS_REGION: ${{ vars.AWS_REGION }} + CDK_AWS_ACCOUNT: ${{ vars.CDK_AWS_ACCOUNT }} + CDK_PROJECT_PREFIX: ${{ vars.CDK_PROJECT_PREFIX }} + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ACCOUNT_ID: ${{ vars.CDK_AWS_ACCOUNT }} + AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + outputs: + image_tag: ${{ steps.build.outputs.image_tag }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/build-and-push-image + id: build + with: + image-name: kb-sync + aws-region: ${{ vars.AWS_REGION || 'us-west-2' }} + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + deploy-kb-sync-code: + name: Deploy kb-sync Lambda images + # One image, two functions: dispatcher + worker share the kb-sync + # image and differ only in ImageConfig.Command (CDK-owned), so a + # single job points both at the freshly-built tag. + needs: [build-kb-sync, test-backend] + runs-on: ubuntu-24.04 + environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'development' }} + + permissions: + id-token: write + contents: read + + env: + CDK_AWS_REGION: ${{ vars.AWS_REGION }} + CDK_AWS_ACCOUNT: ${{ vars.CDK_AWS_ACCOUNT }} + CDK_PROJECT_PREFIX: ${{ vars.CDK_PROJECT_PREFIX }} + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ACCOUNT_ID: ${{ vars.CDK_AWS_ACCOUNT }} + AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/configure-aws-credentials + with: + aws-region: ${{ vars.AWS_REGION || 'us-west-2' }} + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Deploy kb-sync dispatcher image + run: bash scripts/build/deploy-image-lambda-one.sh kb-sync-dispatcher + - name: Deploy kb-sync worker image + run: bash scripts/build/deploy-image-lambda-one.sh kb-sync-worker + + build-scheduled-runs: + name: Build scheduled-runs image + needs: test-backend + # Native ARM64 runner β€” both scheduled-runs Lambdas are arm64 (see the + # scheduled-runs CDK construct), matching the kb-sync pattern. + runs-on: ubuntu-24.04-arm + environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'development' }} + + permissions: + id-token: write + contents: read + + env: + CDK_AWS_REGION: ${{ vars.AWS_REGION }} + CDK_AWS_ACCOUNT: ${{ vars.CDK_AWS_ACCOUNT }} + CDK_PROJECT_PREFIX: ${{ vars.CDK_PROJECT_PREFIX }} + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ACCOUNT_ID: ${{ vars.CDK_AWS_ACCOUNT }} + AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + outputs: + image_tag: ${{ steps.build.outputs.image_tag }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/build-and-push-image + id: build + with: + image-name: scheduled-runs + aws-region: ${{ vars.AWS_REGION || 'us-west-2' }} + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + deploy-scheduled-runs-code: + name: Deploy scheduled-runs Lambda images + # One image, two functions: dispatcher + worker share the + # scheduled-runs image and differ only in ImageConfig.Command + # (CDK-owned), so a single job points both at the freshly-built tag. + needs: [build-scheduled-runs, test-backend] + runs-on: ubuntu-24.04 + environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'development' }} + + permissions: + id-token: write + contents: read + + env: + CDK_AWS_REGION: ${{ vars.AWS_REGION }} + CDK_AWS_ACCOUNT: ${{ vars.CDK_AWS_ACCOUNT }} + CDK_PROJECT_PREFIX: ${{ vars.CDK_PROJECT_PREFIX }} + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ACCOUNT_ID: ${{ vars.CDK_AWS_ACCOUNT }} + AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/configure-aws-credentials + with: + aws-region: ${{ vars.AWS_REGION || 'us-west-2' }} + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Deploy scheduled-runs dispatcher image + run: bash scripts/build/deploy-image-lambda-one.sh scheduled-runs-dispatcher + - name: Deploy scheduled-runs worker image + run: bash scripts/build/deploy-image-lambda-one.sh scheduled-runs-worker + deploy-artifact-render-code: name: Deploy artifact-render Lambda code # Lambda zip code deploy. Mirrors the build-* jobs but for a diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 70067e2f..a7f41ce2 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -469,6 +469,9 @@ jobs: - name: Build rag-ingestion image run: docker build -f backend/Dockerfile.rag-ingestion -t rag-ingestion:scan . + - name: Build kb-sync image + run: docker build -f backend/Dockerfile.kb-sync -t kb-sync:scan . + - name: Trivy scan app-api uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: @@ -487,6 +490,15 @@ jobs: severity: 'CRITICAL,HIGH' output: trivy-inference-api.txt + - name: Trivy scan kb-sync + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: 'kb-sync:scan' + format: 'table' + exit-code: '0' + severity: 'CRITICAL,HIGH' + output: trivy-kb-sync.txt + - name: Trivy scan rag-ingestion uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index bd42691d..1f7ac6ca 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -89,6 +89,29 @@ jobs: # nonexistent host and MCP Apps fail to load. Cert MUST be in us-east-1. CDK_MCP_SANDBOX_CERTIFICATE_ARN: ${{ vars.CDK_MCP_SANDBOX_CERTIFICATE_ARN }} CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS: ${{ vars.CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS }} + # KB sync scheduled re-index. Default ON with a kill switch: CDK enables + # the EventBridge rate(15m) rule + KB_SYNC_ENABLED on both kb-sync + # Lambdas UNLESS this is set to "false". Unset resolves to empty string, + # which config.ts treats as the default (on). Set the + # `CDK_KB_SYNC_ENABLED` variable to "false" in an environment only to + # dark-stop the feature there. + CDK_KB_SYNC_ENABLED: ${{ vars.CDK_KB_SYNC_ENABLED }} + # Scheduled runs (headless "Run now" + grant routes, and the Phase-B + # scheduler when it lands). Default ON with a kill switch: unset + # resolves to empty string, which config.ts treats as the default + # (on). Set the `CDK_SCHEDULED_RUNS_ENABLED` variable to "false" in + # an environment only to dark-stop the feature there. + CDK_SCHEDULED_RUNS_ENABLED: ${{ vars.CDK_SCHEDULED_RUNS_ENABLED }} + # Memory Spaces (user-owned markdown "second brain" + agent binding). + # Default ON with a kill switch: unset resolves to empty string, which + # config.ts treats as the default (on). Set the `CDK_MEMORY_SPACES_ENABLED` + # variable to "false" in an environment only to dark-stop the feature there. + CDK_MEMORY_SPACES_ENABLED: ${{ vars.CDK_MEMORY_SPACES_ENABLED }} + # Agent Designer /agents surface. Default OFF (opt-in) until the Phase-4 + # Designer UI ships β€” a headless API helps no forker. Set the + # `CDK_AGENTS_API_ENABLED` variable to "true" in an environment (e.g. dev) + # to enable it there for dogfooding. + CDK_AGENTS_API_ENABLED: ${{ vars.CDK_AGENTS_API_ENABLED }} # Secrets AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cedb11..4dabc812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,59 @@ All notable changes to this project are documented in this file. Format follows For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +## [1.1.0] - 2026-07-08 + +Feature release adding four new capabilities β€” **Scheduled Runs** (unattended/proactive agent runs), **Memory Spaces** (bindable, shareable markdown memory), the **Agent Designer** (primitive-binding authoring surface), and **Knowledge Base Sync** (scheduled re-index of assistant sources) β€” plus a chat/session UX overhaul (per-conversation streaming, interrupted-turn persistence, mid-stream session titles). Every new surface is flag-gated; there are no breaking changes and no migration. Scheduled Runs, Memory Spaces, and KB Sync default **on**; the Agent Designer defaults **off**. The three preview surfaces (Agents, Memory Spaces, Scheduled Runs) are nav-gated to system-admins and carry a "Preview" badge. + +### πŸš€ Added + +- **Scheduled Runs** β€” run an agent prompt unattended on a cadence (daily/weekday/weekly/interval) or on demand, delivered as a session with an unread indicator. New `/schedules` CRUD, `/runs/now`, and `/runs/grant`; gated by `SCHEDULED_RUNS_ENABLED` (default on) plus a new `scheduled-runs` RBAC capability (#558, #560, #561, #562, #563, #564, #565, #578) +- `run_agent_headless` entrypoint: per-owner Cognito bearer mint, server-side SSE drain, runtime session materialization, and an audit-only fail-closed governance floor (#560, #561) +- Revocable headless-grant record (create-on-enable from an attended session, 30-day login TTL) backing unattended run-as-user auth (#561) +- Interval cadence (every N minutes/hours) and "Run now" with app-wide background-task toasts (#578) +- Unread dot on scheduled-run deliveries and a Mark-as-read / Mark-as-unread toggle in the session menus (`POST /sessions/{id}/read` and `/unread`) (#572, #577) +- **Memory Spaces** β€” named, templated, shareable markdown "second brain" spaces with owner/editor/viewer sharing, loss-free `.zip` export, and a consolidation health pass. New `/memory/spaces` CRUD, `/shares`, `/export`, and `/consolidate`; gated by `MEMORY_SPACES_ENABLED` (default on) (#579, #582, #584, #585, #586, #589, #597) +- Space templates (Blank, Chief-of-Staff, Research-Notebook) and a SPA Memory panel β€” list/detail/create/share/export/delete (#582, #587) +- **Agent Designer** β€” an authoring surface that composes an Agent from RBAC-governed primitives (a governed single-select model + uniform `bindings[]` for tool / skill / knowledge_base / memory_space), evolving the assistant store in place. New `/agents/*` surface and `/agents/bindable` catalog; gated by `AGENTS_API_ENABLED` (default off). Legacy Assistants read as Agents via a compat mapping and `/assistants/*` is unchanged (#590, #591, #592, #598, #599) +- Run-time Agent binding resolution, re-checked against the *invoking* user's RBAC with block-on-missing: `modelConfig` override, bound Memory Space prompt injection, and `memory_*` tools (#594, #596, #601) +- **Knowledge Base Sync** β€” keep assistant KB sources (Google Drive files + web crawls) automatically re-indexed on a schedule (Daily/Weekly/Monthly), with pause/resume, run-now, and reconnect-on-reauth, surfaced as per-source controls on the assistant knowledge page. Gated by `KB_SYNC_ENABLED` (default on) (#542, #543, #544, #545, #546, #547) +- Per-conversation chat streaming: streaming state is now keyed per session, so concurrent conversations no longer cross streams; adds per-conversation scroll restore and a sidebar in-progress dot (#535) +- Interrupted-turn persistence: a stopped or dropped response now persists its partial text, context, and cost/usage metadata, with a reload chip to Continue (`POST /sessions/{id}/interrupt`) (#541, #548) +- Mid-stream session titles via a new `session_title` SSE event, with a shimmer skeleton while the title generates (#540) +- Session options menu (Rename / Share / Save / Delete) on the top-nav title, and the active assistant surfaced as a pill in the top nav (#538) + +### ✨ Improved + +- Conversation export now defaults to messages only β€” tool calls, images, and citations are opt-in (#537) +- OAuth call sites forward the connector's admin-configured `customParameters` verbatim, dropping hardcoded vendor baselines so the AgentCore vault key stays consistent across consent and retrieval (#550) +- KB-sync auto-sync control clarified: self-describing verbs, always-visible "Last synced", a "Saving…" indicator, unified skeleton loading, and web-source/document parity (#552, #555) +- Assistant editor groups the Knowledge Base section into a contrasted inset panel; added Google connector (Drive/Docs/Gmail/Calendar) logos (#557) + +### πŸ› Fixed + +- OAuth consent state is cleared on session switch so an "Authorization needed" banner can't leak onto another conversation (#539) +- Tool card is kept after an OAuth / tool-approval resume instead of being dropped when the resumed stream omits the original `tool_use` block (#532) +- User-sync timestamps normalized to strict ISO 8601 (single trailing `Z`), so admin "Last login" / "Created" dates no longer render as "Never" in Safari (#556) +- Schedule edits can now clear a schedule's assistant or tool restriction via explicit clear flags (a bare `null` was read as "leave unchanged") (#569) +- Client-supplied `enabled_tools` is intersected with the caller's RBAC on the headless paths (schedule create/update, Run now), so a scheduled run can't persist a tool outside the owner's role (#568) +- Scheduled-runs worker lean image made importable (missing `cryptography`/`cachetools`, top-level `agents`/`strands` imports on the delivery path) (#566, #567) + +### πŸ—οΈ Infrastructure + +- New dedicated `memory-spaces` DynamoDB table (OwnerIndex + MemberIndex GSIs) and content-addressed S3 bucket via `MemorySpacesConstruct` (#582) +- New sparse GSIs: `DueScheduleIndex` and `HeadlessGrantUserIndex` on the sessions-metadata table (scheduled runs), and `DueSyncIndex` on the assistants table (KB sync) (#542, #561, #563) +- Two new lean Lambda images with EventBridge sweeps, both following the platform-as-bootstrap pattern (byte-stable stub + out-of-band image deploy): `Dockerfile.scheduled-runs` (dispatcher + worker, `rate(5m)`) and `Dockerfile.kb-sync` (dispatcher + worker, `rate(15m)`) (#543, #565) +- New feature flags forwarded through `platform.yml`, all empty-string-safe kill switches: `SCHEDULED_RUNS_ENABLED`, `MEMORY_SPACES_ENABLED`, `KB_SYNC_ENABLED` (default on) and `AGENTS_API_ENABLED` (default off) (#553, #554, #561, #593, #597) +- IAM: added `bedrock:ListFoundationModels` / `bedrock:GetFoundationModel` to the app-api task role (admin model list), and granted the kb-sync worker read on the vault's backing OAuth secrets (#549, #571) + +### πŸ”§ CI/CD + +- `backend.yml` gains build + API-driven code-deploy jobs for the kb-sync and scheduled-runs Lambda images; nightly image-scan and supply-chain pinning lists include both new Dockerfiles (#543, #565) + +### πŸ“¦ Dependencies + +- New pins in the lean Lambda images only (no core backend or frontend dependency changes): `beautifulsoup4` 4.13.5, `trafilatura` 2.0.0, `lxml` 6.1.1 (kb-sync web re-crawl); `cryptography` 48.0.1, `cachetools` 6.2.4 (scheduled-runs worker); `httpx` 0.28.1, `bedrock-agentcore` 1.9.1, `boto3` 1.43.9, `pydantic` 2.12.5 (shared image runtime) + ## [1.0.4] - 2026-07-01 IAM hotfix restoring AgentCore Memory. Both the App API task role and the AgentCore Runtime execution role were missing `bedrock-agentcore:GetMemory`, which `get_memory_strategies()` needs to resolve strategy IDs β€” breaking the memory dashboard (empty results) and long-term recall (retrieval silently disabled). Ships via the platform (CDK) pipeline; no migration. diff --git a/CLAUDE.MD b/CLAUDE.MD index 4e18d0c2..be9f0ad8 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -52,6 +52,7 @@ npx cdk deploy {prefix}-PlatformStack | `tool_use` / `tool_result` | Tool invocation and result | | `ui_resource` | MCP App UI for a tool (SEP-1865) β€” payload `{type, toolUseId, resourceUri, html, mimeType, csp, permissions, sandboxOrigin, serverName, icon, toolName}`. Emitted at the tool's `content_block_start` (early frame mount, so the App's bridge is live *while* the model streams the tool's arguments β€” see `ui_tool_input_partial`); falls back to right after the correlated `tool_result` if the name wasn't known at block start. At `content_block_start` a **header-only shell** (`html: ""`, full `serverName`/`icon`/`resourceUri` but no `resources/read`) is emitted *first* so the App frame's header (icon + server + tool + shimmer) replaces the plain tool rail with no flash; the full html-bearing event follows after the read and, last-write-wins, mounts the iframe (the SPA gates the iframe on a non-empty `html`). Deduped per `toolUseId` (the header shell on its own set, so it never blocks the full emit). HTML fetched server-side via `resources/read` and inlined; `sandboxOrigin` is the proxy.html origin the SPA frames it in (empty unless the mcp-sandbox stack is deployed β€” inference-api consumes its SSM origin only when `CDK_MCP_SANDBOX_ENABLED=true`; an empty origin means the SPA cannot frame the App). `serverName`/`icon`/`toolName` drive the App frame's connected header (Claude parity): `serverName`/`icon` resolve from the MCP `initialize` `serverInfo` (`title`β†’`name`, plus its `icons`); `serverName` falls back to the title-cased `ui://` authority, and `icon` falls back to the server's served MCPB `manifest.json` icon β€” fetched server-side from `/manifest.json` (same-origin only, cached per origin, base64-inlined as a `data:` URI; the runtime MCP protocol carries no icon, so this mirrors what Claude inlines from the installed bundle), else empty (β†’ generic glyph). A large auto-fetched icon is NOT persisted (size-gated against the 400KB DynamoDB item limit), so it shows live but reloads to the glyph; `toolName` is the agent-facing tool name, carried on the event so the header's name + running shimmer appear atomically with the frame's promotion (not gated on the separately-streamed message content). All three persist with the resource so the header survives reload. Gated by `AGENTCORE_MCP_APPS_HOST_ENABLED` (default true since PR #7; set `=false` to opt an environment out) | | `ui_tool_input_partial` | Streamed partial tool input for a UI tool (SEP-1865 `ui/notifications/tool-input-partial`) β€” payload `{type, toolUseId, arguments}`. Emitted repeatedly while the model is still streaming a UI tool's arguments (after the early `ui_resource` mount); `arguments` is the streamed prefix server-side "healed" into a valid object (`apis/shared/mcp_apps/partial_json.py`). The SPA relays each to the App via `ui/notifications/tool-input-partial` so a progressively-rendering App (e.g. Excalidraw's guided camera tour) animates as args arrive; the complete `tool-input` follows once the input is final. Same gating as `ui_resource` | +| `session_title` | Server-generated conversation title on a session's FIRST turn β€” payload `{type, sessionId, title}`. Title generation (Nova Micro) runs as an asyncio task concurrent with the agent stream; the finished title is interleaved between agent events (non-blocking done-check in `stream_with_quota_warning`), so the sidebar/top-nav rename while the response is still pending. Emitted at most once per stream, possibly after `done` (the SPA parser allowlists it past Completed-state gating); never carries the "New Conversation" placeholder. Best-effort: a stream that finishes before generation emits nothing β€” the SPA's post-close metadata refresh (`refreshTitleFromServer`) is the fallback, reading the title the task also persisted via `update_session_title` | | `stream_error` | Conversational error | | `oauth_required` | External MCP tool needs user consent β€” payload `{providerId, authorizationUrl}`, one event per provider emitted after `message_stop` | | `compaction` | Backend rolled older turns into a summary on this turn β€” payload `{previousCheckpoint, newCheckpoint, summarizedTurns, inputTokens}`, emitted after the final `metadata` event so the badge updates first, before `done` | diff --git a/README.md b/README.md index d570b3d7..83904274 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **An open-source, production-ready Generative AI platform for institutions** *Built by Boise State University, designed for everyone.* -[![Release](https://img.shields.io/badge/Release-v1.0.4-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.1.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) [![Nightly](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml/badge.svg)](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml) ![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white) @@ -296,7 +296,7 @@ agentcore-public-stack/ See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog, including new features, bug fixes, platform upgrades, and deployment notes for each release. -**Current release:** v1.0.4 +**Current release:** v1.1.0 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3408f96f..5adcec87 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,194 @@ +# Release Notes β€” v1.1.0 + +**Release Date:** July 8, 2026 +**Previous Release:** v1.0.4 (July 1, 2026) + +--- + +> πŸ—οΈ **This release adds new AWS infrastructure** (a dedicated Memory Spaces table + bucket, two new Lambda images, and several GSIs), so it ships through the **platform (CDK)** pipeline, not the API-only backend path. There is no data migration and no breaking change β€” existing deployments upgrade in place. See the Deployment notes at the end of this entry. + +--- + +## Highlights + +v1.1.0 is the platform's first **feature** release since the 1.0 line, turning AgentCore from a reactive chat surface into an **agentic platform**. It lands four new capabilities on shared, RBAC-governed primitives: + +- **Scheduled Runs** β€” agents that run *unattended* on a cadence (or on demand) and deliver a session you can read later. +- **Memory Spaces** β€” named, templated, shareable markdown "second brains" you fully own (export the whole thing as a `.zip`). +- **Agent Designer** β€” an authoring surface that composes an Agent from governed primitives (model, tools, skills, knowledge bases, Memory Spaces). +- **Knowledge Base Sync** β€” assistant knowledge sources (Google Drive + web crawls) that re-index themselves on a schedule. + +Alongside them is a **chat/session UX overhaul**: streaming is now isolated per conversation (concurrent chats no longer stomp each other), stopped or dropped turns persist their partial reply and cost, session titles stream in mid-response, and the top-nav gains a session-options menu and an active-assistant pill. + +Everything new is flag-gated and back-compatible. **Scheduled Runs, Memory Spaces, and KB Sync default on; the Agent Designer defaults off.** The three preview surfaces (Agents, Memory Spaces, Scheduled Runs) are gated to system-admins and marked with a "Preview" badge while they mature. **Operators must run a platform (CDK) deploy** for the new resources β€” see the deployment notes. + +--- + +## Scheduled Runs + +Agents can now run **without a person watching** β€” on a schedule you set (daily, on weekdays, weekly, or every *N* minutes/hours) or on demand via "Run now." A run executes one agent turn as the owner, with the owner's RBAC, and delivers the result as a normal session that shows an unread dot in the sidebar until you open it. This is the keystone of the proactive-agent effort (F1–F3 in the primitives plan). + +The hard problem was **unattended auth**: a headless run has no live user session to sign requests. The chosen path is an explicit, revocable *headless-grant* record created when a user enables scheduled runs from an attended session; the worker mints a per-owner Cognito bearer from it at fire time (the workload-token and SigV4 front-door paths were both proven dead at the runtime gateway during the F1 spike). + +### Backend + +- `apis/shared/harness/` β€” `run_agent_headless()` mints a per-owner Cognito bearer, drives the runtime's `/invocations` endpoint, drains the SSE stream server-side, and lets the runtime materialize + title the delivered session. Ships with an audit-only, fail-closed governance floor and wired no-op guardrail/classification seams (#560, #561). +- `apis/shared/harness/grants.py` β€” headless-grant lifecycle: create-on-enable, per-owner lookup via the sparse `HeadlessGrantUserIndex`, rotation-aware minting (a rotated refresh token is persisted before the mint returns), and revocation that deletes the stored credential. TTL is anchored to the login that issued the pinned refresh token ("must have logged in within 30 days") (#561). +- `apis/shared/scheduled_prompts/` + `apis/app_api/runs/` and `.../schedules/` β€” `ScheduledPrompt` model and service (cadence β†’ `next_run_at` computed timezone-aware), CRUD under `/schedules`, and the `/runs/now` + `/runs/grant` surfaces. Gated by the `SCHEDULED_RUNS_ENABLED` kill switch **and** a new `scheduled-runs` RBAC capability resolved through the mature tools grant axis (#561, #563, #578). +- Dispatcher + worker (`rate(5m)` EventBridge β†’ sweep the sparse `DueScheduleIndex` β†’ runaway guard β†’ conditional re-arm β†’ fire-and-forget β†’ per-owner mint β†’ `run_agent_headless(trigger="schedule")` β†’ record outcome, pausing on `reauth_required` / `oauth_required` / repeated failures). A persistent `consecutive_failures` counter trips the breaker at the production default of 3 (#565). +- **Security:** client-supplied `enabled_tools` is intersected with the caller's resolved RBAC grant at schedule create, schedule update, and Run-now (`AppRoleService.filter_requested_tools`), so a scheduled run can never persist a tool outside the owner's role (#568). Schedule edits gained explicit `clearAssistant` / `clearTools` flags so a `null` no longer silently reads as "leave unchanged" (#569). + +### Frontend + +- `frontend/ai.client/src/app/schedules/` β€” a signal-based list/create/edit page (bounded cadence UI + IANA timezone, optional assistant + tool snapshot, pause/resume/delete with confirm), the enablement UX (status banner, "Enable scheduled runs", reauth affordances), and "Run now" wired to a new app-wide `BackgroundTaskService` + toast component mounted in `app.html` (#564, #578). +- The "Scheduled Runs" nav entry is gated to system-admins (`showSchedules() && isAdmin()`) and carries a "Preview" badge (#574, #600). +- Delivered runs surface an unread dot (durable server flag ORed with the in-tab signal); the session menus gained a Mark-as-read / Mark-as-unread toggle (#572, #577). + +### Infrastructure + +- Two new sparse GSIs on the sessions-metadata table β€” `DueScheduleIndex` (projected only while a schedule is active) and `HeadlessGrantUserIndex` (only `HEADLESS-GRANT#` items carry the partition attribute). App-api's existing table grant already covers `index/*`, so no IAM change was needed for the schedule surface (#561, #563). +- `backend/Dockerfile.scheduled-runs` β€” a lean dispatcher+worker image (no ML deps) sharing one image via `ImageConfig.Command`, deployed via the platform-as-bootstrap pattern. Worker IAM scoped to sessions-metadata RW, the grant table, app-client secret read, and AgentCore vault token + oauth-secret read (#565). + +### Test Coverage + +Backend resolver/route/harness suites (schedule CRUD, cadence math, RBAC intersection, grant lifecycle, audit fail-closed ordering, MockTransport stream outcomes) plus an AST guard asserting the lean image never imports `agents`/`strands`; frontend schedule-form, run-now, and background-task specs. + +--- + +## Memory Spaces + +Memory Spaces are named, first-class **markdown wikis** β€” a `MEMORY.md` index plus typed entries (entity / episodic / fact) β€” that a user owns, templates from, and shares. They're the bindable memory primitive behind the "Oliver / Chief-of-Staff" use case, but generalized: any user can create one, and any Agent can bind one. The headline ownership property is a **loss-free `.zip` export** of the entire space (structure preserved, re-importable), so there is zero lock-in. + +### Backend + +- `apis/shared/memory/` β€” an S3 content-addressed byte store (sibling of the skills store); `MemorySpace` / `MemoryIndex` / `MemoryEntryRef` / `SpaceMember` models; Blank / Chief-of-Staff / Research-Notebook templates; a dedicated memory-spaces table repository (META/INDEX/MEMBER rows, Decimal-safe); and a permission-gated service whose `resolve_permission` chokepoint enforces viewer-reads / editor-writes / owner-shares-and-deletes with content-addressed writes and GC-on-replace (#582). +- `apis/app_api/memory_spaces/` β€” `/memory/spaces` CRUD (list with per-space role, create-from-template, entry/index I/O, delete-or-leave), `/shares` grant CRUD, `/export` (streams a `SpooledTemporaryFile` zip that spills to disk beyond 8 MiB, sanitized against zip-slip), and `/consolidate` (a deterministic health pass that GCs orphaned objects and *reports* dup/dead-link/over-cap findings without mutating durable memory). All 404 while `MEMORY_SPACES_ENABLED` is off (#584, #585, #586, #589). +- **Optimistic concurrency** makes multi-editor spaces safe: `put_index(expected_version=…)` does a conditional DynamoDB write on the manifest version, and entry writes route through a bounded read-modify-conditional-write retry loop that converges on transient races and raises `409` only on a sustained one (#586). + +### Frontend + +- `frontend/ai.client/src/app/memory-spaces/` β€” a list page (owned + shared-in cards with role/template badges), a detail page (edit `MEMORY.md` and the entry list; entries open in a view/edit dialog), a create-from-template dialog, and a share dialog (add-by-email + per-row role, delta-on-save). Viewer access is read-only throughout (#587). +- The "Memory Spaces" nav entry rides a live `accessible$` 404-probe, is gated to system-admins, and carries a "Preview" badge (#587, #600). + +### Infrastructure + +- `MemorySpacesConstruct` β€” a content-addressed S3 bucket + a dedicated `memory-spaces` DynamoDB table with `OwnerIndex` + `MemberIndex` GSIs, threaded to both compute roles (readwrite S3 + DynamoDB) with `S3_MEMORY_SPACES_BUCKET_NAME` / `DYNAMODB_MEMORY_SPACES_TABLE_NAME` / `MEMORY_SPACES_ENABLED` env vars. Provisioned unconditionally; only the runtime flag gates route mounting (#582, #588, #597). + +### Test Coverage + +47 moto-backed data-layer tests plus route suites covering CRUD, share matrices, zip layout/verbatim-frontmatter/hostile-slug, optimistic-lock convergence, and the consolidation report; frontend facade specs. + +--- + +## Agent Designer + +The Agent Designer is a new authoring surface that composes an **Agent** from RBAC-governed primitives β€” a governed single-select model plus a uniform `bindings[]` array (`tool` | `skill` | `knowledge_base` | `memory_space`) β€” evolving the existing Assistant store *in place* rather than forking a parallel table. Legacy Assistants read as Agents through a compat mapping, and `/assistants/*` is unchanged. It ships **dark** (`AGENTS_API_ENABLED` default off) so environments can opt in for dogfooding. + +The governing design decision: RBAC is *composed* from the five existing per-primitive access checks, not reinvented, and bindings are resolved twice β€” filtered at design time against the author's palette, then re-resolved at run time against the **invoking** user with block-on-missing. + +### Backend + +- `apis/shared/assistants/` β€” `AgentModelConfig` (governed single-select; stored as `modelConfig`) and `AgentBinding[]`, both optional/additive; a compat mapping projecting a legacy Assistant to an Agent (synthesizing a `knowledge_base` binding, never fabricating a model); Decimal-safe serialization for `modelConfig.params` (#591). +- `apis/app_api/agent_designer/` β€” the governed `/agents/*` surface (draft/create/list/get/update/delete + shares) returning the Agent shape, and `/agents/bindable?kind=…`, an RBAC-filtered palette composing the five per-primitive access services. Design-time `binding_validation` composes model access, memory `resolve_permission`, and shape-only checks; the model and tool write-checks use the *same* predicate the picker's catalog uses, so "if the palette offers it, the write accepts it" holds by construction (#592, #598, #600, #601). (Package renamed from `agents` to `agent_designer` to avoid shadowing the top-level `agents` package on the app-api `sys.path` β€” #595.) +- **Phase 3 harness resolution** (in inference-api, importing `apis.shared` only): `resolve_agent_invocation()` re-resolves the Agent's `modelConfig`, bound Memory Space, and `tool` bindings against the invoking user. A pinned model / memory grant / bound tool is re-checked per invoker; a missing grant blocks the turn with a conversational message (no silent downgrade). Bound tools *replace* the request's `enabled_tools`; the bound Memory Space's `alwaysLoad` content is injected into the system prompt and exposes `memory_list` / `memory_read` (always) + `memory_write` (readwrite bindings) tools (#594, #596, #601). + +### Frontend + +- `frontend/ai.client/src/app/agents/` β€” an Agents list page and an agent-form (persona/emoji/tags/starters, required single-select model picker, tool/skill multi-select chips, a Memory Space picker with read / read+write access and an `alwaysLoad` toggle, read-only KB). Sharing reuses the assistants share dialog. The "Agents" nav entry is gated to system-admins with a "Preview" badge (#598, #599, #600). + +### Infrastructure + +- `AGENTS_API_ENABLED` wired through CDK config (`CDK_AGENTS_API_ENABLED`, default off) onto app-api and the inference runtime. No new AWS resources β€” the flag only gates whether the routes 404 (#593). + +### Test Coverage + +Contract/compat/persistence/router suites, the bindable-catalog matrix, and per-invoker resolver cases (override, block-on-missing, dedupe, passthrough) for model, memory, and tool bindings. + +--- + +## Knowledge Base Sync + +Assistant knowledge sources previously indexed once, at import. KB Sync keeps them **fresh**: imported Google Drive files and web crawls can be put on a schedule (Daily/Weekly/Monthly) that re-checks the source, re-embeds only what actually changed, and pauses gracefully when a credential needs re-consent. It's a "sweeper, not scheduler" design β€” a periodic sweep of due policies with layered runaway guards. + +### Backend + +- `apis/shared/sync_policies/` β€” `SyncPolicy` model + repository (adjacency-list items on the assistants table) with a sparse `DueSyncIndex` (keyed only while active, so paused policies are invisible to the sweep), conditional re-arm for idempotent dispatch, and breaker counters. Assistant/document deletes cascade their policies (#542). +- Dispatcher + worker (`rate(15m)`): the dispatcher applies guards in order (kill switch β†’ liveness β†’ circuit breaker β†’ 30-day inactivity β†’ in-flight skip β†’ re-arm-with-backoff), then async-invokes the worker. The worker resolves the policy creator's Google token from the AgentCore Identity vault (no live session), does two-gate change detection (Drive `version` then content hash) for Drive files and conditional-GET/ETag + content-hash for web crawls, and stages changed bytes to the existing S3 key so the untouched ingestion pipeline re-chunks/re-embeds. Stale tail vectors are cleaned up on shrinkage (#543, #544, #545). +- `/assistants/{id}/sync-policies` CRUD + run-now (202, atomic 10-min cooldown) + resume hooks: a `paused_reauth` policy resumes only on a fresh OAuth consent; a `paused_inactive` policy wakes on a throttled `lastUsedAt` bump from chat use (#546). + +### Frontend + +- Per-source "Keep in sync" controls on the assistant knowledge editor (interval select, status line with state/reason/last-and-next sync, pause/resume, cooldown-aware Sync now, and a Reconnect affordance that routes through the OAuth consent popup). Owner/editor-only; device uploads show no control (#547). Follow-on UX polish clarified the control copy, always shows last-synced, adds a saving indicator, and unifies skeleton loading (#552, #555). + +### Infrastructure + +- `backend/Dockerfile.kb-sync` β€” a lightweight dispatcher+worker image (boto3 + pydantic, no ML deps) sharing one image via `ImageConfig.Command`, deployed via platform-as-bootstrap; a `rate(15m)` EventBridge rule; error alarms; function-name SSM params for the code-deploy step. Worker IAM adds read on the vault's backing OAuth secrets (`…!default/oauth2/*`) β€” the same grant app-api/inference-api carry, minus the write lifecycle (#543, #549). + +### Test Coverage + +Dispatcher-guard ordering, Drive/web change-detection and pause-semantics matrices, TTL-rearm cases, and the import-surface simulation that keeps the lean image FastAPI-free. + +--- + +## Chat & session UX + +A cluster of changes that make multi-conversation chat robust and legible. + +- **Per-conversation streaming (#535).** `ChatStateService` now holds per-session state (loading, stop reason, cost/context aggregates, Continue affordance, `AbortController`) behind a viewed-session facade; `StreamParserService` keys a `Map` and drops late events from a superseded stream. Asking in conversation A then navigating to B and asking again no longer crosses streams, and navigating away no longer aborts the in-flight turn (the backend still completes and persists it). Adds per-conversation scroll restore, a streaming-text replay fix, and a sidebar in-progress dot. +- **Interrupted-turn persistence (#541, #548).** A Stop / refresh / dropped socket used to orphan the user turn (no assistant reply persisted). Now the in-flight partial text is persisted via `asyncio.shield`, an authoritative `POST /sessions/{id}/interrupt` carries the `user_stopped` reason, and `_persist_interruption` also writes per-message + session-aggregate metadata (with a context-attribution projection for input tokens when a cut turn never delivered Bedrock's usage event) β€” so cost badges and the reload "Continue" chip survive. +- **Mid-stream session titles (#540).** inference-api interleaves a one-shot `session_title` SSE event once the concurrent title task resolves; the SPA applies it to both the sidebar and the top-nav header. Fixes a latent bug where title generation ran sync `boto3` on the event loop and stalled the live stream. +- **Top-nav session menu + active-assistant pill (#538).** The top-nav title gains a dropdown (Rename / Share / Save / Delete) and an active-assistant pill moved out of the chat-input footer, with optimistic rename and a title-transition polish pass. +- Other: conversation export defaults to messages-only (#537); OAuth consent state is cleared on session switch (#539); the tool card is preserved after an OAuth/tool-approval resume (#532); the assistant editor groups its Knowledge Base into an inset panel (#557). + +--- + +## πŸ› Bug fixes + +- **Admin "Last login" / "Created" showed "Never" in Safari.** `UserSyncService` built timestamps as `datetime.isoformat() + "Z"`, yielding an invalid `…+00:00Z` (offset *and* Z) that strict engines parse to Invalid Date. Normalized write-path timestamps to a single trailing `Z` and added a read-path heal for legacy rows (#556). +- **Admin model list returned a generic 502.** `GET /admin/bedrock/models` calls `ListFoundationModels`, but the app-api task role only had `InvokeModel`, so deployed environments hit `AccessDenied` (it only worked locally under broader dev credentials). Granted `bedrock:ListFoundationModels` + `bedrock:GetFoundationModel` (#571). +- **OAuth consent banner leaked across sessions.** The root-singleton consent service was keyed by `providerId`, not `sessionId`, and wasn't reset on conversation change; now cleared fail-closed alongside the other per-session resets (#539). +- **Tool card vanished after an OAuth/tool-approval resume.** The resumed stream omits the original `tool_use` block; both resume paths now pin the existing messages as a prefix and reconcile from persisted memory after the stream closes (#532). + +## πŸ—οΈ Infrastructure + +- New dedicated `memory-spaces` DynamoDB table (`OwnerIndex` + `MemberIndex` GSIs) and content-addressed S3 bucket (`MemorySpacesConstruct`). +- New sparse GSIs: `DueScheduleIndex` and `HeadlessGrantUserIndex` (sessions-metadata table), `DueSyncIndex` (assistants table). +- Two new lean Lambda images with EventBridge sweeps, both platform-as-bootstrap: `Dockerfile.scheduled-runs` (`rate(5m)`) and `Dockerfile.kb-sync` (`rate(15m)`). +- New empty-string-safe kill-switch flags forwarded via `platform.yml`: `SCHEDULED_RUNS_ENABLED`, `MEMORY_SPACES_ENABLED`, `KB_SYNC_ENABLED` (default on), `AGENTS_API_ENABLED` (default off). +- IAM additions: `bedrock:ListFoundationModels`/`GetFoundationModel` on the app-api task role; kb-sync worker read on the vault-backing OAuth secrets; scheduled-runs worker scoped to sessions-metadata RW + grant table + app-client secret + AgentCore vault token/oauth-secret read. + +## πŸ”§ CI/CD + +- `backend.yml` gains per-image build + API-driven code-deploy jobs for the kb-sync (dispatcher/worker) and scheduled-runs (dispatcher/worker) images, each sharing one image tag; `deploy-image-lambda-one.sh` grows a first-deploy grace skip for the introducing PR. Nightly image-scan and supply-chain Dockerfile-pinning lists include both new Dockerfiles. + +## πŸ“¦ Dependency notes + +No core backend (`pyproject.toml`) or frontend (`package.json`) dependency versions changed in this release. The new pins live **only** in the two lean Lambda images: + +| Component | Package | Version | +|---|---|---| +| kb-sync (web re-crawl) | `beautifulsoup4` | 4.13.5 | +| kb-sync (web re-crawl) | `trafilatura` | 2.0.0 | +| kb-sync (web re-crawl) | `lxml` | 6.1.1 | +| scheduled-runs (worker) | `cryptography` | 48.0.1 | +| scheduled-runs (worker) | `cachetools` | 6.2.4 | +| shared image runtime | `httpx` | 0.28.1 | +| shared image runtime | `bedrock-agentcore` | 1.9.1 | +| shared image runtime | `boto3` | 1.43.9 | +| shared image runtime | `pydantic` | 2.12.5 | + +## πŸš€ Deployment notes + +Unlike the recent 1.0.x patches, **this release requires a platform (CDK) deploy** β€” it creates a new DynamoDB table, a new S3 bucket, several GSIs, two new Lambda images, and two EventBridge rules. Run the `platform.yml` pipeline first, then the `backend.yml` pipeline ships the real kb-sync and scheduled-runs image code (platform-as-bootstrap). No data migration is needed and there are no breaking API changes; deployments on any 1.0.x upgrade in place. + +Feature enablement after deploy: + +- **Scheduled Runs, Memory Spaces, KB Sync** are **on by default** (empty/unset workflow vars = on; only the literal `false` disables). Their nav entries are visible only to system-admins and marked "Preview." Grant the `scheduled-runs` RBAC capability to the roles that should be able to schedule runs. +- **Agent Designer** is **off by default**. Set `CDK_AGENTS_API_ENABLED=true` for an environment to dogfood the `/agents` surface (its full payoff needs Memory Spaces deployed in the same environment). +- To dark-stop any preview surface in production, set the matching `CDK_*_ENABLED=false` and redeploy the platform. + +--- + # Release Notes β€” v1.0.4 **Release Date:** July 1, 2026 diff --git a/VERSION b/VERSION index ee90284c..9084fa2f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.4 +1.1.0 diff --git a/backend/Dockerfile.kb-sync b/backend/Dockerfile.kb-sync new file mode 100644 index 00000000..226943bb --- /dev/null +++ b/backend/Dockerfile.kb-sync @@ -0,0 +1,46 @@ +# kb-sync Lambda image β€” KB sync dispatcher + worker. +# +# One image, two Lambda functions: the CDK construct +# (infrastructure/lib/constructs/kb-sync/kb-sync-construct.ts) points +# both functions at this image with different ImageConfig.Command +# overrides (dispatcher vs worker handler). The command override is +# function *configuration*, so the workflow's +# `update-function-code --image-uri` swaps code on both functions +# without touching their handlers. +# +# Deliberately lightweight: the worker only fetches source bytes and +# stages them to S3 β€” chunking/embedding stays in the rag-ingestion +# image, triggered by the staged object's S3 event. +# +# Base image digest-pinned, same pin as the other Lambda images (see +# backend/Dockerfile.rag-ingestion and the supply-chain +# dockerfile-pinning test). + +FROM public.ecr.aws/lambda/python:3.12@sha256:745b0eb8a9787e9c4bfd4fc4cae942399a2225831c96394ae70c0c2a7c7c6168 + +# Dependencies (exact pins) +COPY backend/src/apis/app_api/kb_sync/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +# Application code β€” namespace-package layout mirrors backend/src. +# Keep this surface minimal: apis.shared domains the handlers import, +# plus the kb_sync package itself. If a handler needs more, COPY it +# here AND add the path to the kb-sync SOURCE_DIRS in +# scripts/build/build-one.sh so the content-hash tag notices changes. +COPY backend/src/apis/shared/__init__.py ${LAMBDA_TASK_ROOT}/apis/shared/__init__.py +COPY backend/src/apis/shared/sync_policies/ ${LAMBDA_TASK_ROOT}/apis/shared/sync_policies/ +COPY backend/src/apis/shared/oauth/ ${LAMBDA_TASK_ROOT}/apis/shared/oauth/ +COPY backend/src/apis/shared/embeddings/ ${LAMBDA_TASK_ROOT}/apis/shared/embeddings/ +COPY backend/src/apis/app_api/file_sources/ ${LAMBDA_TASK_ROOT}/apis/app_api/file_sources/ +COPY backend/src/apis/app_api/documents/ ${LAMBDA_TASK_ROOT}/apis/app_api/documents/ +COPY backend/src/apis/app_api/web_sources/ ${LAMBDA_TASK_ROOT}/apis/app_api/web_sources/ +COPY backend/src/apis/app_api/kb_sync/ ${LAMBDA_TASK_ROOT}/apis/app_api/kb_sync/ + +# file_sources/service.py, documents/routes.py, and web_sources/routes.py +# ride along with their packages but must never be imported here β€” they +# pull FastAPI, which this image deliberately lacks. The worker uses the +# oauth primitives, document/crawl services, and the crawler directly. + +# Default command: dispatcher. The worker function's CMD is overridden +# to apis.app_api.kb_sync.worker.lambda_handler by its ImageConfig. +CMD ["apis.app_api.kb_sync.dispatcher.lambda_handler"] diff --git a/backend/Dockerfile.scheduled-runs b/backend/Dockerfile.scheduled-runs new file mode 100644 index 00000000..05430b19 --- /dev/null +++ b/backend/Dockerfile.scheduled-runs @@ -0,0 +1,43 @@ +# scheduled-runs Lambda image β€” dispatcher + worker for the F3 scheduled +# trigger (docs/specs/scheduled-runs-phase-b-brief.md, docs/specs/ +# scheduled-agent-runs.md). +# +# One image, two Lambda functions: the CDK construct +# (infrastructure/lib/constructs/scheduled-runs/scheduled-runs-construct.ts) +# points both functions at this image with different ImageConfig.Command +# overrides (dispatcher vs worker handler). The command override is +# function *configuration*, so the workflow's +# `update-function-code --image-uri` swaps code on both functions +# without touching their handlers. Mirrors backend/Dockerfile.kb-sync, +# except the two handlers are plain top-level modules (dispatcher.py / +# worker.py) rather than living under the apis.* namespace package β€” the +# house rule (CLAUDE.md) keeps standalone Lambda code out of apis/, so +# there is no natural dotted path to reuse here the way kb-sync reuses +# apis.app_api.kb_sync. +# +# Base image digest-pinned, same pin as the other Lambda images (see +# backend/Dockerfile.kb-sync and the supply-chain dockerfile-pinning test). + +FROM public.ecr.aws/lambda/python:3.12@sha256:745b0eb8a9787e9c4bfd4fc4cae942399a2225831c96394ae70c0c2a7c7c6168 + +# Dependencies (exact pins) +COPY backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +# Application code β€” namespace-package layout mirrors backend/src for the +# apis.shared modules the handlers import. If a handler needs more, COPY +# it here AND add the path to the scheduled-runs SOURCE_DIRS in +# scripts/build/build-one.sh so the content-hash tag notices changes. +COPY backend/src/apis/shared/__init__.py ${LAMBDA_TASK_ROOT}/apis/shared/__init__.py +COPY backend/src/apis/shared/harness/ ${LAMBDA_TASK_ROOT}/apis/shared/harness/ +COPY backend/src/apis/shared/scheduled_prompts/ ${LAMBDA_TASK_ROOT}/apis/shared/scheduled_prompts/ +COPY backend/src/apis/shared/sessions_bff/ ${LAMBDA_TASK_ROOT}/apis/shared/sessions_bff/ +COPY backend/src/apis/shared/sessions/ ${LAMBDA_TASK_ROOT}/apis/shared/sessions/ + +# The two handlers themselves, as plain top-level modules. +COPY backend/src/lambdas/scheduled_runs_dispatcher/dispatcher.py ${LAMBDA_TASK_ROOT}/dispatcher.py +COPY backend/src/lambdas/scheduled_runs_worker/worker.py ${LAMBDA_TASK_ROOT}/worker.py + +# Default command: dispatcher. The worker function's CMD is overridden +# to worker.lambda_handler by its ImageConfig. +CMD ["dispatcher.lambda_handler"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e3632cb3..f8cc2a49 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.0.4" +version = "1.1.0" requires-python = ">=3.10" description = "Multi-agent conversational AI system with AWS Bedrock AgentCore" readme = "README.md" diff --git a/backend/scripts/spike_headless_run.py b/backend/scripts/spike_headless_run.py new file mode 100644 index 00000000..a639ef25 --- /dev/null +++ b/backend/scripts/spike_headless_run.py @@ -0,0 +1,249 @@ +"""Dev-ai driver for the headless run-entrypoint spike (F1). + +Runs `apis.shared.harness.run_agent_headless` from a laptop against the +deployed dev-ai AgentCore Runtime β€” through the runtime gateway, exactly the +path a scheduler worker would take. See +docs/specs/harness-entrypoint-spike-findings.md. + +Usage (requires an authenticated AWS profile for the dev-ai account): + + cd backend + AWS_PROFILE=dev-ai uv run python scripts/spike_headless_run.py \ + --user-id \ + --prompt "Find 3-credit undergraduate communication classes" \ + --tools class_search + + # Negative probes for the record (gateway auth evidence): + AWS_PROFILE=dev-ai uv run python scripts/spike_headless_run.py \ + --user-id --probe-workload-token --probe-sigv4 + +The script resolves all names from SSM / naming conventions for --prefix +(default dev-boisestateai-v2), exports the env vars the shared harness +expects, runs the turn, then reads back the session-metadata row and the +RUN# audit record as delivery proof. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import sys +import urllib.parse +import uuid + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("spike") + + +def resolve_environment(prefix: str, region: str) -> dict: + """Resolve dev-ai names from SSM + conventions and export harness env.""" + import boto3 + + ssm = boto3.client("ssm", region_name=region) + sts = boto3.client("sts", region_name=region) + account = sts.get_caller_identity()["Account"] + + runtime_id = ssm.get_parameter(Name=f"/{prefix}/inference-api/runtime-id")[ + "Parameter" + ]["Value"] + runtime_arn = f"arn:aws:bedrock-agentcore:{region}:{account}:runtime/{runtime_id}" + client_id = ssm.get_parameter(Name=f"/{prefix}/auth/cognito/bff-app-client-id")[ + "Parameter" + ]["Value"] + + env = { + "AWS_REGION": region, + "INFERENCE_API_URL": ( + f"https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{runtime_arn}" + ), + "BFF_SESSIONS_TABLE_NAME": f"{prefix}-bff-sessions", + "COGNITO_BFF_APP_CLIENT_ID": client_id, + "COGNITO_BFF_APP_CLIENT_SECRET_ARN": f"{prefix}-cognito-bff-app-client-secret", + "DYNAMODB_SESSIONS_METADATA_TABLE_NAME": f"{prefix}-sessions-metadata", + } + os.environ.update(env) + return {"runtime_arn": runtime_arn, "account": account, **env} + + +def probe_workload_token(prefix: str, region: str, runtime_arn: str, user_id: str) -> None: + """Unknown-1 'try first' path β€” recorded evidence: the gateway rejects it.""" + import boto3 + import httpx + + client = boto3.client("bedrock-agentcore", region_name=region) + token = client.get_workload_access_token_for_user_id( + workloadName=f"{prefix}-platform-workload", userId=user_id + )["workloadAccessToken"] + is_jwt = token.count(".") == 2 + logger.info("workload token minted (len=%d, jwt=%s)", len(token), is_jwt) + + encoded = urllib.parse.quote(runtime_arn, safe="") + url = ( + f"https://bedrock-agentcore.{region}.amazonaws.com/runtimes/" + f"{encoded}/invocations?qualifier=DEFAULT" + ) + r = httpx.post( + url, + headers={"Authorization": f"Bearer {token}"}, + json={"session_id": f"probe-{uuid.uuid4().hex[:8]}", "message": "ping"}, + timeout=30, + ) + logger.info("PROBE workload-token bearer -> HTTP %d %s", r.status_code, r.text[:200]) + + +def probe_sigv4(region: str, runtime_arn: str) -> None: + """IAM data-plane call β€” recorded evidence: authorizer-method mismatch.""" + import boto3 + + client = boto3.client("bedrock-agentcore", region_name=region) + try: + resp = client.invoke_agent_runtime( + agentRuntimeArn=runtime_arn, + qualifier="DEFAULT", + runtimeSessionId=f"probe-sigv4-{uuid.uuid4().hex}", + contentType="application/json", + accept="text/event-stream", + payload=json.dumps( + {"session_id": f"probe-{uuid.uuid4().hex[:8]}", "message": "ping"} + ).encode(), + ) + logger.info("PROBE sigv4 -> statusCode=%s", resp.get("statusCode")) + except Exception as exc: + logger.info("PROBE sigv4 -> %s: %s", type(exc).__name__, exc) + + +def verify_delivery(prefix: str, region: str, user_id: str, session_id: str, run_id: str) -> None: + """Read back the session row + audit record as F2/F6a proof.""" + import boto3 + from boto3.dynamodb.conditions import Key + + table = boto3.resource("dynamodb", region_name=region).Table( + f"{prefix}-sessions-metadata" + ) + rows = table.query( + IndexName="SessionLookupIndex", + KeyConditionExpression=Key("GSI_PK").eq(f"SESSION#{session_id}"), + )["Items"] + meta = [r for r in rows if str(r.get("SK", "")).startswith("S#")] + messages = [r for r in rows if str(r.get("GSI_SK", "")).startswith("C#")] + logger.info( + "DELIVERY session row: %s", + json.dumps( + { + k: str(v) + for k, v in (meta[0] if meta else {}).items() + if k in ("title", "status", "messageCount", "lastModel", "SK") + } + ), + ) + logger.info("DELIVERY persisted message items: %d", len(messages)) + + audit = table.get_item( + Key={"PK": f"USER#{user_id}", "SK": f"RUN#{run_id}"} + ).get("Item") + logger.info( + "AUDIT record: %s", + json.dumps({k: str(v) for k, v in (audit or {}).items()}, sort_keys=True)[:600], + ) + + +async def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prefix", default="dev-boisestateai-v2") + parser.add_argument("--region", default="us-west-2") + parser.add_argument("--user-id", required=True, help="Cognito sub of the run owner") + parser.add_argument("--prompt", default="Reply with the single word: pong") + parser.add_argument( + "--tools", + default=None, + help="Comma-separated enabled_tools (omit for the user's defaults)", + ) + parser.add_argument("--title", default=None, help="Explicit session title") + parser.add_argument("--probe-workload-token", action="store_true") + parser.add_argument("--probe-sigv4", action="store_true") + parser.add_argument("--skip-run", action="store_true") + args = parser.parse_args() + + resolved = resolve_environment(args.prefix, args.region) + logger.info("runtime: %s", resolved["runtime_arn"]) + + if args.probe_workload_token: + probe_workload_token( + args.prefix, args.region, resolved["runtime_arn"], args.user_id + ) + if args.probe_sigv4: + probe_sigv4(args.region, resolved["runtime_arn"]) + if args.skip_run: + return 0 + + # Import after env export β€” the harness reads configuration from env. + from apis.shared.harness import ( + CognitoRefreshBearerAuth, + HeadlessGrantService, + run_agent_headless, + ) + + # Dev-driver stand-in for create-on-enable: production creates the + # headless grant from the caller's *live* session on the "Run now" + # route; from a laptop we bootstrap it from the user's newest BFF + # session row instead (a filtered Scan is fine for a dev script). + grants = HeadlessGrantService() + if await grants.get_active_grant(args.user_id) is None: + import boto3 + from boto3.dynamodb.conditions import Attr + + table = boto3.resource("dynamodb", region_name=args.region).Table( + os.environ["BFF_SESSIONS_TABLE_NAME"] + ) + rows: list[dict] = [] + kwargs: dict = {"FilterExpression": Attr("user_id").eq(args.user_id)} + while True: + page = table.scan(**kwargs) + rows.extend(page.get("Items", [])) + if "LastEvaluatedKey" not in page: + break + kwargs["ExclusiveStartKey"] = page["LastEvaluatedKey"] + if not rows: + logger.error( + "No BFF session for %s β€” log in once, then re-run", args.user_id + ) + return 1 + newest = max(rows, key=lambda r: int(r.get("last_seen_at") or 0)) + await grants.enable( + user_id=args.user_id, + username=str(newest["username"]), + refresh_token=str(newest["cognito_refresh_token"]), + token_issued_at=int(newest.get("created_at") or 0) or None, + ) + logger.info("Bootstrapped headless grant for %s", args.user_id) + + async def on_event(name: str, data: dict) -> None: + if name in ("tool_use", "tool_result", "session_title", "stream_error"): + logger.info("SSE %s: %s", name, json.dumps(data, default=str)[:220]) + + result = await run_agent_headless( + user_id=args.user_id, + prompt=args.prompt, + auth=CognitoRefreshBearerAuth(), + enabled_tools=args.tools.split(",") if args.tools else None, + agent_type="chat", + trigger="spike", + title=args.title, + on_event=on_event, + ) + + print("\n================ RunResult ================") + print(json.dumps(result.to_dict(), indent=2, default=str)[:4000]) + print("===========================================\n") + + verify_delivery( + args.prefix, args.region, args.user_id, result.session_id, result.run_id + ) + return 0 if result.status == "completed" else 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/backend/src/agents/builtin_tools/memory_spaces/__init__.py b/backend/src/agents/builtin_tools/memory_spaces/__init__.py new file mode 100644 index 00000000..f4679292 --- /dev/null +++ b/backend/src/agents/builtin_tools/memory_spaces/__init__.py @@ -0,0 +1,13 @@ +"""Context-bound Memory-Space tools for an Agent's bound space (Agent Designer Phase 3).""" + +from .tools import ( + make_memory_list_tool, + make_memory_read_tool, + make_memory_write_tool, +) + +__all__ = [ + "make_memory_list_tool", + "make_memory_read_tool", + "make_memory_write_tool", +] diff --git a/backend/src/agents/builtin_tools/memory_spaces/tools.py b/backend/src/agents/builtin_tools/memory_spaces/tools.py new file mode 100644 index 00000000..c6317da6 --- /dev/null +++ b/backend/src/agents/builtin_tools/memory_spaces/tools.py @@ -0,0 +1,130 @@ +"""Context-bound tools for an Agent's bound Memory Space (Agent Designer Phase 3). + +Each factory closes over the *binding's* space id and the *invoking* user's identity, so +the returned tool can physically only address that one space as that one user β€” it cannot +be re-pointed at another space. ``MemorySpaceService`` re-checks the caller's grant +(``viewer+`` for reads, ``editor+`` for writes) on every call, so a permission revoked +mid-session surfaces as an error tool-result on the next call rather than leaking access. +Same closure-identity + ``asyncio.to_thread`` pattern as the artifact/spreadsheet tools +(the codebase has no tool-execution contextvar; ``MemorySpaceService`` is sync boto3). + +These are injected via the invocation path's ``extra_tools`` seam only when an Agent has a +resolved ``memory_space`` binding β€” agents with ``extra_tools`` are never cached, so a tool +closed over user A's identity can never be served to user B. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Optional + +from strands import tool + +from apis.shared.memory.service import ( + MemorySpaceError, + MemorySpaceNotFoundError, + MemorySpacePermissionError, + MemorySpaceService, +) + +logger = logging.getLogger(__name__) + + +def _error(text: str) -> dict[str, Any]: + return {"content": [{"text": f"❌ {text}"}], "status": "error"} + + +def make_memory_list_tool(space_id: str, space_name: str, user_id: str, user_email: Optional[str]): + @tool + async def memory_list(entry_type: Optional[str] = None) -> dict[str, Any]: + """List the entries in your bound memory space (manifest only β€” no content). + + Use this to see what you remember before deciding whether to `memory_read` a + specific entry. Returns each entry's slug, type, description, and last-updated + time. Optionally filter by `entry_type` ("entity", "episodic", or "fact"). + + Args: + entry_type: Optional filter β€” one of "entity", "episodic", "fact". + """ + try: + entries = await asyncio.to_thread( + MemorySpaceService().list_entries, + space_id, user_id, user_email, entry_type=entry_type, + ) + except MemorySpacePermissionError as exc: + return _error(f"You no longer have access to memory space '{space_name}': {exc}") + except MemorySpaceError as exc: + return _error(f"Could not list memory: {exc}") + + summary = [ + {"slug": e.slug, "type": e.entry_type, "description": e.description, "updated": e.updated} + for e in entries + ] + return {"content": [{"json": {"entries": summary}}], "status": "success"} + + return memory_list + + +def make_memory_read_tool(space_id: str, space_name: str, user_id: str, user_email: Optional[str]): + @tool + async def memory_read(slug: str) -> dict[str, Any]: + """Read the full content of one entry in your bound memory space. + + Pass a `slug` from `memory_list` (or referenced in your injected memory index). + + Args: + slug: The entry's stable id within the space (e.g. "jane-doe"). + """ + try: + body = await asyncio.to_thread( + MemorySpaceService().read_entry, space_id, user_id, user_email, slug + ) + except MemorySpaceNotFoundError: + return _error(f"No memory entry '{slug}' exists in '{space_name}'.") + except MemorySpacePermissionError as exc: + return _error(f"You no longer have access to memory space '{space_name}': {exc}") + except MemorySpaceError as exc: + return _error(f"Could not read memory entry '{slug}': {exc}") + return {"content": [{"text": body}], "status": "success"} + + return memory_read + + +def make_memory_write_tool(space_id: str, space_name: str, user_id: str, user_email: Optional[str]): + @tool + async def memory_write( + slug: str, + body: str, + entry_type: str = "fact", + description: str = "", + ) -> dict[str, Any]: + """Create or replace an entry in your bound memory space (persists across sessions). + + Use this to remember durable facts, people/entities, or episodic notes the user + will want recalled later. Writing an existing `slug` replaces that entry. Only + available when the agent's memory binding grants write access. + + Args: + slug: Stable id for the entry (e.g. "jane-doe", "daily-2026-07-07"). + body: The entry's markdown content. + entry_type: "entity", "episodic", or "fact" (default "fact"). + description: Short one-line summary shown in listings. + """ + try: + ref = await asyncio.to_thread( + lambda: MemorySpaceService().write_entry( + space_id, user_id, user_email, slug, body, + entry_type=entry_type, description=description, + ) + ) + except MemorySpacePermissionError as exc: + return _error(f"You don't have write access to memory space '{space_name}': {exc}") + except MemorySpaceError as exc: + return _error(f"Could not write memory entry '{slug}': {exc}") + return { + "content": [{"text": f'Saved memory entry "{ref.slug}" ({ref.entry_type}) to "{space_name}".'}], + "status": "success", + } + + return memory_write diff --git a/backend/src/agents/main_agent/base_agent.py b/backend/src/agents/main_agent/base_agent.py index caf4240f..ddb863df 100644 --- a/backend/src/agents/main_agent/base_agent.py +++ b/backend/src/agents/main_agent/base_agent.py @@ -358,23 +358,14 @@ async def scopes_lookup(provider_id: str) -> List[str]: provider = await get_provider_repository().get_provider(provider_id) return provider.scopes if provider else [] - async def provider_type_lookup(provider_id: str) -> Optional[str]: - # AgentCore Identity needs vendor-specific OAuth params - # forwarded via `customParameters` (e.g. Google's - # `access_type=offline` for refresh tokens). The hook reads - # this to forward those. - from apis.shared.oauth.provider_repository import get_provider_repository - - provider = await get_provider_repository().get_provider(provider_id) - return provider.provider_type.value if provider else None - async def custom_parameters_lookup( provider_id: str, ) -> Optional[dict[str, str]]: - # Admin-supplied OAuth extras (e.g. `hd=mycorp.com` for - # Google Workspace domain restriction). Merged with the - # vendor baseline by `custom_parameters_for`; baseline wins - # on conflict. + # The connector's admin-configured OAuth `customParameters`, + # forwarded verbatim to AgentCore Identity (e.g. a Google + # connector's `access_type=offline` for refresh tokens + + # `prompt=consent`, or `hd=mycorp.com` for Workspace domain + # restriction). from apis.shared.oauth.provider_repository import get_provider_repository provider = await get_provider_repository().get_provider(provider_id) @@ -396,7 +387,6 @@ async def disconnected_lookup(provider_id: str) -> bool: user_id=self.user_id, provider_lookup=provider_lookup, scopes_lookup=scopes_lookup, - provider_type_lookup=provider_type_lookup, custom_parameters_lookup=custom_parameters_lookup, disconnected_lookup=disconnected_lookup, tool_use_provider_lookup=tool_use_provider_lookup, diff --git a/backend/src/agents/main_agent/session/hooks/oauth_consent.py b/backend/src/agents/main_agent/session/hooks/oauth_consent.py index 7b08a6b1..8f221c19 100644 --- a/backend/src/agents/main_agent/session/hooks/oauth_consent.py +++ b/backend/src/agents/main_agent/session/hooks/oauth_consent.py @@ -124,16 +124,12 @@ def _looks_like_auth_failure(tool_result: Any) -> bool: # without forcing a sync wrapper. ScopesLookup = Callable[[str], Union[list[str], Awaitable[list[str]]]] -# Returns the provider's vendor type (e.g. "google", "microsoft") for a -# provider_id, or None if unknown / no per-vendor params needed. Optional β€” -# omitted in older tests; without it AgentCore Identity gets no -# `customParameters`, which means Google won't issue a refresh token and -# the vault entry expires after ~1 hour. -ProviderTypeLookup = Callable[[str], Union[Optional[str], Awaitable[Optional[str]]]] - -# Returns admin-supplied OAuth params (e.g. `hd=mycorp.com` for Google -# Workspace domain restriction) for a provider_id. Merged with the -# vendor baseline by `custom_parameters_for`; baseline wins on conflict. +# Returns the connector's admin-configured OAuth `customParameters` for a +# provider_id (e.g. a Google connector's `access_type=offline` + +# `prompt=consent`, or `hd=mycorp.com` for Workspace domain restriction), or +# None when the connector has no extras. Optional β€” omitted in older tests; +# without it AgentCore Identity gets no `customParameters`, so a Google +# connector configured this way is what makes Google issue a refresh token. CustomParametersLookup = Callable[ [str], Union[Optional[dict[str, str]], Awaitable[Optional[dict[str, str]]]] ] @@ -153,7 +149,6 @@ def __init__( user_id: str, provider_lookup: ProviderLookup, scopes_lookup: ScopesLookup, - provider_type_lookup: Optional[ProviderTypeLookup] = None, custom_parameters_lookup: Optional[CustomParametersLookup] = None, disconnected_lookup: Optional[DisconnectedLookup] = None, tool_use_provider_lookup: Optional[ToolUseProviderLookup] = None, @@ -166,12 +161,10 @@ def __init__( fallback (no-op in production). provider_lookup: See `ProviderLookup`. scopes_lookup: See `ScopesLookup`. - provider_type_lookup: See `ProviderTypeLookup`. Optional. When - provided, the hook forwards vendor-specific OAuth params - (e.g. Google's `access_type=offline`) to AgentCore Identity. custom_parameters_lookup: See `CustomParametersLookup`. - Optional. Admin-supplied extras to merge with the vendor - baseline. + Optional. The connector's admin-configured `customParameters` + forwarded verbatim to AgentCore Identity (e.g. a Google + connector's `access_type=offline` + `prompt=consent`). disconnected_lookup: See `DisconnectedLookup`. Optional. When omitted, the hook never bypasses the local token cache β€” effectively assumes the user has not disconnected. Wire @@ -185,7 +178,6 @@ def __init__( self._user_id = user_id self._provider_lookup = provider_lookup self._scopes_lookup = scopes_lookup - self._provider_type_lookup = provider_type_lookup self._custom_parameters_lookup = custom_parameters_lookup self._disconnected_lookup = disconnected_lookup self._tool_use_provider_lookup = tool_use_provider_lookup @@ -193,11 +185,10 @@ def __init__( # invocation). Avoids repeated DB hits if the same provider is used # across multiple tool calls in a single turn. self._scopes_cache: dict[str, list[str]] = {} - # Same cache shape for provider_type. `None` is a legitimate value - # (vendor without extra params), so we use a separate sentinel set - # to distinguish "unknown" from "looked up, no extras needed". - self._provider_type_cache: dict[str, Optional[str]] = {} - self._provider_type_cache_keys: set[str] = set() + # Cache the connector's customParameters per provider. `None` is a + # legitimate value (connector without extra params), so we use a + # separate sentinel set to distinguish "unknown" from "looked up, + # no extras". self._custom_parameters_cache: dict[str, Optional[dict[str, str]]] = {} self._custom_parameters_cache_keys: set[str] = set() # Providers that already burned their one 401-retry in the current @@ -312,7 +303,6 @@ async def _fetch_token_or_url( ) -> Optional[dict]: """Return {'token': str|None, 'url': str|None} or None on hard error.""" scopes = await self._resolve_scopes(provider_id) - provider_type = await self._resolve_provider_type(provider_id) admin_extras = await self._resolve_custom_parameters(provider_id) identity_client = get_agentcore_identity_client() @@ -322,11 +312,7 @@ async def _fetch_token_or_url( scopes=scopes, user_id=self._user_id, force_authentication=force_authentication, - custom_parameters=custom_parameters_for( - provider_type, - admin_extras, - force_authentication=force_authentication, - ), + custom_parameters=custom_parameters_for(admin_extras), ) except WorkloadTokenUnavailableError: logger.error( @@ -424,20 +410,6 @@ async def _resolve_scopes(self, provider_id: str) -> list[str]: self._scopes_cache[provider_id] = scopes return scopes - async def _resolve_provider_type(self, provider_id: str) -> Optional[str]: - if self._provider_type_lookup is None: - return None - if provider_id in self._provider_type_cache_keys: - return self._provider_type_cache.get(provider_id) - result = self._provider_type_lookup(provider_id) - if inspect.isawaitable(result): - provider_type = await result - else: - provider_type = result - self._provider_type_cache[provider_id] = provider_type - self._provider_type_cache_keys.add(provider_id) - return provider_type - async def _resolve_custom_parameters( self, provider_id: str ) -> Optional[dict[str, str]]: diff --git a/backend/src/agents/main_agent/streaming/stream_coordinator.py b/backend/src/agents/main_agent/streaming/stream_coordinator.py index b4133679..55d3d745 100644 --- a/backend/src/agents/main_agent/streaming/stream_coordinator.py +++ b/backend/src/agents/main_agent/streaming/stream_coordinator.py @@ -114,6 +114,20 @@ async def stream_response( per_message_metadata: List[Dict[str, Any]] = [] current_assistant_message_index = -1 # Track which assistant message we're on (0-indexed within this stream) + # Accumulate the IN-FLIGHT assistant message's TEXT so an interruption + # (client Stop / refresh / dropped socket) can persist the partial the + # user already saw. Unlike max_tokens truncation β€” where Strands + # recovers and appends the partial itself β€” a raw cancellation aborts + # the turn before Strands commits the current message, so we + # reconstruct it from the deltas here. Scope is strictly the message + # currently streaming: TurnBasedSessionManager persists each COMPLETED + # message immediately via the append_message hook (flush is a no-op), + # so the accumulator resets at assistant `message_start` and clears at + # `message_stop` β€” otherwise re-persisting it would duplicate text + # from mid-turn messages that already landed in AgentCore Memory. + # See the CancelledError/GeneratorExit handler below. + assistant_text_acc: List[str] = [] + # OPTIMIZATION: Capture initial message count BEFORE streaming starts # This allows us to calculate message indices without post-stream AgentCore Memory queries # The TurnBasedSessionManager.message_count is initialized from AgentCore Memory at session start @@ -155,6 +169,10 @@ async def stream_response( if event.get("type") == "message_start": role = event.get("data", {}).get("role") if role == "assistant": + # New in-flight message β€” drop any leftover deltas + # (a prior message was completed + persisted by the + # append_message hook, or this is the first message). + assistant_text_acc.clear() current_assistant_message_index += 1 # Record the start time for this specific assistant message # This enables accurate per-message latency calculation @@ -177,6 +195,8 @@ async def stream_response( # Only track first token for text deltas (not tool use deltas) # This gives accurate TTFT for actual text generation if event_data.get("type") == "text" and event_data.get("text"): + # Capture the partial for interruption persistence. + assistant_text_acc.append(event_data["text"]) if current_assistant_message_index >= 0 and current_assistant_message_index < len(per_message_metadata): if per_message_metadata[current_assistant_message_index]["first_token_time"] is None: per_message_metadata[current_assistant_message_index]["first_token_time"] = time.time() @@ -226,6 +246,11 @@ async def stream_response( # Track when assistant messages end if event.get("type") == "message_stop": + # Message complete β€” Strands appends it to agent.messages + # and the append_message hook persists it to AgentCore + # Memory. It is no longer "in flight", so an interruption + # from here on must not re-persist its text. + assistant_text_acc.clear() if current_assistant_message_index >= 0 and current_assistant_message_index < len(per_message_metadata): per_message_metadata[current_assistant_message_index]["end_time"] = time.time() logger.debug(f"πŸ“ Assistant message {current_assistant_message_index} ended") @@ -894,6 +919,37 @@ async def stream_response( except Exception as e: logger.error(f"Failed to store user displayText: {e}", exc_info=True) + except (asyncio.CancelledError, GeneratorExit): + # Client interruption: Stop click, page refresh, or dropped + # socket. Depending on where the generator is suspended when the + # disconnect lands, Starlette's teardown surfaces here as either + # CancelledError (cancellation delivered at an inner `await`, + # e.g. while waiting on model tokens β€” the common case) or + # GeneratorExit (thrown at a `yield` via `aclose()`). Both + # subclass BaseException, so the `except Exception` below never + # sees them β€” without this arm the partial is lost and the turn + # stays an orphan user message. This server-side arm is the + # BEST-EFFORT BACKSTOP (disconnect propagation through the + # AgentCore Runtime data plane is not guaranteed in cloud); the + # authoritative intent signal is the SPA's stop-signal endpoint + # on app-api. Persist the partial the user already saw + mark the + # turn, then RE-RAISE so teardown unwinds normally. + # `connection_lost` is the fallback reason; a `user_stopped` + # client signal, if it lands, wins via reason precedence in + # set_interrupted_turn. + await self._persist_interruption( + agent=agent, + session_id=session_id, + user_id=user_id, + partial_text="".join(assistant_text_acc), + main_agent_wrapper=main_agent_wrapper, + accumulated_metadata=accumulated_metadata, + initial_message_count=initial_message_count, + current_assistant_message_index=current_assistant_message_index, + stream_start_time=stream_start_time, + first_token_time=first_token_time, + ) + raise except Exception as e: # Handle errors with emergency flush logger.error(f"Error in stream_response: {e}") @@ -958,6 +1014,179 @@ async def stream_response( "MCP Apps broker unsubscribe failed", exc_info=True ) + async def _persist_interruption( + self, + agent: Any, + session_id: str, + user_id: str, + partial_text: str, + main_agent_wrapper: Any = None, + accumulated_metadata: Optional[Dict[str, Any]] = None, + initial_message_count: int = 0, + current_assistant_message_index: int = -1, + stream_start_time: Optional[float] = None, + first_token_time: Optional[float] = None, + ) -> None: + """Persist the in-flight partial assistant turn + an interrupted + marker when a turn is torn down mid-stream. + + Runs from inside the ``except (CancelledError, GeneratorExit)`` arm, + i.e. while the request task is being torn down. Any bare ``await`` + here would itself be cancelled before it completes, so the work is + wrapped in ``asyncio.shield`` around an independent task β€” the writes + finish even as the outer stream unwinds. Best-effort throughout: a + failure logs and never masks the original teardown exception (the + caller re-raises it). + + Only the assistant partial is persisted β€” the user turn (and any + mid-turn message that completed before the interruption) was already + committed by Strands' MessageAddedEvent/append_message hook (same + reasoning as the error paths; canonical reference in + ``session/persistence.py``). + + Role-alternation repair: when the interruption lands before any token + of the in-flight message streamed (empty partial), a minimal + placeholder assistant turn is persisted ONLY if the last committed + message is a user turn β€” that is the dangling userβ†’user case the + placeholder exists to fix. On continuation/resume turns (history tail + is an assistant message) or a pre-turn cancellation nothing needs + repair, so no synthetic write happens and only the marker is set. + """ + async def _do() -> None: + text = partial_text.strip() + last_role = None + try: + messages = getattr(agent, "messages", None) or [] + if messages: + last_role = messages[-1].get("role") + except Exception: # noqa: BLE001 - diagnostic only + logger.debug("Could not read agent.messages tail", exc_info=True) + + message = text if text else "[Response interrupted before any content was generated]" + should_persist = bool(text) or last_role == "user" + if should_persist: + try: + from agents.main_agent.session.persistence import persist_synthetic_messages + from agents.main_agent.session.session_factory import SessionFactory + + persist_session_manager = SessionFactory.create_session_manager( + session_id=session_id, user_id=user_id, caching_enabled=False + ) + persist_synthetic_messages( + persist_session_manager, + session_id, + [("assistant", message)], + ) + except Exception as persist_error: + logger.error( + "Failed to persist interrupted partial for session %s: %s", + session_id, persist_error, exc_info=True, + ) + else: + logger.info( + "Interruption with no in-flight partial and non-user history tail " + "(role=%s) for session %s β€” marker only, no synthetic write", + last_role, session_id, + ) + + try: + from apis.shared.sessions.metadata import set_interrupted_turn + + await set_interrupted_turn( + session_id, user_id, reason="connection_lost", source="cancellation" + ) + except Exception as marker_error: + logger.error( + "Failed to persist interrupted_turn marker for session %s: %s", + session_id, marker_error, exc_info=True, + ) + + # Partial-turn metadata. On a Stop the client's socket is already + # gone, so the `done`-path metadata SSE (usage / cost / context) + # never reaches it and BOTH the per-message badges and the session + # cost badge stay blank β€” even after a reload, because nothing was + # persisted. Store it here with the same `_store_message_metadata` + # the completion path uses: that writes the per-message row AND + # bumps the session aggregates that hydrate the cost badge. Runs + # only when an assistant message was actually persisted above + # (`should_persist`), keyed to the same odd-position index the + # messages endpoint re-derives on reload. Best-effort: a cut + # generation often never delivered Bedrock's terminal usage event, + # so fall back to the context-attribution projection for the input + # side (drives the context-% badge + input-side cost; output is + # unknown and priced at zero). + if should_persist and main_agent_wrapper is not None: + try: + metadata_for_message = dict(accumulated_metadata or {}) + if not metadata_for_message.get("usage"): + projected = self._projected_input_usage(agent) + if projected: + metadata_for_message = {**metadata_for_message, "usage": projected} + message_id = ( + initial_message_count + 2 * current_assistant_message_index + 1 + if current_assistant_message_index >= 0 + else initial_message_count + 1 + ) + await self._store_message_metadata( + session_id=session_id, + user_id=user_id, + message_id=message_id, + accumulated_metadata=metadata_for_message, + stream_start_time=stream_start_time if stream_start_time is not None else time.time(), + stream_end_time=time.time(), + first_token_time=first_token_time, + agent=main_agent_wrapper, + ) + logger.info( + "πŸ“Š Persisted interrupted-turn metadata for session %s (message_id=%s)", + session_id, message_id, + ) + except Exception as meta_error: + logger.error( + "Failed to persist interrupted-turn metadata for session %s: %s", + session_id, meta_error, exc_info=True, + ) + + try: + await asyncio.shield(asyncio.ensure_future(_do())) + except asyncio.CancelledError: + # The shield itself was cancelled from outside while _do() ran β€” + # the inner task keeps running to completion (that's the point of + # shield). Swallow here and let the caller re-raise the original. + logger.debug("interrupted-turn persistence shield cancelled; inner write continues") + + def _projected_input_usage(self, agent: Any) -> Optional[Dict[str, Any]]: + """Best-effort input-token usage for an interrupted turn. + + A cut generation frequently never delivers Bedrock's terminal usage + event, so ``accumulated_metadata['usage']`` is empty and the turn + would persist with no token/cost/context data. The context-attribution + hook computed the turn's projected input at ``BeforeModelCallEvent`` + (before the model call that got interrupted), so use its total as the + input-side occupancy. Output is unknown β€” the turn never finished β€” so + it's reported as zero (input-side cost only). Returns ``None`` if no + projection is available, leaving the caller to persist whatever it has. + """ + try: + from agents.main_agent.session.hooks.context_attribution import ( + get_context_breakdown, + ) + + breakdown = get_context_breakdown(agent) + if not breakdown: + return None + total = ( + breakdown.get("total") + if isinstance(breakdown, dict) + else getattr(breakdown, "total", None) + ) + if not total or total <= 0: + return None + return {"inputTokens": int(total), "outputTokens": 0, "totalTokens": int(total)} + except Exception: # noqa: BLE001 - best-effort enrichment only + logger.debug("Could not project interrupted-turn input usage", exc_info=True) + return None + async def _persist_paused_turn_snapshot( self, agent: Any, diff --git a/backend/src/apis/app_api/agent_designer/__init__.py b/backend/src/apis/app_api/agent_designer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/apis/app_api/agent_designer/routes.py b/backend/src/apis/app_api/agent_designer/routes.py new file mode 100644 index 00000000..9a43d8ec --- /dev/null +++ b/backend/src/apis/app_api/agent_designer/routes.py @@ -0,0 +1,353 @@ +"""Agent Designer β€” the ``/agents/*`` surface (Phase 1, PR-3). + +A thin alias over the evolved assistant store: the same ``apis.shared.assistants`` +service functions and the same identity-based access gates as ``/assistants/*``, but +returning the **Agent** shape (``compat.to_agent_view`` β†’ ``AgentResponse``) so callers +see ``modelConfig`` + ``bindings``. Legacy ids are valid unchanged (agentId == assistantId). + +Gating: the router is mounted unconditionally but every route depends on +``require_agents_enabled`` β€” a 404 when ``AGENTS_API_ENABLED`` is off, so the surface +behaves as if unmounted while the feature ships incrementally. Auth is the standard SPA +cookie dependency per the CLAUDE.md app-api rule. + +Deliberately excluded in Phase 1: ``test-chat`` (the only reason the assistants router is +on the architecture import-boundary allow-list β€” aliasing it would force a second +exception) and document sub-routes. Those stay on ``/assistants/*``. ``GET /agents`` lists +the caller's own + shared-with-them agents; ``include_public``/pagination parity is a +Phase-4 concern when the Designer consumes it. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status + +from apis.app_api.agent_designer.services.bindable_catalog import ( + BINDABLE_KINDS, + list_bindable, +) +from apis.app_api.agent_designer.services.binding_validation import ( + BindingValidationError, + validate_agent_write, +) +from apis.shared.assistants.compat import to_agent_view +from apis.shared.assistants.models import ( + AgentResponse, + AgentSharesResponse, + AgentsListResponse, + BindableListResponse, + CreateAssistantDraftRequest, + CreateAssistantRequest, + ShareAssistantRequest, + ShareEntry, + UnshareAssistantRequest, + UpdateAssistantRequest, + UpdateSharePermissionRequest, +) +from apis.shared.assistants.service import ( + assistant_exists, + create_assistant, + create_assistant_draft, + delete_assistant, + get_assistant_with_access_check, + list_assistant_shares, + list_shared_with_user, + list_user_assistants, + resolve_assistant_permission, + share_assistant, + unshare_assistant, + update_assistant, + update_share_permission, +) +from apis.shared.auth.dependencies import get_current_user_from_session +from apis.shared.auth.models import User +from apis.shared.feature_flags import agents_enabled + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/agents", tags=["agents"]) + + +async def require_agents_enabled( + user: User = Depends(get_current_user_from_session), +) -> User: + """Cookie auth + the environment kill switch. + + 404 when ``AGENTS_API_ENABLED`` is off, so the surface behaves as if unmounted + (mirrors the memory-spaces / schedules pattern). + """ + if not agents_enabled(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + return user + + +def _agent_response(assistant, *, permission: Optional[str] = None, + is_shared_with_me: Optional[bool] = None) -> AgentResponse: + """Project an Assistant into the Agent read-shape, layering share metadata.""" + view = to_agent_view(assistant) + if permission is not None: + view["userPermission"] = permission + if is_shared_with_me is not None: + view["isSharedWithMe"] = is_shared_with_me + view["firstInteracted"] = getattr(assistant, "first_interacted", None) + return AgentResponse.model_validate(view) + + +def _shares_response(agent_id: str, shares: list) -> AgentSharesResponse: + return AgentSharesResponse( + agent_id=agent_id, + shared_with=[ShareEntry.model_validate(s) for s in shares], + ) + + +# --------------------------------------------------------------------------- CRUD +@router.post("/draft", response_model=AgentResponse, response_model_exclude_none=True) +async def create_agent_draft_endpoint( + request: CreateAssistantDraftRequest, current_user: User = Depends(require_agents_enabled) +): + """Create a draft Agent with an auto-generated id (status=DRAFT).""" + try: + assistant = await create_assistant_draft( + owner_id=current_user.user_id, owner_name=current_user.name, name=request.name + ) + return _agent_response(assistant, permission="owner") + except Exception as e: + logger.error(f"Error creating draft agent: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to create draft agent: {str(e)}") + + +@router.post("", response_model=AgentResponse, response_model_exclude_none=True) +async def create_agent_endpoint( + request: CreateAssistantRequest, current_user: User = Depends(require_agents_enabled) +): + """Create a complete Agent (status=COMPLETE) with optional bindings + modelConfig.""" + try: + await validate_agent_write( + current_user, bindings=request.bindings, model_settings=request.model_settings + ) + except BindingValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + + try: + assistant = await create_assistant( + owner_id=current_user.user_id, + owner_name=current_user.name, + name=request.name, + description=request.description, + instructions=request.instructions, + visibility=request.visibility, + tags=request.tags, + starters=request.starters, + emoji=request.emoji, + bindings=request.bindings, + model_settings=request.model_settings, + ) + return _agent_response(assistant, permission="owner") + except Exception as e: + logger.error(f"Error creating agent: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}") + + +@router.get("", response_model=AgentsListResponse, response_model_exclude_none=True) +async def list_agents_endpoint( + include_drafts: bool = False, current_user: User = Depends(require_agents_enabled) +): + """List the caller's own Agents plus those shared with them (most-recent first).""" + try: + owned, _ = await list_user_assistants( + owner_id=current_user.user_id, include_drafts=include_drafts, include_public=False + ) + owned_ids = {a.assistant_id for a in owned} + shared = [a for a in await list_shared_with_user(current_user.email) if a.assistant_id not in owned_ids] + + agents = [_agent_response(a, permission="owner", is_shared_with_me=False) for a in owned] + agents += [ + _agent_response(a, permission=getattr(a, "user_permission", None), is_shared_with_me=True) + for a in shared + ] + agents.sort(key=lambda a: a.created_at, reverse=True) + return AgentsListResponse(agents=agents, next_token=None) + except Exception as e: + logger.error(f"Error listing agents: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") + + +# --------------------------------------------------------------------------- palette +# Declared BEFORE ``/{agent_id}`` so the literal path is not captured by the path param. +@router.get("/bindable", response_model=BindableListResponse) +async def list_bindable_endpoint( + kind: str, current_user: User = Depends(require_agents_enabled) +): + """RBAC-filtered catalog of bindable primitives of ``kind`` for the caller (D4). + + The Designer palette: each picker fetches ``?kind=model|tool|skill|knowledge_base| + memory_space`` and shows only what the user's role enables. Composes the existing + per-primitive access services (see ``bindable_catalog``). + """ + if kind not in BINDABLE_KINDS: + raise HTTPException( + status_code=400, + detail=f"Unsupported bindable kind '{kind}'. Expected one of: {', '.join(BINDABLE_KINDS)}.", + ) + try: + items = await list_bindable(kind, current_user) + return BindableListResponse(kind=kind, items=items) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error listing bindable '{kind}': {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to list bindable '{kind}': {str(e)}") + + +@router.get("/{agent_id}", response_model=AgentResponse, response_model_exclude_none=True) +async def get_agent_endpoint(agent_id: str, current_user: User = Depends(require_agents_enabled)): + """Retrieve an Agent by id with visibility-based access control.""" + try: + if not await assistant_exists(agent_id): + raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") + assistant, permission = await get_assistant_with_access_check( + assistant_id=agent_id, user_id=current_user.user_id, user_email=current_user.email + ) + if not assistant: + raise HTTPException(status_code=403, detail="Access denied: you do not have permission to access this agent") + return _agent_response(assistant, permission=permission) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving agent: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to retrieve agent: {str(e)}") + + +@router.put("/{agent_id}", response_model=AgentResponse, response_model_exclude_none=True) +async def update_agent_endpoint( + agent_id: str, request: UpdateAssistantRequest, current_user: User = Depends(require_agents_enabled) +): + """Update an Agent (owner or editor); visibility changes are owner-only.""" + try: + assistant, permission = await resolve_assistant_permission( + assistant_id=agent_id, user_id=current_user.user_id, user_email=current_user.email + ) + if not assistant: + raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") + if permission not in ("owner", "editor"): + raise HTTPException(status_code=403, detail="You do not have permission to edit this agent") + if permission == "editor" and request.visibility is not None and request.visibility != assistant.visibility: + raise HTTPException(status_code=400, detail="Only the owner can change agent visibility") + + try: + await validate_agent_write( + current_user, bindings=request.bindings, model_settings=request.model_settings + ) + except BindingValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + + updated = await update_assistant( + assistant_id=agent_id, + owner_id=assistant.owner_id, + name=request.name, + description=request.description, + instructions=request.instructions, + visibility=request.visibility, + tags=request.tags, + starters=request.starters, + emoji=request.emoji, + status=request.status, + image_url=request.image_url, + bindings=request.bindings, + model_settings=request.model_settings, + ) + if not updated: + raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") + return _agent_response(updated, permission=permission) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating agent: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") + + +@router.delete("/{agent_id}", status_code=204) +async def delete_agent_endpoint(agent_id: str, current_user: User = Depends(require_agents_enabled)): + """Delete an Agent (owner only).""" + try: + deleted = await delete_assistant(agent_id, current_user.user_id) + if not deleted: + raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting agent: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") + + +# --------------------------------------------------------------------------- shares +# The share mutations return a bool (False β†’ not owned / not found); we then read the +# updated list. Mirrors the /assistants/{id}/shares handlers exactly (same records). +@router.post("/{agent_id}/shares", response_model=AgentSharesResponse) +async def share_agent_endpoint( + agent_id: str, request: ShareAssistantRequest, current_user: User = Depends(require_agents_enabled) +): + """Share an Agent with emails at a permission level (owner only).""" + user_id = current_user.user_id + try: + if not await share_assistant(agent_id, user_id, request.emails, request.permission): + raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") + return _shares_response(agent_id, await list_assistant_shares(agent_id, user_id)) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error sharing agent: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to share agent: {str(e)}") + + +@router.delete("/{agent_id}/shares", response_model=AgentSharesResponse) +async def unshare_agent_endpoint( + agent_id: str, request: UnshareAssistantRequest, current_user: User = Depends(require_agents_enabled) +): + """Remove shares from an Agent (owner only).""" + user_id = current_user.user_id + try: + if not await unshare_assistant(agent_id, user_id, request.emails): + raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") + return _shares_response(agent_id, await list_assistant_shares(agent_id, user_id)) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error unsharing agent: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to modify shares for agent: {str(e)}") + + +@router.patch("/{agent_id}/shares", response_model=AgentSharesResponse) +async def update_agent_share_endpoint( + agent_id: str, request: UpdateSharePermissionRequest, current_user: User = Depends(require_agents_enabled) +): + """Change an existing share's permission level (owner only).""" + user_id = current_user.user_id + try: + if not await update_share_permission(agent_id, user_id, request.email, request.permission): + raise HTTPException(status_code=404, detail="Agent or share record not found") + return _shares_response(agent_id, await list_assistant_shares(agent_id, user_id)) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating agent share permission: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update share permission: {str(e)}") + + +@router.get("/{agent_id}/shares", response_model=AgentSharesResponse) +async def get_agent_shares_endpoint(agent_id: str, current_user: User = Depends(require_agents_enabled)): + """List an Agent's share records (owners and editors may read).""" + try: + assistant, permission = await resolve_assistant_permission( + assistant_id=agent_id, user_id=current_user.user_id, user_email=current_user.email + ) + if not assistant: + raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") + if permission not in ("owner", "editor"): + raise HTTPException(status_code=403, detail="You do not have permission to view shares for this agent") + return _shares_response(agent_id, await list_assistant_shares(agent_id, assistant.owner_id)) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting agent shares: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get agent shares: {str(e)}") diff --git a/backend/src/apis/app_api/agent_designer/services/__init__.py b/backend/src/apis/app_api/agent_designer/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/apis/app_api/agent_designer/services/bindable_catalog.py b/backend/src/apis/app_api/agent_designer/services/bindable_catalog.py new file mode 100644 index 00000000..65185708 --- /dev/null +++ b/backend/src/apis/app_api/agent_designer/services/bindable_catalog.py @@ -0,0 +1,180 @@ +"""Agent Designer Phase 2 β€” the bindable-primitives catalog (D4). + +The read/list dual of ``binding_validation``: where that module *validates* an Agent +write against the author's per-primitive access, this one *lists* what the caller may +bind, composing the **same** existing per-primitive access services. It invents no new +RBAC β€” it is the palette **and** the design-time enforcement point (D4). + +One entry point, ``list_bindable(kind, user)``, fans out to the primitive's existing +list + access service and projects each result into the uniform ``BindableItem`` shape +so every Designer picker consumes one contract. Services are injectable for testing, +mirroring ``validate_agent_write``. + +Per-kind sourcing (see the write-side rules in ``binding_validation``): + +- ``model`` β†’ ``filter_accessible_models(user, list_all_managed_models())``. +- ``tool`` β†’ ``ToolCatalogService.get_user_accessible_tools(user)`` (already + RBAC-filtered + MCP-server-tool grouped). +- ``skill`` β†’ ``resolve_accessible_skill_ids(user)`` hydrated via + ``batch_get_skills`` β€” **empty when ``skills_enabled()`` is off**. +- ``knowledge_base`` β†’ **empty**. The KB is welded to the agent and its index is not + user-configurable, so it is never author-settable (``binding_validation`` rejects an + explicit KB write); the compat layer synthesizes it on read. Nothing to pick. +- ``memory_space`` β†’ ``MemorySpaceService.list_spaces_for_user`` β€” **empty when + ``memory_spaces_enabled()`` is off**. +""" + +import logging +from typing import List, Optional + +from apis.shared.assistants.models import BindableItem +from apis.shared.auth.models import User +from apis.shared.feature_flags import memory_spaces_enabled, skills_enabled +from apis.shared.memory.service import MemorySpaceService +from apis.shared.models.managed_models import list_all_managed_models +from apis.shared.skills.access import resolve_accessible_skill_ids +from apis.shared.skills.repository import get_skill_catalog_repository + +from apis.app_api.admin.services.model_access import ( + ModelAccessService, + get_model_access_service, +) +from apis.app_api.tools.service import ToolCatalogService, get_tool_catalog_service + +logger = logging.getLogger(__name__) + +# The kinds the catalog can serve. ``model`` is not a binding kind (it is the governed +# single-select ``modelConfig``) but rides the same palette endpoint so the Designer has +# one place to fetch every choice. +BINDABLE_KINDS = ("model", "tool", "skill", "knowledge_base", "memory_space") + + +async def list_bindable( + kind: str, + user: User, + *, + model_access_service: Optional[ModelAccessService] = None, + tool_service: Optional[ToolCatalogService] = None, + memory_service: Optional[MemorySpaceService] = None, +) -> List[BindableItem]: + """Return the RBAC-filtered bindable primitives of ``kind`` for ``user``. + + Raises ``ValueError`` for an unknown ``kind`` (the route maps that to 400). Each + sub-lister is best-effort: a failure in one primitive's service logs and yields an + empty list rather than failing the whole palette. + """ + if kind == "model": + return await _list_models(user, model_access_service or get_model_access_service()) + if kind == "tool": + return await _list_tools(user, tool_service or get_tool_catalog_service()) + if kind == "skill": + return await _list_skills(user) + if kind == "knowledge_base": + # Welded to the agent, synthesized on read, never author-settable (D2/F4). + return [] + if kind == "memory_space": + return _list_memory_spaces(user, memory_service or MemorySpaceService()) + raise ValueError(f"Unknown bindable kind '{kind}'.") + + +async def _list_models(user: User, svc: ModelAccessService) -> List[BindableItem]: + try: + models = await list_all_managed_models() + accessible = await svc.filter_accessible_models(user, models) + except Exception: + logger.warning("Failed to list bindable models", exc_info=True) + return [] + # ``ref`` is the Bedrock/provider model id β€” the identifier the runtime resolver, + # RBAC (``permissions.models``) and invocation all key on. NOT the internal UUID. + return [ + BindableItem( + kind="model", + ref=m.model_id, + label=m.model_name, + description=m.provider_name or m.provider or "", + meta={ + "provider": m.provider, + "providerName": m.provider_name, + "isDefault": m.is_default, + "maxInputTokens": m.max_input_tokens, + "maxOutputTokens": m.max_output_tokens, + "supportsCaching": m.supports_caching, + "inputModalities": m.input_modalities, + "outputModalities": m.output_modalities, + "supportedParams": m.supported_params, + }, + ) + for m in accessible + ] + + +async def _list_tools(user: User, svc: ToolCatalogService) -> List[BindableItem]: + try: + tools = await svc.get_user_accessible_tools(user) + except Exception: + logger.warning("Failed to list bindable tools", exc_info=True) + return [] + return [ + BindableItem( + kind="tool", + ref=t.tool_id, + label=t.display_name, + description=t.description or "", + meta={ + "category": t.category, + "protocol": t.protocol, + "requiresOauthProvider": t.requires_oauth_provider, + "serverTools": [ + { + "name": st.name, + "description": st.description, + "needsApproval": st.needs_approval, + "enabled": st.enabled, + } + for st in t.server_tools + ], + }, + ) + for t in tools + ] + + +async def _list_skills(user: User) -> List[BindableItem]: + if not skills_enabled(): + return [] + try: + skill_ids = await resolve_accessible_skill_ids(user) + skills = await get_skill_catalog_repository().batch_get_skills(skill_ids) + except Exception: + logger.warning("Failed to list bindable skills", exc_info=True) + return [] + return [ + BindableItem( + kind="skill", + ref=s.skill_id, + label=s.display_name, + description=s.description or "", + meta={"boundToolIds": s.bound_tool_ids, "compose": s.compose}, + ) + for s in skills + ] + + +def _list_memory_spaces(user: User, svc: MemorySpaceService) -> List[BindableItem]: + if not memory_spaces_enabled(): + return [] + try: + spaces = svc.list_spaces_for_user(user.user_id, user.email) + except Exception: + logger.warning("Failed to list bindable memory spaces", exc_info=True) + return [] + return [ + BindableItem( + kind="memory_space", + ref=space.space_id, + label=space.name, + description=space.template or "", + meta={"role": role, "ownerId": space.owner_id, "template": space.template}, + ) + for space, role in spaces + ] diff --git a/backend/src/apis/app_api/agent_designer/services/binding_validation.py b/backend/src/apis/app_api/agent_designer/services/binding_validation.py new file mode 100644 index 00000000..16a5a3a6 --- /dev/null +++ b/backend/src/apis/app_api/agent_designer/services/binding_validation.py @@ -0,0 +1,171 @@ +"""Agent Designer Phase 1 β€” design-time binding + model validation (D4/D5). + +Composes the *existing* per-primitive access checks; invents no new RBAC (D4). Run at +write time against the **author** (design-time half of D5); Phase 3 re-resolves each +binding against the invoking user at run time. + +Resolution scope: +- ``model`` β†’ must exist + author passes ``ModelAccessService.can_access_model``. +- ``tool`` β†’ author must have the tool in the ``/agents/bindable`` palette + (``ToolCatalogService.get_user_accessible_tools``, the SAME source the picker fetches, so + "if the palette offers it, the write accepts it" β€” cf. the model check). Run-time then + re-resolves each bound tool against the *invoker* (``AppRoleService.can_access_tool``, D5). +- ``memory_space`` β†’ feature-flagged; author needs viewer+ (read) / editor+ (readwrite). +- ``knowledge_base`` β†’ **managed implicitly** (the KB is welded to the agent and its index + is not user-configurable), so it is NOT author-settable; the compat layer synthesizes it + on read. An explicit knowledge_base binding is rejected. +- ``skill`` β†’ **inert**: shape-checked only, stored verbatim, no catalog lookup and + no RBAC (its run-time fold interacts with ``agent_type``/skill resolution β€” a later slice). +""" + +import logging +from typing import List, Optional + +from apis.shared.assistants.models import KNOWN_BINDING_KINDS, AgentBinding, AgentModelConfig +from apis.shared.auth.models import User +from apis.shared.feature_flags import memory_spaces_enabled +from apis.shared.memory.service import MemorySpaceService +from apis.shared.models.managed_models import list_all_managed_models + +from apis.app_api.admin.services.model_access import ModelAccessService +from apis.app_api.tools.service import ToolCatalogService + +logger = logging.getLogger(__name__) + +_INERT_KINDS = ("skill",) +_ROLE_RANK = {"viewer": 1, "editor": 2, "owner": 3} + + +class BindingValidationError(Exception): + """A binding/model failed design-time validation. + + ``status_code`` maps to the HTTP response the route should return: 403 for an + access denial (the author lacks the capability), 400 for a malformed request. + """ + + def __init__(self, message: str, status_code: int = 400) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + + +async def validate_agent_write( + user: User, + *, + bindings: Optional[List[AgentBinding]] = None, + model_settings: Optional[AgentModelConfig] = None, + model_access_service: Optional[ModelAccessService] = None, + memory_service: Optional[MemorySpaceService] = None, + tool_service: Optional[ToolCatalogService] = None, +) -> None: + """Validate an Agent write for ``user``; raise ``BindingValidationError`` on failure. + + Services are injectable for testing. Callers pass only the fields actually present + on the request β€” ``None`` means "not provided", so nothing is validated for it. + """ + if model_settings is not None: + await _validate_model(user, model_settings, model_access_service or ModelAccessService()) + + if bindings is not None: + mem = memory_service or MemorySpaceService() + # Resolve the author's accessible tools ONCE (async) if any tool binding is present, + # then validate each binding synchronously against that set β€” same source the palette + # uses, so the picker and the write agree (cf. the model check). + accessible_tool_ids: Optional[set] = None + if any(b.kind == "tool" for b in bindings): + svc = tool_service or ToolCatalogService() + accessible_tool_ids = {t.tool_id for t in await svc.get_user_accessible_tools(user)} + for binding in bindings: + _validate_binding(user, binding, mem, accessible_tool_ids) + + +async def _validate_model(user: User, cfg: AgentModelConfig, svc: ModelAccessService) -> None: + # ``modelConfig.modelId`` is the Bedrock/provider model id β€” the identifier the + # runtime resolver, RBAC (``permissions.models``) and invocation all key on. Look + # the record up by ``model_id`` (NOT ``get_managed_model``, which keys on the + # internal UUID PK and would reject a valid Bedrock id with a 400). + model = next((m for m in await list_all_managed_models() if m.model_id == cfg.model_id), None) + if model is None: + raise BindingValidationError(f"Model '{cfg.model_id}' is not available.", status_code=400) + # Use the SAME predicate the ``/agents/bindable`` catalog uses (``filter_accessible_models``), + # not ``can_access_model``: the latter only honors an AppRole model grant when the model + # record also carries a non-empty ``allowed_app_roles``, so a model granted purely via the + # user's AppRole ``permissions.models`` (empty ``allowed_app_roles``) is *listed* by the + # palette but would be *rejected* here β€” the picker shows it, saving 403s. Filtering the + # single model guarantees "if the palette offers it, the write accepts it", and matches the + # runtime's membership grant. + if not await svc.filter_accessible_models(user, [model]): + raise BindingValidationError( + f"You do not have access to model '{cfg.model_id}'.", status_code=403 + ) + + +def _validate_binding( + user: User, + binding: AgentBinding, + mem: MemorySpaceService, + accessible_tool_ids: Optional[set] = None, +) -> None: + kind = binding.kind + if kind not in KNOWN_BINDING_KINDS: + raise BindingValidationError(f"Unsupported binding kind '{kind}'.", status_code=400) + + if kind == "knowledge_base": + raise BindingValidationError( + "knowledge_base bindings are managed automatically and cannot be set directly.", + status_code=400, + ) + + if kind in _INERT_KINDS: + # Inert: shape only, no RBAC, no catalog lookup. + if not binding.ref or not binding.ref.strip(): + raise BindingValidationError(f"{kind} binding requires a non-empty 'ref'.", status_code=400) + return + + if kind == "tool": + _validate_tool(binding, accessible_tool_ids or set()) + return + + if kind == "memory_space": + _validate_memory_space(user, binding, mem) + + +def _validate_tool(binding: AgentBinding, accessible_tool_ids: set) -> None: + ref = (binding.ref or "").strip() + if not ref: + raise BindingValidationError("tool binding requires a non-empty 'ref'.", status_code=400) + # The tool id must be in the author's palette (get_user_accessible_tools) β€” the exact + # source the Designer picker fetches, so a bindable tool is always writable. Run-time + # re-resolves against the invoker via AppRoleService.can_access_tool (D5). + if ref not in accessible_tool_ids: + raise BindingValidationError( + f"You do not have access to tool '{ref}'.", status_code=403 + ) + + +def _validate_memory_space(user: User, binding: AgentBinding, mem: MemorySpaceService) -> None: + if not memory_spaces_enabled(): + raise BindingValidationError("Memory Spaces are not enabled.", status_code=400) + + access = (binding.config or {}).get("access", "read") + if access not in ("read", "readwrite"): + raise BindingValidationError( + f"memory_space binding 'access' must be 'read' or 'readwrite', got '{access}'.", + status_code=400, + ) + + always_load = (binding.config or {}).get("alwaysLoad") + if always_load is not None and not ( + isinstance(always_load, list) and all(isinstance(x, str) for x in always_load) + ): + raise BindingValidationError("memory_space 'alwaysLoad' must be a list of strings.", status_code=400) + + space, role = mem.resolve_permission(binding.ref, user.user_id, user.email) + if space is None: + raise BindingValidationError(f"Memory space '{binding.ref}' not found.", status_code=400) + + required = "editor" if access == "readwrite" else "viewer" + if role is None or _ROLE_RANK[role] < _ROLE_RANK[required]: + raise BindingValidationError( + f"'{required}' access required on memory space '{binding.ref}'.", status_code=403 + ) diff --git a/backend/src/apis/app_api/assistants/routes.py b/backend/src/apis/app_api/assistants/routes.py index aa810eea..17e337fd 100644 --- a/backend/src/apis/app_api/assistants/routes.py +++ b/backend/src/apis/app_api/assistants/routes.py @@ -12,6 +12,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse +from apis.app_api.agent_designer.services.binding_validation import ( + BindingValidationError, + validate_agent_write, +) from apis.app_api.documents.services.document_service import list_assistant_documents from apis.inference_api.chat.routes import stream_conversational_message from apis.inference_api.chat.service import get_agent @@ -121,6 +125,15 @@ async def create_assistant_endpoint(request: CreateAssistantRequest, current_use logger.info(f"POST /assistants - User: {user_id}, Name: {request.name}") + # Design-time binding/model validation (D4/D5). Outside the try below so the + # 4xx it raises isn't swallowed into a 500 by the generic handler. + try: + await validate_agent_write( + current_user, bindings=request.bindings, model_settings=request.model_settings + ) + except BindingValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + try: # Create complete assistant # Note: vector_index_id is automatically set from S3_ASSISTANTS_VECTOR_STORE_INDEX_NAME env var @@ -134,6 +147,8 @@ async def create_assistant_endpoint(request: CreateAssistantRequest, current_use tags=request.tags, starters=request.starters, emoji=request.emoji, + bindings=request.bindings, + model_settings=request.model_settings, ) # Convert to response model (excludes owner_id for privacy) @@ -344,6 +359,14 @@ async def update_assistant_endpoint(assistant_id: str, request: UpdateAssistantR detail="Only the owner can change assistant visibility", ) + # Design-time binding/model validation (D4/D5), after the auth gate above. + try: + await validate_agent_write( + current_user, bindings=request.bindings, model_settings=request.model_settings + ) + except BindingValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + # Mutation functions are keyed on the assistant's real owner_id, not the requester updated_assistant = await update_assistant( assistant_id=assistant_id, @@ -357,6 +380,8 @@ async def update_assistant_endpoint(assistant_id: str, request: UpdateAssistantR emoji=request.emoji, status=request.status, image_url=request.image_url, + bindings=request.bindings, + model_settings=request.model_settings, ) if not updated_assistant: @@ -397,12 +422,17 @@ async def delete_assistant_endpoint(assistant_id: str, current_user: User = Depe document_ids=[doc.document_id for doc in docs], ) - # 3. Hard-delete assistant record + # 3. Delete sync policies eagerly so no schedule outlives the assistant + # (the dispatcher's liveness check is the backstop, not the mechanism) + from apis.shared.sync_policies.service import delete_sync_policies_for_assistant + await delete_sync_policies_for_assistant(assistant_id) + + # 4. Hard-delete assistant record success = await delete_assistant(assistant_id=assistant_id, owner_id=user_id) if not success: raise HTTPException(status_code=404, detail=f"Assistant not found: {assistant_id}") - # 4. Fire-and-forget background cleanup for all documents + # 5. Fire-and-forget background cleanup for all documents if docs: from apis.app_api.documents.services.cleanup_service import cleanup_assistant_documents asyncio.ensure_future(cleanup_assistant_documents(assistant_id, docs)) diff --git a/backend/src/apis/app_api/chat/proxy_routes.py b/backend/src/apis/app_api/chat/proxy_routes.py index b01d036c..9fa060d7 100644 --- a/backend/src/apis/app_api/chat/proxy_routes.py +++ b/backend/src/apis/app_api/chat/proxy_routes.py @@ -21,10 +21,9 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse -from urllib.parse import quote, urlsplit - from apis.shared.auth.dependencies import get_current_user_from_session from apis.shared.auth.models import User +from apis.shared.harness.runner import build_invocations_url logger = logging.getLogger(__name__) @@ -37,29 +36,12 @@ def _inference_api_url() -> str: # wedged upstream eventually surfaces. _PROXY_TIMEOUT_SECONDS = 300.0 - -def _build_invocations_url(base_url: str) -> str: - """Resolve the upstream `/invocations` URL from `INFERENCE_API_URL`. - - Cloud: `INFERENCE_API_URL` is the AgentCore Runtime data-plane base - (`https://bedrock-agentcore..amazonaws.com/runtimes/`), - where `` is unencoded in SSM. The data-plane route is - `POST /runtimes/{agentRuntimeArn}/invocations?qualifier={qualifier}` - with `{agentRuntimeArn}` as a single URL-encoded path segment β€” so the - ARN's literal `/` and `:` must be percent-encoded or AWS returns 404. - A `qualifier` is also required; we use `DEFAULT`. - - Local dev: `INFERENCE_API_URL` is `http://localhost:8001`, where - `/invocations` is a real FastAPI route on inference-api directly. No - encoding or qualifier needed. - """ - parts = urlsplit(base_url) - prefix = "/runtimes/" - if parts.netloc.startswith("bedrock-agentcore.") and parts.path.startswith(prefix): - arn = parts.path[len(prefix):] - encoded_arn = quote(arn, safe="") - return f"{parts.scheme}://{parts.netloc}/runtimes/{encoded_arn}/invocations?qualifier=DEFAULT" - return f"{base_url}/invocations" +# Canonical `/invocations` URL resolution lives in the shared harness +# (`apis.shared.harness.runner.build_invocations_url`) β€” the headless +# runner, this proxy, and the MCP Apps proxy all share one copy. Kept +# under the historical private name so existing call sites and docstring +# references stay valid. +_build_invocations_url = build_invocations_url def _build_upstream_client() -> httpx.AsyncClient: diff --git a/backend/src/apis/app_api/connectors/routes.py b/backend/src/apis/app_api/connectors/routes.py index 0e5b82b8..f1f2e209 100644 --- a/backend/src/apis/app_api/connectors/routes.py +++ b/backend/src/apis/app_api/connectors/routes.py @@ -230,20 +230,15 @@ async def initiate_consent( scopes=provider.scopes, user_id=current_user.user_id, force_authentication=force_auth, - # initiate_consent is by definition a "walk me through consent" - # path β€” the user clicked Connect/Reconnect after seeing they - # were not connected. For Google this means we must send - # `prompt=consent`, otherwise Google sees a previously-consented - # user, skips the consent screen, and re-issues an access_token - # WITHOUT a refresh_token. Vault then expires after ~1h and the - # user is back here. Decoupled from `force_auth` (which only - # toggles the SDK's vault-bypass) so the silent-refresh fast - # path elsewhere is unaffected. - custom_parameters=custom_parameters_for( - provider.provider_type.value, - provider.custom_parameters, - force_authentication=True, - ), + # Forward the connector's admin-configured `customParameters` + # verbatim. For a Google connector these come from the "Custom + # OAuth Parameters" field β€” typically `access_type=offline` (so + # Google issues a refresh token) plus `prompt=consent` (so a + # previously-consented user is still shown the consent screen and + # a refresh token is re-issued, rather than an access-token-only + # grant that expires in ~1h). The same map is sent on the status + # read below so AgentCore's vault key matches. + custom_parameters=custom_parameters_for(provider.custom_parameters), # No custom_state: AgentCore appears to treat its presence as a # signal to start a fresh flow, never short-circuiting to the # cached token. The frontend passes provider_id via the @@ -321,15 +316,12 @@ async def connector_status( # Match the customParameters `initiate_consent` used to grant the # token. AgentCore factors `customParameters` into whether # `get_resource_oauth2_token` short-circuits to a vaulted token, so - # a status read that omits Google's `prompt=consent` is treated as - # a fresh request and reports consent-required even when a usable - # token is vaulted. Pure read β€” `get_token_for_user` itself stays - # at `force_authentication=False`. - custom_parameters=custom_parameters_for( - provider.provider_type.value, - provider.custom_parameters, - force_authentication=True, - ), + # a status read that omits the connector's configured params (e.g. + # a Google connector's `prompt=consent`) is treated as a fresh + # request and reports consent-required even when a usable token is + # vaulted. Pure read β€” `get_token_for_user` itself stays at + # `force_authentication=False`. + custom_parameters=custom_parameters_for(provider.custom_parameters), ) except WorkloadTokenUnavailableError as err: logger.warning("Status check without workload context: %s", err) @@ -400,6 +392,22 @@ async def complete_consent( current_user.user_id, body.provider_id ) + # A fresh grant is the ONLY thing that resumes reauth-paused KB + # sync policies (docs/specs/assistant-kb-sync.md Β§7.6). Resumed + # policies come due immediately. Best-effort: a hook failure must + # never fail the consent completion itself. + try: + from apis.shared.sync_policies.service import resume_reauth_policies + + await resume_reauth_policies(current_user.user_id, body.provider_id) + except Exception as resume_err: + logger.warning( + "Failed to resume reauth-paused sync policies for user=%s provider=%s: %s", + current_user.user_id, + scrub_log(body.provider_id), + resume_err, + ) + logger.info( "Completed OAuth consent for user=%s provider=%s", current_user.user_id, diff --git a/backend/src/apis/app_api/documents/ingestion/handler.py b/backend/src/apis/app_api/documents/ingestion/handler.py index ce491da8..418a8ab6 100644 --- a/backend/src/apis/app_api/documents/ingestion/handler.py +++ b/backend/src/apis/app_api/documents/ingestion/handler.py @@ -302,5 +302,18 @@ async def update_chunking_progress(chunk_count: int) -> None: await status_manager.mark_complete(assistant_id=assistant_id, document_id=document_id, vector_store_id=vector_store_id) logger.info("Embeddings stored, processing complete") + # KB sync shrinkage cleanup: vectors are overwritten in place, so a + # re-ingested document that produced FEWER chunks strands its old tail + # ({doc_id}#{new..prev-1}). The kb-sync worker stashes the pre-sync + # chunk count; pop it (atomic) and delete the tail if we shrank. Uses + # len(chunks) post-split β€” the true vector count this run wrote (the + # stored chunkCount predates validate_and_split, same as other delete + # paths). + previous_count = await status_manager.pop_previous_chunk_count(assistant_id=assistant_id, document_id=document_id) + if previous_count and previous_count > len(chunks): + from embeddings.bedrock_embeddings import delete_vector_tail + deleted = await delete_vector_tail(document_id, len(chunks), previous_count) + logger.info(f"Shrinkage cleanup: removed {deleted} stale tail vectors for document {document_id}") + # Test s3vector dump (Optional debugging) # await test_s3vector_dump() diff --git a/backend/src/apis/app_api/documents/ingestion/status.py b/backend/src/apis/app_api/documents/ingestion/status.py index a88bdbfa..6c710487 100644 --- a/backend/src/apis/app_api/documents/ingestion/status.py +++ b/backend/src/apis/app_api/documents/ingestion/status.py @@ -284,6 +284,40 @@ async def mark_complete( vector_store_id=vector_store_id ) + async def pop_previous_chunk_count( + self, + assistant_id: str, + document_id: str + ) -> Optional[int]: + """ + Atomically read-and-clear the previousChunkCount stash. + + The KB sync worker stashes the pre-sync chunk count on the document + record before re-staging its S3 object (docs/specs/assistant-kb-sync.md + Β§6.1 shrinkage cleanup). REMOVE with ReturnValues=UPDATED_OLD makes + the pop atomic β€” a duplicate S3 event can't double-delete the tail. + + Returns: + The stashed count, or None if no stash was present (the common + case: every non-sync ingestion). + """ + if not self.table_name: + return None + try: + import boto3 + + table = boto3.resource('dynamodb').Table(self.table_name) + response = table.update_item( + Key={'PK': f'AST#{assistant_id}', 'SK': f'DOC#{document_id}'}, + UpdateExpression='REMOVE previousChunkCount', + ReturnValues='UPDATED_OLD', + ) + old_value = response.get('Attributes', {}).get('previousChunkCount') + return int(old_value) if old_value is not None else None + except Exception as e: + logger.warning(f"Failed to pop previousChunkCount for {document_id}: {e}") + return None + async def mark_failed( self, assistant_id: str, diff --git a/backend/src/apis/app_api/documents/models.py b/backend/src/apis/app_api/documents/models.py index 615db04c..eb3339ee 100644 --- a/backend/src/apis/app_api/documents/models.py +++ b/backend/src/apis/app_api/documents/models.py @@ -61,6 +61,11 @@ class Document(BaseModel): source_file_id: Optional[str] = Field(None, alias="sourceFileId", description="Provider-side opaque file identifier") source_etag: Optional[str] = Field(None, alias="sourceEtag", description="Provider-side version stamp at import time") imported_by_user_id: Optional[str] = Field(None, alias="importedByUserId", description="User whose credentials imported the file") + # Sync bookkeeping β€” populated only when the document is covered by a + # SyncPolicy (scheduled re-index from source). + content_hash: Optional[str] = Field(None, alias="contentHash", description="SHA-256 of the last-ingested raw bytes (change-detection second gate)") + last_synced_at: Optional[str] = Field(None, alias="lastSyncedAt", description="ISO 8601 timestamp of last successful sync run") + sync_policy_id: Optional[str] = Field(None, alias="syncPolicyId", description="Back-pointer to the covering SyncPolicy (UI badges)") class CreateDocumentRequest(BaseModel): @@ -99,6 +104,14 @@ class DocumentResponse(BaseModel): chunk_count: Optional[int] = Field(None, alias="chunkCount", description="Number of chunks") created_at: str = Field(..., alias="createdAt", description="ISO 8601 creation timestamp") updated_at: str = Field(..., alias="updatedAt", description="ISO 8601 update timestamp") + # Provenance-lite for the SPA: which documents were imported from an + # external source (and can therefore carry a sync policy) vs uploaded + # from the device. Null for device uploads. + source_connector_id: Optional[str] = Field(None, alias="sourceConnectorId", description="OAuth connector the file was imported from") + source_adapter_key: Optional[str] = Field(None, alias="sourceAdapterKey", description="File-source adapter that fetched the file") + source_file_id: Optional[str] = Field(None, alias="sourceFileId", description="Provider-side opaque file identifier") + sync_policy_id: Optional[str] = Field(None, alias="syncPolicyId", description="Back-pointer to the covering SyncPolicy") + last_synced_at: Optional[str] = Field(None, alias="lastSyncedAt", description="ISO 8601 timestamp of last successful sync run") class DocumentsListResponse(BaseModel): diff --git a/backend/src/apis/app_api/documents/routes.py b/backend/src/apis/app_api/documents/routes.py index 9b353c5f..18bf4432 100644 --- a/backend/src/apis/app_api/documents/routes.py +++ b/backend/src/apis/app_api/documents/routes.py @@ -354,6 +354,12 @@ async def delete_document( if not document: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Document not found: {document_id}") + # Delete any sync policy covering this document so the schedule + # cannot outlive its source (drive_file policies use the document + # id as source_ref; crawl policies are cascaded with the CrawlJob) + from apis.shared.sync_policies.service import delete_sync_policies_for_source + await delete_sync_policies_for_source(assistant_id, document_id) + # Fire-and-forget cleanup (response already sent as 204) asyncio.ensure_future( cleanup_document_resources( diff --git a/backend/src/apis/app_api/export_targets/service.py b/backend/src/apis/app_api/export_targets/service.py index ec5c0cb2..168f82b8 100644 --- a/backend/src/apis/app_api/export_targets/service.py +++ b/backend/src/apis/app_api/export_targets/service.py @@ -107,22 +107,19 @@ async def resolve_export_target_token( Returns a `TokenResult`: `access_token` is populated when the vault has a usable token, `authorization_url` when the user still needs to consent. - `custom_parameters` is built with `force_authentication=True` so it matches - the consent flow β€” AgentCore factors `customParameters` into whether - `get_resource_oauth2_token` short-circuits to a vaulted token (see the - file-source service for the full rationale). Pure read; `force_authentication` - stays False on `get_token_for_user` itself. + `custom_parameters` forwards the connector's admin-configured params + verbatim so it matches the consent flow β€” AgentCore factors + `customParameters` into whether `get_resource_oauth2_token` short-circuits + to a vaulted token (see the file-source service for the full rationale). + Pure read; `force_authentication` stays False on `get_token_for_user` + itself. """ identity = get_agentcore_identity_client() return await identity.get_token_for_user( provider_name=provider.provider_id, scopes=provider.scopes, user_id=user_id, - custom_parameters=custom_parameters_for( - provider.provider_type.value, - provider.custom_parameters, - force_authentication=True, - ), + custom_parameters=custom_parameters_for(provider.custom_parameters), ) diff --git a/backend/src/apis/app_api/file_sources/adapters/google_drive.py b/backend/src/apis/app_api/file_sources/adapters/google_drive.py index 87383747..7c2accf4 100644 --- a/backend/src/apis/app_api/file_sources/adapters/google_drive.py +++ b/backend/src/apis/app_api/file_sources/adapters/google_drive.py @@ -140,6 +140,25 @@ async def search( data = await self._get_json(access_token, "/files", params=params) return self._to_browse_result(data) + async def get_file_metadata(self, access_token: str, file_id: str) -> Dict[str, Any]: + """Fetch change-detection metadata for one file (KB sync). + + Cheap files.get β€” no content transfer. `md5Checksum` is only + populated for binary content; native editor files expose + `modifiedTime`/`version` instead. A permanently-deleted or + unshared file raises FileSourceNotFoundError (Drive returns 404 + for both, deliberately indistinguishable); an owner-trashed file + is a normal 200 with `trashed: true`. + """ + return await self._get_json( + access_token, + f"/files/{file_id}", + params={ + "fields": "id,name,mimeType,trashed,modifiedTime,version,md5Checksum,size", + "supportsAllDrives": "true", + }, + ) + async def download(self, access_token: str, file_id: str) -> DownloadedFile: meta = await self._get_json( access_token, diff --git a/backend/src/apis/app_api/file_sources/service.py b/backend/src/apis/app_api/file_sources/service.py index 01155701..76235bdd 100644 --- a/backend/src/apis/app_api/file_sources/service.py +++ b/backend/src/apis/app_api/file_sources/service.py @@ -108,13 +108,14 @@ async def resolve_file_source_token( Returns a `TokenResult`: `access_token` is populated when the vault has a usable token, `authorization_url` when the user still needs to consent. - `custom_parameters` is built with `force_authentication=True` so it matches - the consent flow. AgentCore factors `customParameters` into whether - `get_resource_oauth2_token` short-circuits to a vaulted token: connector - consent always runs through `initiate_consent`, which sends Google's - `prompt=consent` extra β€” so a retrieval call that omits it is treated as a - different request and reports consent-required even though a usable token - is vaulted. This is a pure read; `force_authentication` stays False on + `custom_parameters` forwards the connector's admin-configured params + verbatim so it matches the consent flow. AgentCore factors + `customParameters` into whether `get_resource_oauth2_token` short-circuits + to a vaulted token: connector consent runs through `initiate_consent`, + which sends the same stored map (e.g. a Google connector's `prompt=consent`) + β€” so a retrieval call must send it too, or it's treated as a different + request and reports consent-required even though a usable token is vaulted. + This is a pure read; `force_authentication` stays False on `get_token_for_user` itself. """ identity = get_agentcore_identity_client() @@ -122,11 +123,7 @@ async def resolve_file_source_token( provider_name=provider.provider_id, scopes=provider.scopes, user_id=user_id, - custom_parameters=custom_parameters_for( - provider.provider_type.value, - provider.custom_parameters, - force_authentication=True, - ), + custom_parameters=custom_parameters_for(provider.custom_parameters), ) diff --git a/backend/src/apis/app_api/kb_sync/__init__.py b/backend/src/apis/app_api/kb_sync/__init__.py new file mode 100644 index 00000000..c555517a --- /dev/null +++ b/backend/src/apis/app_api/kb_sync/__init__.py @@ -0,0 +1,14 @@ +"""KB sync Lambdas β€” scheduled re-index of assistant knowledge-base sources. + +Two Lambda entry points packaged in the kb-sync container image +(backend/Dockerfile.kb-sync), sharing one image with different CMD +overrides: + +- ``dispatcher`` β€” fired by the EventBridge rate rule; sweeps the sparse + DueSyncIndex, applies the runaway guards, and async-invokes the worker. +- ``worker`` β€” executes a single policy's sync run (PR-2 ships a stub; + the Drive-file path lands in PR-3, web re-crawl in PR-4). + +Design: docs/specs/assistant-kb-sync.md. Keep this package importable +without FastAPI β€” it deploys outside the app-api container. +""" diff --git a/backend/src/apis/app_api/kb_sync/dispatcher.py b/backend/src/apis/app_api/kb_sync/dispatcher.py new file mode 100644 index 00000000..b4ebaf90 --- /dev/null +++ b/backend/src/apis/app_api/kb_sync/dispatcher.py @@ -0,0 +1,252 @@ +"""KB sync dispatcher β€” the single initiator of scheduled sync work. + +Fired by one EventBridge rate rule (every 15 minutes). Sweeps the sparse +DueSyncIndex and, for each due policy, applies the runaway guards from +docs/specs/assistant-kb-sync.md Β§7 in order: + +1. Kill switch β€” KB_SYNC_ENABLED must be "true" or the tick no-ops. +2. Liveness β€” assistant and source must still exist; a miss + hard-deletes the policy on the spot (self-healing + backstop behind the eager delete cascades). +3. Circuit breaker β€” consecutive_not_found >= 2 or consecutive_failures + >= 5 pauses the policy instead of dispatching. +4. Inactivity β€” assistant unused for 30 days pauses the policy + (auto-resumed by the chat-path bump, PR-5). +5. In-flight skip β€” a fresh syncRunStartedAt stamp means the previous + run hasn't finished; skip without re-arming. Stale + stamps (crashed runs) are overwritten by the re-arm. +6. Re-arm BEFORE work β€” next_sync_at advances (with failure backoff) + via a conditional write, then the worker is + async-invoked. A crashed worker costs one missed + sync, never a hot loop; a double-fired tick loses + the conditional write and skips. + +Guard failures transition state (removing the policy from the sparse +index) β€” they never reschedule-and-retry. +""" + +import asyncio +import json +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +from apis.shared.sync_policies.models import SyncPolicy +from apis.shared.sync_policies.service import ( + INTERVAL_DELTAS, + delete_sync_policy, + list_due_policies, + rearm_policy, + set_policy_state, +) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +METRIC_NAMESPACE = "KBSync" + +# Backoff cap from the spec: interval * 2^failures never exceeds 30 days. +MAX_BACKOFF = timedelta(days=30) + + +def _env_int(name: str, default: int) -> int: + return int(os.environ.get(name, default)) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _timestamp(dt: datetime) -> str: + # Normalize the +00:00 offset to a single trailing Z: "…+00:00Z" (offset AND + # Z) is invalid ISO 8601 and renders as Invalid Date in strict JS engines. + return dt.isoformat().replace("+00:00", "Z") + + +def _parse_timestamp(value: str) -> Optional[datetime]: + """Parse the house timestamp format, tolerating both the current trailing + 'Z' and the legacy '+00:00Z' (offset AND Z). Always returns a UTC-aware + datetime so comparisons with :func:`_now` never mix naive and aware.""" + try: + dt = datetime.fromisoformat(value.rstrip("Z")) + except (ValueError, AttributeError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _get_assistant_item(assistant_id: str) -> Optional[Dict[str, Any]]: + """See apis.app_api.kb_sync.records for the raw-lookup rationale.""" + from apis.app_api.kb_sync import records + + return records.get_assistant_item(assistant_id) + + +def _get_source_item(assistant_id: str, source_type: str, source_ref: str) -> Optional[Dict[str, Any]]: + from apis.app_api.kb_sync import records + + return records.get_source_item(assistant_id, source_type, source_ref) + + +def _invoke_worker(payload: Dict[str, Any]) -> None: + """Async-invoke the sync worker Lambda (fire-and-forget).""" + import boto3 + + function_name = os.environ["KB_SYNC_WORKER_FUNCTION_NAME"] + boto3.client("lambda").invoke( + FunctionName=function_name, + InvocationType="Event", + Payload=json.dumps(payload).encode("utf-8"), + ) + + +def _emit_metrics(counts: Dict[str, int]) -> None: + """Best-effort CloudWatch metrics; never fails the tick.""" + import boto3 + + try: + metric_data = [ + {"MetricName": name, "Value": value, "Unit": "Count"} for name, value in counts.items() + ] + boto3.client("cloudwatch").put_metric_data(Namespace=METRIC_NAMESPACE, MetricData=metric_data) + except Exception as e: + logger.warning(f"Failed to emit KBSync metrics: {e}") + + +def _assistant_last_activity(assistant_item: Dict[str, Any]) -> Optional[datetime]: + """Reference time for the inactivity guard. + + lastUsedAt when present; otherwise the newest of createdAt / + updatedAt so pre-existing assistants aren't paused purely because the + tracking field postdates them. + """ + candidates = [assistant_item.get("lastUsedAt"), assistant_item.get("updatedAt"), assistant_item.get("createdAt")] + parsed = [ts for ts in (_parse_timestamp(c) for c in candidates if c) if ts] + return max(parsed) if parsed else None + + +def _backoff_next_sync_at(policy: SyncPolicy, now: datetime) -> str: + """Next due time: the policy interval, doubled per consecutive failure, + capped at 30 days.""" + delay = INTERVAL_DELTAS[policy.interval] * (2**policy.consecutive_failures) + return _timestamp(now + min(delay, MAX_BACKOFF)) + + +async def _dispatch_policy(policy: SyncPolicy, now: datetime, counts: Dict[str, int]) -> None: + assistant_id = policy.assistant_id + + # Guard 2 β€” liveness: assistant, then source. A miss deletes the policy. + assistant = _get_assistant_item(assistant_id) + if assistant is None: + logger.warning(f"Sync policy {policy.policy_id}: assistant {assistant_id} gone; deleting policy") + await delete_sync_policy(assistant_id, policy.policy_id) + counts["OrphansDeleted"] += 1 + return + + source = _get_source_item(assistant_id, policy.source_type, policy.source_ref) + if source is None or source.get("status") == "deleting": + logger.warning( + f"Sync policy {policy.policy_id}: source {policy.source_ref} gone or deleting; deleting policy" + ) + await delete_sync_policy(assistant_id, policy.policy_id) + counts["OrphansDeleted"] += 1 + return + + # Guard 3 β€” circuit breaker on the streak counters the worker maintains. + if policy.consecutive_not_found >= _env_int("KB_SYNC_MAX_NOT_FOUND", 2): + await set_policy_state( + assistant_id, policy.policy_id, "paused_error", state_reason="source no longer accessible" + ) + counts["PausedBreaker"] += 1 + return + if policy.consecutive_failures >= _env_int("KB_SYNC_MAX_FAILURES", 5): + await set_policy_state( + assistant_id, policy.policy_id, "paused_error", state_reason="repeated sync failures" + ) + counts["PausedBreaker"] += 1 + return + + # Guard 4 β€” inactivity: nobody is using this assistant, stop paying for it. + inactivity_days = _env_int("KB_SYNC_INACTIVITY_PAUSE_DAYS", 30) + last_activity = _assistant_last_activity(assistant) + if last_activity is not None and (now - last_activity) > timedelta(days=inactivity_days): + await set_policy_state( + assistant_id, + policy.policy_id, + "paused_inactive", + state_reason=f"assistant unused for {inactivity_days}+ days (resumes on next use)", + ) + counts["PausedInactive"] += 1 + return + + # Guard 5 β€” in-flight skip: fresh run stamp means the last run is still + # going; leave the policy due and let a later tick retry. Stale stamps + # are crashed runs β€” fall through and let the re-arm overwrite them. + if policy.sync_run_started_at: + started = _parse_timestamp(policy.sync_run_started_at) + stale_after = timedelta(hours=_env_int("KB_SYNC_RUN_STAMP_STALE_HOURS", 2)) + if started is not None and (now - started) < stale_after: + logger.info(f"Sync policy {policy.policy_id}: run in flight since {policy.sync_run_started_at}; skipping") + counts["InFlightSkipped"] += 1 + return + + # Guard 6 β€” re-arm before work; losing the conditional write means + # another dispatcher already claimed this policy. + new_next = _backoff_next_sync_at(policy, now) + won = await rearm_policy( + assistant_id, policy.policy_id, policy.next_sync_at, new_next, mark_run_started=True + ) + if not won: + counts["RearmLost"] += 1 + return + + _invoke_worker( + { + "policyId": policy.policy_id, + "assistantId": assistant_id, + "sourceType": policy.source_type, + "sourceRef": policy.source_ref, + } + ) + counts["Dispatched"] += 1 + + +async def dispatch_once() -> Dict[str, int]: + """One dispatcher tick. Returns the metric counts (also emitted).""" + counts: Dict[str, int] = { + "PoliciesDue": 0, + "Dispatched": 0, + "OrphansDeleted": 0, + "PausedBreaker": 0, + "PausedInactive": 0, + "InFlightSkipped": 0, + "RearmLost": 0, + } + + if os.environ.get("KB_SYNC_ENABLED", "false").lower() != "true": + logger.info("KB_SYNC_ENABLED is not true; dispatcher tick is a no-op") + return counts + + now = _now() + limit = _env_int("KB_SYNC_DISPATCH_LIMIT", 20) + due = await list_due_policies(now=_timestamp(now), limit=limit) + counts["PoliciesDue"] = len(due) + logger.info(f"Dispatcher tick: {len(due)} due policies (limit {limit})") + + for policy in due: + try: + await _dispatch_policy(policy, now, counts) + except Exception as e: + # One broken policy must not starve the rest of the sweep. + logger.error(f"Failed to dispatch sync policy {policy.policy_id}: {e}", exc_info=True) + + _emit_metrics(counts) + return counts + + +def lambda_handler(event, context): + """EventBridge entry point.""" + counts = asyncio.run(dispatch_once()) + return {"statusCode": 200, "body": counts} diff --git a/backend/src/apis/app_api/kb_sync/records.py b/backend/src/apis/app_api/kb_sync/records.py new file mode 100644 index 00000000..4faff18a --- /dev/null +++ b/backend/src/apis/app_api/kb_sync/records.py @@ -0,0 +1,122 @@ +"""Raw assistants-table record access for the kb-sync Lambdas. + +The dispatcher and worker read/write assistant, document, and crawl +records by their adjacency-list keys instead of importing the app-api +domain services β€” the assistants package's __init__ drags in the +embeddings stack, and keeping the kb-sync image surface minimal is a +deliberate constraint (see backend/Dockerfile.kb-sync). + +The key patterns are the stable storage contract (see +apis/shared/assistants/service.py, documents/services/document_service.py, +web_sources). The kb-sync tests create records through those REAL +services, so any schema drift breaks tests loudly rather than silently +orphaning sync work. +""" + +import logging +import os +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +def _table(): + import boto3 + + return boto3.resource("dynamodb").Table(os.environ["DYNAMODB_ASSISTANTS_TABLE_NAME"]) + + +def get_item(pk: str, sk: str) -> Optional[Dict[str, Any]]: + response = _table().get_item(Key={"PK": pk, "SK": sk}) + return response.get("Item") + + +def get_assistant_item(assistant_id: str) -> Optional[Dict[str, Any]]: + """Assistant METADATA record (existence + activity timestamps).""" + return get_item(f"AST#{assistant_id}", "METADATA") + + +def get_document_item(assistant_id: str, document_id: str) -> Optional[Dict[str, Any]]: + return get_item(f"AST#{assistant_id}", f"DOC#{document_id}") + + +def get_source_item(assistant_id: str, source_type: str, source_ref: str) -> Optional[Dict[str, Any]]: + """The source record backing a sync policy (DOC# or CRAWL#).""" + sk_prefix = "DOC#" if source_type == "drive_file" else "CRAWL#" + return get_item(f"AST#{assistant_id}", f"{sk_prefix}{source_ref}") + + +def list_document_items(assistant_id: str) -> list: + """All DOC# records under an assistant (raw items, paginated query).""" + from boto3.dynamodb.conditions import Key + + items = [] + kwargs = { + "KeyConditionExpression": Key("PK").eq(f"AST#{assistant_id}") & Key("SK").begins_with("DOC#"), + } + while True: + response = _table().query(**kwargs) + items.extend(response.get("Items", [])) + last_key = response.get("LastEvaluatedKey") + if not last_key: + return items + kwargs["ExclusiveStartKey"] = last_key + + +def set_document_miss_count(assistant_id: str, document_id: str, count: int) -> None: + """Set the re-crawl miss streak on a web page document (0 resets it).""" + _table().update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"DOC#{document_id}"}, + UpdateExpression="SET consecutiveMisses = :c", + ExpressionAttributeValues={":c": count}, + ) + + +def update_document_sync_fields( + assistant_id: str, + document_id: str, + *, + source_etag: Optional[str] = None, + content_hash: Optional[str] = None, + previous_chunk_count: Optional[int] = None, + last_synced_at: Optional[str] = None, + sync_policy_id: Optional[str] = None, +) -> None: + """Targeted update of the sync-bookkeeping fields on a document record. + + Only sets the fields passed β€” safe alongside the ingestion pipeline's + own targeted UpdateExpressions (which never touch these attributes). + """ + set_parts = [] + values: Dict[str, Any] = {} + if source_etag is not None: + set_parts.append("sourceEtag = :etag") + values[":etag"] = source_etag + if content_hash is not None: + set_parts.append("contentHash = :hash") + values[":hash"] = content_hash + if previous_chunk_count is not None: + set_parts.append("previousChunkCount = :prev") + values[":prev"] = previous_chunk_count + if last_synced_at is not None: + set_parts.append("lastSyncedAt = :synced") + values[":synced"] = last_synced_at + if sync_policy_id is not None: + set_parts.append("syncPolicyId = :spid") + values[":spid"] = sync_policy_id + if not set_parts: + return + + _table().update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"DOC#{document_id}"}, + UpdateExpression="SET " + ", ".join(set_parts), + ExpressionAttributeValues=values, + ) + + +def clear_document_sync_policy_id(assistant_id: str, document_id: str) -> None: + """Remove the SyncPolicy back-pointer when its policy is deleted.""" + _table().update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"DOC#{document_id}"}, + UpdateExpression="REMOVE syncPolicyId", + ) diff --git a/backend/src/apis/app_api/kb_sync/requirements.txt b/backend/src/apis/app_api/kb_sync/requirements.txt new file mode 100644 index 00000000..3903478c --- /dev/null +++ b/backend/src/apis/app_api/kb_sync/requirements.txt @@ -0,0 +1,10 @@ +# kb-sync Lambda image dependencies (backend/Dockerfile.kb-sync). +# Exact pins per repo policy; versions match backend/uv.lock. +boto3==1.43.9 +pydantic==2.12.5 +httpx==0.28.1 +bedrock-agentcore==1.9.1 +# Web re-crawl path (crawler markdown extraction) +beautifulsoup4==4.13.5 +trafilatura==2.0.0 +lxml==6.1.1 diff --git a/backend/src/apis/app_api/kb_sync/worker.py b/backend/src/apis/app_api/kb_sync/worker.py new file mode 100644 index 00000000..c1fdbd94 --- /dev/null +++ b/backend/src/apis/app_api/kb_sync/worker.py @@ -0,0 +1,429 @@ +"""KB sync worker β€” executes a single policy's sync run. + +Drive-file path (docs/specs/assistant-kb-sync.md Β§6.1): resolve the +policy creator's stored Google token from the AgentCore Identity vault +(no live user session β€” pure IAM + vaulted refresh token), gate on +cheap change detection, and only when bytes actually changed, stage +them to the document's existing S3 key β€” which re-runs the whole +existing ingestion pipeline (chunk β†’ embed β†’ overwrite vectors) +untouched. + +Change detection is two gates, cheapest first: +1. Drive `files.get` metadata β€” `version` compared against the stored + `sourceEtag`. Imports capture Drive's `version` as the provenance + etag (FileEntry.etag), so this stays continuous with documents + imported before sync existed. `version` over-triggers (bumps on + comments/metadata), which the second gate absorbs. +2. sha256 of the downloaded bytes vs the stored `contentHash` β€” the + authoritative gate; identical bytes never reach Docling/Titan. + +Pause/breaker outcomes (spec Β§7): +- vault says re-consent (TokenResult.requires_consent) or Google 401/403 + β†’ policy paused_reauth; only a fresh consent resumes it (PR-5 hook) +- Drive 404 (deleted OR unshared β€” indistinguishable) β†’ failed run with + not_found=True; the dispatcher pauses after 2 consecutive +- trashed=true β†’ "skipped" (grace state β€” recoverable from trash) +- anything else β†’ failed run; dispatcher backoff/breaker handles it + +Web re-crawl (source_type == "web_crawl", spec Β§6.2): re-runs the +policy's CrawlJob with its stored (already-capped) settings in the +crawler's refresh/upsert mode β€” conditional GET per page, content-hash +gating, no duplicate documents β€” then applies miss accounting: a page +absent from 2 consecutive re-crawls is deleted (transient outages +don't count; fetch failures are "seen"). The crawl job finalizes +WITHOUT its usual 30-day TTL so the policy's source record can't +auto-expire out from under the schedule. + +Payload contract with the dispatcher: + {"policyId", "assistantId", "sourceType", "sourceRef"} +""" + +import asyncio +import hashlib +import logging +import os +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from apis.shared.oauth.agentcore_identity import ( + AgentCoreIdentityClient, + WorkloadTokenUnavailableError, + custom_parameters_for, +) +from apis.shared.oauth.provider_repository import OAuthProviderRepository +from apis.shared.sync_policies.models import SyncPolicy +from apis.shared.sync_policies.service import get_sync_policy, record_sync_result, set_policy_state + +from apis.app_api.file_sources.models import ( + FileSourceAuthError, + FileSourceError, + FileSourceNotFoundError, +) +from apis.app_api.file_sources.registry import registry +from apis.app_api.kb_sync import records + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +_NATIVE_PREFIX = "application/vnd.google-apps." + + +def _now_timestamp() -> str: + # Normalize the +00:00 offset to a single trailing Z; "…+00:00Z" (offset AND + # Z) is invalid ISO 8601 and renders as Invalid Date in strict JS engines, + # leaving the last-synced time blank in the UI. + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _sha256(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def _stage_to_s3(s3_key: str, content: bytes, content_type: str) -> None: + """Overwrite the document's existing S3 object β€” the bucket's + ObjectCreated event re-runs the ingestion pipeline on the new bytes.""" + import boto3 + + bucket = os.environ["S3_ASSISTANTS_DOCUMENTS_BUCKET_NAME"] + boto3.client("s3").put_object(Bucket=bucket, Key=s3_key, Body=content, ContentType=content_type) + + +async def _resolve_access_token(provider, user_id: str) -> Optional[str]: + """Vault token for the policy creator; None means re-consent needed. + + Mirrors file_sources.service.resolve_file_source_token (which we + can't import here β€” it pulls FastAPI): same provider identity, same + scopes, and critically the same customParameters β€” they're part of + the vault key, and a mismatched map falsely reports consent-required. + """ + client = AgentCoreIdentityClient() + result = await client.get_token_for_user( + provider_name=provider.provider_id, + scopes=provider.scopes, + user_id=user_id, + custom_parameters=custom_parameters_for(provider.custom_parameters), + callback_url=os.environ.get("AGENTCORE_LOCAL_OAUTH_CALLBACK_URL"), + ) + if result.requires_consent: + return None + return result.access_token + + +async def _pause_reauth(policy: SyncPolicy, detail: str, provider_id: Optional[str] = None) -> Dict[str, Any]: + # Log the exact (workload identity, userId) the vault lookup used. The + # token is keyed by that pair, so a "requires consent" almost always means + # the token was vaulted under a DIFFERENT workload β€” e.g. a consent done + # via local dev (AGENTCORE_RUNTIME_WORKLOAD_NAME=local_dev_inference) + # can't be read by the deployed worker (platform-workload). Surfacing both + # turns an opaque reauth pause into a one-line mismatch diagnosis. + logger.warning( + f"Sync policy {policy.policy_id}: credentials need re-consent ({detail}); " + f"vault lookup used workload={os.environ.get('AGENTCORE_RUNTIME_WORKLOAD_NAME')!r} " + f"userId={policy.created_by_user_id!r} provider={provider_id!r} β€” a token vaulted " + f"under a different workload identity will read as consent-required here" + ) + # "skipped" (not "failed"): resets the breaker streaks and clears the + # run stamp β€” reauth is its own terminal state, not a failure count. + await record_sync_result(policy.assistant_id, policy.policy_id, "skipped") + await set_policy_state( + policy.assistant_id, + policy.policy_id, + "paused_reauth", + state_reason="Reconnect Google Drive to resume syncing", + ) + if provider_id: + # Marker lets the consent-completion hook find this policy without + # a table scan; resume re-verifies, so best-effort is fine here. + from apis.shared.sync_policies.service import put_reauth_marker + + await put_reauth_marker(policy.created_by_user_id, policy.assistant_id, policy.policy_id, provider_id) + return {"policyId": policy.policy_id, "result": "paused_reauth"} + + +async def _finish(policy: SyncPolicy, result: str, not_found: bool = False) -> Dict[str, Any]: + await record_sync_result(policy.assistant_id, policy.policy_id, result, not_found=not_found) + return {"policyId": policy.policy_id, "result": result} + + +async def _sync_drive_file(policy: SyncPolicy) -> Dict[str, Any]: + assistant_id = policy.assistant_id + document = records.get_document_item(assistant_id, policy.source_ref) + if document is None or document.get("status") == "deleting": + # Dispatcher's liveness check races a just-deleted doc; nothing to do. + logger.info(f"Sync policy {policy.policy_id}: document {policy.source_ref} gone; skipping") + return await _finish(policy, "skipped") + + connector_id = document.get("sourceConnectorId") + file_id = document.get("sourceFileId") + adapter_key = document.get("sourceAdapterKey") or "google-drive" + if not connector_id or not file_id: + logger.error(f"Sync policy {policy.policy_id}: document {policy.source_ref} has no import provenance") + await set_policy_state( + assistant_id, policy.policy_id, "paused_error", state_reason="document has no import source" + ) + return await _finish(policy, "skipped") + + adapter = registry.get(adapter_key) + if adapter is None: + await set_policy_state( + assistant_id, policy.policy_id, "paused_error", state_reason=f"adapter '{adapter_key}' not available" + ) + return await _finish(policy, "skipped") + + provider = await OAuthProviderRepository().get_provider(connector_id) + if provider is None: + await set_policy_state( + assistant_id, policy.policy_id, "paused_error", state_reason="connector no longer configured" + ) + return await _finish(policy, "skipped") + + try: + access_token = await _resolve_access_token(provider, policy.created_by_user_id) + except WorkloadTokenUnavailableError as e: + # Operator/config error (workload env, IAM) β€” not the user's grant. + logger.error(f"Sync policy {policy.policy_id}: workload token unavailable: {e}") + return await _finish(policy, "failed") + if access_token is None: + return await _pause_reauth(policy, "vault returned authorization URL", provider_id=connector_id) + + try: + # Gate 1 β€” metadata. Cheap; an unchanged corpus costs one + # files.get per source per interval. + metadata = await adapter.get_file_metadata(access_token, file_id) + if metadata.get("trashed"): + logger.info(f"Sync policy {policy.policy_id}: file {file_id} is trashed; grace-skipping") + return await _finish(policy, "skipped") + + new_etag = str(metadata.get("version") or metadata.get("modifiedTime") or "") + stored_etag = document.get("sourceEtag") + if stored_etag and new_etag and str(stored_etag) == new_etag: + return await _finish(policy, "unchanged") + + # Gate 2 β€” bytes. version over-triggers, so hash before re-embedding. + downloaded = await adapter.download(access_token, file_id) + content_hash = _sha256(downloaded.content) + if document.get("contentHash") and document["contentHash"] == content_hash: + # Content identical; advance the etag so gate 1 passes next run. + records.update_document_sync_fields( + assistant_id, policy.source_ref, source_etag=new_etag, last_synced_at=_now_timestamp() + ) + return await _finish(policy, "unchanged") + + # Changed: stash the old chunk count for the ingestion tail-delete + # (shrinkage cleanup) BEFORE staging, then overwrite the S3 object. + previous_chunk_count = int(document.get("chunkCount") or 0) + records.update_document_sync_fields( + assistant_id, + policy.source_ref, + source_etag=new_etag, + content_hash=content_hash, + previous_chunk_count=previous_chunk_count, + last_synced_at=_now_timestamp(), + ) + _stage_to_s3(document["s3Key"], downloaded.content, downloaded.content_type) + logger.info( + f"Sync policy {policy.policy_id}: staged {len(downloaded.content)} changed bytes for " + f"document {policy.source_ref} (prev chunks: {previous_chunk_count})" + ) + return await _finish(policy, "changed") + + except FileSourceAuthError as e: + # Provider-side revocation the vault can't see (Google rejected the + # token). Same terminal state as consent-required. + return await _pause_reauth(policy, str(e), provider_id=connector_id) + except FileSourceNotFoundError: + # Deleted OR unshared β€” Drive won't say which. Never delete our + # indexed copy on a 404; strike the counter and let the dispatcher + # pause at 2. + logger.warning(f"Sync policy {policy.policy_id}: file {file_id} not found/accessible") + return await _finish(policy, "failed", not_found=True) + except FileSourceError as e: + logger.error(f"Sync policy {policy.policy_id}: drive error: {e}") + return await _finish(policy, "failed") + + +async def _delete_missing_web_document(assistant_id: str, item: Dict[str, Any], owner_id: str) -> None: + """Soft-delete a page that vanished from 2 consecutive re-crawls, and + run the resource cleanup inline (no request context to fire-and-forget + from β€” the Lambda can afford to wait).""" + from apis.app_api.documents.services.cleanup_service import cleanup_document_resources + from apis.app_api.documents.services.document_service import soft_delete_document + + document_id = item["documentId"] + document = await soft_delete_document(assistant_id, document_id, owner_id) + if document is None: + return + await cleanup_document_resources( + document_id=document_id, + assistant_id=assistant_id, + s3_key=document.s3_key, + chunk_count=document.chunk_count, + source_connector_id=document.source_connector_id, + source_file_id=document.source_file_id, + ) + + +async def _sync_web_crawl(policy: SyncPolicy) -> Dict[str, Any]: + """Re-run the policy's crawl with its stored (already-capped) settings + in upsert/refresh mode, then apply the 2-consecutive-miss deletion rule + (docs/specs/assistant-kb-sync.md Β§6.2).""" + from apis.app_api.documents.models import DocumentProvenance + from apis.app_api.documents.services.document_service import _generate_document_id, create_document + from apis.app_api.documents.services.storage_service import _get_s3_key, _sanitize_filename + from apis.app_api.web_sources import crawler + from apis.app_api.web_sources.crawl_repository import get_crawl_job, reset_crawl_for_refresh + from apis.app_api.web_sources.url_utils import normalize_url, url_extension_hint + + assistant_id = policy.assistant_id + job = await get_crawl_job(assistant_id, policy.source_ref) + if job is None: + # Dispatcher liveness races a just-expired/deleted job. + await set_policy_state( + assistant_id, policy.policy_id, "paused_error", state_reason="crawl configuration no longer exists" + ) + return await _finish(policy, "skipped") + + root = normalize_url(job.root_url) + + # Existing web pages under this root, keyed by their normalized URL. + web_docs: Dict[str, Dict[str, Any]] = {} + for item in records.list_document_items(assistant_id): + url = str(item.get("sourceFileId") or "") + if ( + item.get("sourceConnectorId") == "web" + and url.startswith(root) + and item.get("status") != "deleting" + ): + web_docs[url] = item + + now = _now_timestamp() + + async def on_result(url: str, document_id: str, outcome: str, etag, content_hash) -> None: + if outcome == "changed": + # BEFORE the S3 overwrite: stash the previous chunk count for + # the ingestion tail-delete, alongside the new gate values. + records.update_document_sync_fields( + assistant_id, + document_id, + source_etag=etag, + content_hash=content_hash, + previous_chunk_count=int(web_docs[url].get("chunkCount") or 0), + last_synced_at=now, + ) + elif outcome == "unchanged": + records.update_document_sync_fields( + assistant_id, document_id, source_etag=etag, content_hash=content_hash, last_synced_at=now + ) + elif outcome == "created": + # The crawler's own metadata update owns etag/filename; we add + # the hash so the next refresh can gate on it. + records.update_document_sync_fields( + assistant_id, document_id, content_hash=content_hash, last_synced_at=now + ) + + refresh = crawler.RefreshState( + docs={ + url: crawler.RefreshDoc( + document_id=item["documentId"], + source_etag=item.get("sourceEtag"), + content_hash=item.get("contentHash"), + chunk_count=int(item.get("chunkCount") or 0), + ) + for url, item in web_docs.items() + }, + on_result=on_result, + ) + + # Root document: reuse if alive, else recreate (mirrors the crawl route). + root_item = web_docs.get(root) + if root_item is not None: + root_document_id = root_item["documentId"] + else: + root_document_id = _generate_document_id() + filename = f"{_sanitize_filename(url_extension_hint(root))}.html" + await create_document( + assistant_id=assistant_id, + filename=filename, + content_type="text/html", + size_bytes=0, + s3_key=_get_s3_key(assistant_id, root_document_id, filename), + document_id=root_document_id, + provenance=DocumentProvenance( + source_connector_id="web", + source_adapter_key="http", + source_file_id=root, + imported_by_user_id=policy.created_by_user_id, + ), + ) + + if not await reset_crawl_for_refresh(assistant_id=assistant_id, crawl_id=job.crawl_id): + await set_policy_state( + assistant_id, policy.policy_id, "paused_error", state_reason="crawl configuration no longer exists" + ) + return await _finish(policy, "skipped") + + # finalize_with_ttl=False: a sync-covered crawl job must never TTL-expire + # out from under its policy. budget_seconds shaves the crawler's default + # 15-minute ceiling so finalize + miss accounting fit inside this + # Lambda's own 15-minute timeout. + await crawler.run_crawl( + assistant_id=assistant_id, + crawl_id=job.crawl_id, + user_id=policy.created_by_user_id, + root_url=job.root_url, + settings=job.settings, + root_document_id=root_document_id, + refresh=refresh, + finalize_with_ttl=False, + budget_seconds=13 * 60, + ) + + # Miss accounting: pages that never survived the robots gate this run. + # Fetch failures ARE in seen_urls (transient outage β‰  gone); only a + # 2-consecutive-run absence deletes. + assistant_item = records.get_assistant_item(assistant_id) + owner_id = str(assistant_item.get("ownerId")) if assistant_item else policy.created_by_user_id + deleted = 0 + for url, item in web_docs.items(): + if url in refresh.seen_urls: + if int(item.get("consecutiveMisses") or 0) > 0: + records.set_document_miss_count(assistant_id, item["documentId"], 0) + continue + misses = int(item.get("consecutiveMisses") or 0) + 1 + if misses >= 2: + logger.info(f"Sync policy {policy.policy_id}: {url} missing {misses} consecutive runs; deleting") + await _delete_missing_web_document(assistant_id, item, owner_id) + deleted += 1 + else: + records.set_document_miss_count(assistant_id, item["documentId"], misses) + + logger.info( + f"Sync policy {policy.policy_id}: re-crawl done β€” {refresh.changed} changed, " + f"{refresh.created} new, {refresh.unchanged} unchanged, {deleted} deleted" + ) + result = "changed" if (refresh.changed or refresh.created or deleted) else "unchanged" + return await _finish(policy, result) + + +async def run_sync(payload: Dict[str, Any]) -> Dict[str, Any]: + policy_id = payload["policyId"] + assistant_id = payload["assistantId"] + + policy = await get_sync_policy(assistant_id, policy_id) + if policy is None: + logger.info(f"KB sync worker: policy {policy_id} no longer exists; dropping run") + return {"policyId": policy_id, "result": "dropped"} + + sync_fn = _sync_drive_file if policy.source_type == "drive_file" else _sync_web_crawl + try: + return await sync_fn(policy) + except Exception as e: + # Last-resort catch: always record the run so the stamp clears + # and the breaker counts, never leave a policy half-run. + logger.error(f"KB sync worker: unexpected failure on policy {policy_id}: {e}", exc_info=True) + return await _finish(policy, "failed") + + +def lambda_handler(event, context): + """Async-invoke entry point (InvocationType=Event from the dispatcher).""" + return asyncio.run(run_sync(event)) diff --git a/backend/src/apis/app_api/main.py b/backend/src/apis/app_api/main.py index 60a95eb5..b02fc7ea 100644 --- a/backend/src/apis/app_api/main.py +++ b/backend/src/apis/app_api/main.py @@ -184,9 +184,11 @@ async def lifespan(app: FastAPI): from apis.app_api.chat.proxy_routes import router as bff_chat_proxy_router from apis.app_api.mcp_apps.routes import router as mcp_apps_router from apis.app_api.memory.routes import router as memory_router +from apis.app_api.memory_spaces.routes import router as memory_spaces_router from apis.app_api.tools.routes import router as tools_router from apis.app_api.files.routes import router as files_router from apis.app_api.assistants.routes import router as assistants_router +from apis.app_api.agent_designer.routes import router as agents_router from apis.app_api.documents.routes import router as documents_router from apis.app_api.users.routes import router as users_router from apis.app_api.user_settings.routes import router as user_settings_router @@ -194,11 +196,14 @@ async def lifespan(app: FastAPI): from apis.app_api.file_sources.routes import router as file_sources_router from apis.app_api.export_targets.routes import router as export_targets_router from apis.app_api.web_sources.routes import router as web_sources_router +from apis.app_api.sync_policies.routes import router as sync_policies_router from apis.app_api.system.routes import router as system_router from apis.app_api.shares.routes import conversations_share_router, shares_router, shared_view_router from apis.app_api.voice import router as voice_router from apis.app_api.user_menu_links.routes import router as user_menu_links_router from apis.app_api.system_prompts.routes import router as system_prompts_router +from apis.app_api.runs.routes import router as runs_router +from apis.app_api.schedules.routes import router as schedules_router # Include routers app.include_router(health_router) @@ -208,6 +213,7 @@ async def lifespan(app: FastAPI): app.include_router(sessions_router) app.include_router(admin_router) app.include_router(assistants_router) +app.include_router(agents_router) # Agent Designer /agents surface; 404s while AGENTS_API_ENABLED off app.include_router(documents_router) app.include_router(users_router) app.include_router(user_settings_router) @@ -217,6 +223,7 @@ async def lifespan(app: FastAPI): app.include_router(converse_router) # Proxies to Inference API for cost accounting app.include_router(bff_chat_proxy_router) # Cookie-authenticated SSE proxy (Phase 4, dormant until SPA cutover) app.include_router(mcp_apps_router) # MCP Apps app-initiated tools/call proxy (PR #5; inert until host flag on) +app.include_router(memory_spaces_router) # Memory Spaces user surface (A2); 404s while flag off app.include_router(memory_router) # AgentCore Memory access endpoints app.include_router(tools_router) # Tool discovery and permissions app.include_router(files_router) # File upload via pre-signed URLs @@ -224,6 +231,7 @@ async def lifespan(app: FastAPI): app.include_router(file_sources_router) # File-source catalog + browse/search over connectors app.include_router(export_targets_router) # Export-target catalog + save-a-conversation to a connector app.include_router(web_sources_router) # Web-crawl ingestion: URL -> documents via BFS + S3 staging +app.include_router(sync_policies_router) # KB sync schedules: scheduled re-index of assistant sources app.include_router(system_router) # System status and first-boot endpoints app.include_router(conversations_share_router) # Share conversations endpoints app.include_router(shares_router) # Share management (update, revoke, export) @@ -231,6 +239,8 @@ async def lifespan(app: FastAPI): app.include_router(voice_router) # Cookie-authenticated WS proxy for Nova Sonic voice mode (#211) app.include_router(user_menu_links_router) # Public read of admin-managed user-menu links app.include_router(system_prompts_router) # Public read of admin-managed system prompts +app.include_router(runs_router) # Headless "Run now" + grant lifecycle (scheduled-runs PR-1; SCHEDULED_RUNS_ENABLED + RBAC gated at runtime) +app.include_router(schedules_router) # Schedule CRUD (scheduled-runs B1; inert β€” SCHEDULED_RUNS_ENABLED + RBAC gated, nothing fires yet) # Conditionally register fine-tuning routes if os.environ.get("FINE_TUNING_ENABLED", "false").lower() == "true": diff --git a/backend/src/apis/app_api/memory_spaces/__init__.py b/backend/src/apis/app_api/memory_spaces/__init__.py new file mode 100644 index 00000000..79e535f8 --- /dev/null +++ b/backend/src/apis/app_api/memory_spaces/__init__.py @@ -0,0 +1,6 @@ +"""Memory Spaces user surface (app-api) β€” `/memory/spaces/*` CRUD (A2). + +The user-facing "own your data" surface over the Memory Space primitive +(``apis.shared.memory``). Agent-consumption (tools, binding, prompt +injection) is a separate workstream and does not live here. +""" diff --git a/backend/src/apis/app_api/memory_spaces/models.py b/backend/src/apis/app_api/memory_spaces/models.py new file mode 100644 index 00000000..66cd15a4 --- /dev/null +++ b/backend/src/apis/app_api/memory_spaces/models.py @@ -0,0 +1,201 @@ +"""Request/response models for the Memory Spaces user surface (A2). + +The SPA consumes camelCase; FastAPI serializes by alias, so responses declare +camelCase aliases with ``populate_by_name`` for snake_case construction β€” +matching the assistants/schedules API models. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from apis.shared.memory.models import ( + EntryType, + MemoryEntryRef, + MemorySpace, + Role, + ShareRole, + SpaceMember, +) +from apis.shared.memory.service import ConsolidationReport +from apis.shared.memory.templates import TEMPLATES, SpaceTemplate + + +# ---- requests ---------------------------------------------------------- + + +class CreateSpaceRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + template: str = Field("blank") + + +class UpsertEntryRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + body: str = Field(..., description="The entry's markdown content") + entry_type: EntryType = Field("fact", alias="type") + description: str = Field("") + indexed: Dict[str, Any] = Field(default_factory=dict) + + +class UpdateIndexRequest(BaseModel): + content: str = Field(..., description="The MEMORY.md index text") + + +class ShareRequest(BaseModel): + email: str = Field(..., min_length=1, description="Grantee email") + permission: ShareRole = Field("viewer", description="viewer | editor") + + +class UpdateShareRequest(BaseModel): + permission: ShareRole = Field(..., description="viewer | editor") + + +# ---- responses --------------------------------------------------------- + + +class TemplateResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + template_id: str = Field(..., alias="templateId") + name: str + description: str + + @classmethod + def from_template(cls, t: SpaceTemplate) -> "TemplateResponse": + return cls(template_id=t.template_id, name=t.name, description=t.description) + + +class SpaceSummaryResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + space_id: str = Field(..., alias="spaceId") + name: str + template: str + role: Role + owner_id: str = Field(..., alias="ownerId") + created_at: str = Field("", alias="createdAt") + updated_at: str = Field("", alias="updatedAt") + + @classmethod + def from_space(cls, space: MemorySpace, role: Role) -> "SpaceSummaryResponse": + return cls( + space_id=space.space_id, + name=space.name, + template=space.template, + role=role, + owner_id=space.owner_id, + created_at=space.created_at, + updated_at=space.updated_at, + ) + + +class SpacesListResponse(BaseModel): + spaces: List[SpaceSummaryResponse] + templates: List[TemplateResponse] = Field(default_factory=list) + + +class EntryRefResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + slug: str + entry_type: EntryType = Field("fact", alias="type") + description: str = "" + size: int = 0 + updated: str = "" + updated_by: str = Field("", alias="updatedBy") + indexed: Dict[str, Any] = Field(default_factory=dict) + + @classmethod + def from_ref(cls, r: MemoryEntryRef) -> "EntryRefResponse": + return cls( + slug=r.slug, + entry_type=r.entry_type, + description=r.description, + size=r.size, + updated=r.updated, + updated_by=r.updated_by, + indexed=r.indexed, + ) + + +class SpaceDetailResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + space_id: str = Field(..., alias="spaceId") + name: str + template: str + role: Role + owner_id: str = Field(..., alias="ownerId") + created_at: str = Field("", alias="createdAt") + updated_at: str = Field("", alias="updatedAt") + index: str = Field("", description="The MEMORY.md index text") + entries: List[EntryRefResponse] = Field(default_factory=list) + + +class EntryContentResponse(BaseModel): + slug: str + content: str + + +class IndexContentResponse(BaseModel): + content: str + + +class EntriesListResponse(BaseModel): + entries: List[EntryRefResponse] + + +class MemberResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + email: str + permission: ShareRole = "viewer" + created_at: str = Field("", alias="createdAt") + + @classmethod + def from_member(cls, m: SpaceMember) -> "MemberResponse": + return cls(email=m.email, permission=m.permission, created_at=m.created_at) + + +class MembersListResponse(BaseModel): + members: List[MemberResponse] + + +class ConsolidateRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + apply_gc: bool = Field(True, alias="applyGc") + strip_dead_links: bool = Field(False, alias="stripDeadLinks") + + +class ConsolidationReportResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + space_id: str = Field(..., alias="spaceId") + entry_count: int = Field(..., alias="entryCount") + index_cap: int = Field(..., alias="indexCap") + over_cap: bool = Field(..., alias="overCap") + orphans_deleted: int = Field(0, alias="orphansDeleted") + duplicate_groups: List[List[str]] = Field(default_factory=list, alias="duplicateGroups") + dead_links: List[str] = Field(default_factory=list, alias="deadLinks") + stripped_dead_links: bool = Field(False, alias="strippedDeadLinks") + + @classmethod + def from_report(cls, r: "ConsolidationReport") -> "ConsolidationReportResponse": + return cls( + space_id=r.space_id, + entry_count=r.entry_count, + index_cap=r.index_cap, + over_cap=r.over_cap, + orphans_deleted=r.orphans_deleted, + duplicate_groups=r.duplicate_groups, + dead_links=r.dead_links, + stripped_dead_links=r.stripped_dead_links, + ) + + +def all_templates() -> List[TemplateResponse]: + return [TemplateResponse.from_template(t) for t in TEMPLATES.values()] diff --git a/backend/src/apis/app_api/memory_spaces/routes.py b/backend/src/apis/app_api/memory_spaces/routes.py new file mode 100644 index 00000000..108fe661 --- /dev/null +++ b/backend/src/apis/app_api/memory_spaces/routes.py @@ -0,0 +1,472 @@ +"""Memory Spaces user surface β€” CRUD over `/memory/spaces/*` (Workstream A2). + +The user-facing surface for the Memory Space primitive: a person creates, +lists, reads, edits, and deletes their own (and shared-with-them) spaces, +entries, and index. This is the "own your data" surface; the *agent* +consumption of a space (tools, binding, prompt injection) is a separate +workstream (Agent/Harness layer), not here. + +Gating: the router is mounted unconditionally but every route depends on +``require_memory_spaces_user`` β€” a 404 when ``MEMORY_SPACES_ENABLED`` is off +(the surface behaves as if unmounted). Auth is the standard SPA cookie +dependency (``get_current_user_from_session``) per the CLAUDE.md app-api rule. +Memory spaces are user-owned personal data (like sessions/assistants), so +there is no cohort RBAC capability gate; access to a *specific* space is the +identity-based ``resolve_permission`` check inside ``MemorySpaceService``. +""" + +from __future__ import annotations + +import json +import logging +import re +import zipfile +from datetime import datetime, timezone +from tempfile import SpooledTemporaryFile +from typing import Iterator, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import StreamingResponse + +from apis.shared.auth.dependencies import get_current_user_from_session +from apis.shared.auth.models import User +from apis.shared.feature_flags import memory_spaces_enabled +from apis.shared.memory.models import EntryType +from apis.shared.memory.service import ( + MemorySpaceConcurrencyError, + MemorySpaceError, + MemorySpaceExport, + MemorySpaceNotFoundError, + MemorySpacePermissionError, + MemorySpaceService, +) +from apis.shared.memory.store import MemorySpaceStoreError + +from apis.app_api.memory_spaces.models import ( + ConsolidateRequest, + ConsolidationReportResponse, + CreateSpaceRequest, + EntriesListResponse, + EntryContentResponse, + EntryRefResponse, + IndexContentResponse, + MemberResponse, + MembersListResponse, + ShareRequest, + SpaceDetailResponse, + SpaceSummaryResponse, + SpacesListResponse, + UpdateIndexRequest, + UpdateShareRequest, + UpsertEntryRequest, + all_templates, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/memory/spaces", tags=["memory-spaces"]) + +_service: Optional[MemorySpaceService] = None + + +def _svc() -> MemorySpaceService: + global _service + if _service is None: + _service = MemorySpaceService() + return _service + + +async def require_memory_spaces_user( + user: User = Depends(get_current_user_from_session), +) -> User: + """Cookie auth + the environment kill switch. + + 404 when ``MEMORY_SPACES_ENABLED`` is off, so the surface behaves as if + unmounted (mirrors the schedules/runs pattern). + """ + if not memory_spaces_enabled(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + return user + + +def _translate(e: Exception) -> HTTPException: + """Map a service error to an HTTP status.""" + if isinstance(e, MemorySpaceNotFoundError): + return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + if isinstance(e, MemorySpacePermissionError): + return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + if isinstance(e, MemorySpaceConcurrencyError): + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + if isinstance(e, MemorySpaceError): + return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + raise e + + +# ---- export (Β§9) ------------------------------------------------------- + +# Spill the zip to disk beyond this size so a large space never pins app-api +# memory (the entry count is bounded by the consolidation cap, so this is a +# ceiling, not the common case). +_ZIP_SPOOL_MAX_BYTES = 8 * 1024 * 1024 +_UNSAFE_PATH_CHARS = re.compile(r"[^A-Za-z0-9._-]+") + + +def _safe_component(value: str, fallback: str) -> str: + """Reduce a user string to one safe archive path segment. + + Collapses separators / ``..`` / other unsafe characters so a hostile slug + or space name cannot escape its folder in the zip (zip-slip). Empty results + fall back to ``fallback``. + """ + cleaned = _UNSAFE_PATH_CHARS.sub("-", (value or "").strip()).strip("-._") + return cleaned or fallback + + +def _export_metadata_json(export: MemorySpaceExport) -> str: + """Serialize the space-level state the markdown files don't carry (Β§9).""" + space = export.space + meta = { + "spaceId": space.space_id, + "name": space.name, + "template": space.template, + "createdAt": space.created_at, + "updatedAt": space.updated_at, + "exportedAt": datetime.now(timezone.utc).isoformat(), + "owner": {"userId": space.owner_id, "email": space.owner_email}, + "members": [ + { + "email": m.email, + "permission": m.permission, + "createdAt": m.created_at, + } + for m in export.members + ], + "entryCount": len(export.files), + } + return json.dumps(meta, indent=2, ensure_ascii=False) + + +def _build_export_zip(root: str, export: MemorySpaceExport) -> SpooledTemporaryFile: + """Write the space's corpus into a spooled zip mirroring the S3 layout.""" + spool: SpooledTemporaryFile = SpooledTemporaryFile( + max_size=_ZIP_SPOOL_MAX_BYTES, mode="w+b" + ) + with zipfile.ZipFile(spool, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr(f"{root}/MEMORY.md", export.index_text) + for ref, body in export.files: + slug = _safe_component(ref.slug, "entry") + entry_type = _safe_component(ref.entry_type, "fact") + zf.writestr(f"{root}/entries/{entry_type}/{slug}.md", body) + zf.writestr(f"{root}/metadata.json", _export_metadata_json(export)) + spool.seek(0) + return spool + + +def _stream_and_close(spool: SpooledTemporaryFile) -> Iterator[bytes]: + """Yield the spooled zip in chunks, closing (and unlinking) it when done.""" + try: + while True: + chunk = spool.read(65536) + if not chunk: + break + yield chunk + finally: + spool.close() + + +# ---- spaces ------------------------------------------------------------ + + +@router.get("", response_model=SpacesListResponse) +def list_spaces(user: User = Depends(require_memory_spaces_user)) -> SpacesListResponse: + svc = _svc() + summaries = [ + SpaceSummaryResponse.from_space(space, role) + for space, role in svc.list_spaces_for_user(user.user_id, user.email) + ] + return SpacesListResponse(spaces=summaries, templates=all_templates()) + + +@router.post("", response_model=SpaceSummaryResponse, status_code=status.HTTP_201_CREATED) +def create_space( + request: CreateSpaceRequest, + user: User = Depends(require_memory_spaces_user), +) -> SpaceSummaryResponse: + try: + space = _svc().create_space( + owner_id=user.user_id, + owner_email=user.email, + name=request.name, + template=request.template, + ) + except MemorySpaceError as e: + raise _translate(e) + return SpaceSummaryResponse.from_space(space, "owner") + + +@router.get("/{space_id}", response_model=SpaceDetailResponse) +def get_space( + space_id: str, user: User = Depends(require_memory_spaces_user) +) -> SpaceDetailResponse: + svc = _svc() + try: + space, role = svc.resolve_permission(space_id, user.user_id, user.email) + if space is None: + raise MemorySpaceNotFoundError(f"Memory space '{space_id}' not found") + if role is None: + raise MemorySpacePermissionError( + f"'viewer' access required on memory space '{space_id}'" + ) + index_text = svc.read_index(space_id, user.user_id, user.email) + entries = svc.list_entries(space_id, user.user_id, user.email) + except MemorySpaceError as e: + raise _translate(e) + return SpaceDetailResponse( + space_id=space.space_id, + name=space.name, + template=space.template, + role=role, + owner_id=space.owner_id, + created_at=space.created_at, + updated_at=space.updated_at, + index=index_text, + entries=[EntryRefResponse.from_ref(r) for r in entries], + ) + + +@router.get("/{space_id}/export") +def export_space( + space_id: str, user: User = Depends(require_memory_spaces_user) +) -> StreamingResponse: + """Download the whole space as a `.zip` of its raw markdown (viewer+, Β§9). + + The loss-free "own your data" export: the ``MEMORY.md`` index, every entry + with frontmatter intact under ``entries//``, and a small + ``metadata.json``. Any member who can read the space may export it; the + owner exports the full space. Streamed from a spooled buffer so a large + space never pins app-api memory. + """ + svc = _svc() + try: + export = svc.export_space(space_id, user.user_id, user.email) + except MemorySpaceError as e: + raise _translate(e) + except MemorySpaceStoreError as e: + logger.error("memory-spaces: export failed for space=%s: %s", space_id, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="failed to read memory space contents for export", + ) + + root = _safe_component(export.space.name, export.space.space_id) + spool = _build_export_zip(root, export) + return StreamingResponse( + _stream_and_close(spool), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{root}.zip"'}, + ) + + +@router.post("/{space_id}/consolidate", response_model=ConsolidationReportResponse) +def consolidate_space( + space_id: str, + request: ConsolidateRequest | None = None, + user: User = Depends(require_memory_spaces_user), +) -> ConsolidationReportResponse: + """Run a deterministic consolidation (health) pass on a space (editor+, A6). + + Auto-fixes storage hygiene (orphaned-object GC) and reports issues that + need judgment (duplicate content, dead ``[[slug]]`` links, over-cap). Never + merges or evicts entries. ``stripDeadLinks`` opts into unlinking dead + wikilinks from MEMORY.md. + """ + req = request or ConsolidateRequest() + try: + report = _svc().consolidate( + space_id, + user.user_id, + user.email, + apply_gc=req.apply_gc, + strip_dead_links=req.strip_dead_links, + ) + except MemorySpaceError as e: + raise _translate(e) + except MemorySpaceStoreError as e: + logger.error("memory-spaces: consolidate failed for space=%s: %s", space_id, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="failed to consolidate memory space storage", + ) + return ConsolidationReportResponse.from_report(report) + + +@router.delete("/{space_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_or_leave_space( + space_id: str, user: User = Depends(require_memory_spaces_user) +) -> None: + """Owner deletes the whole space; a member drops their own grant (leave).""" + svc = _svc() + try: + _, role = svc.resolve_permission(space_id, user.user_id, user.email) + if role == "owner": + svc.delete_space(space_id, user.user_id, user.email) + else: + svc.leave_space(space_id, user.user_id, user.email) + except MemorySpaceError as e: + raise _translate(e) + + +# ---- sharing (A4) ------------------------------------------------------ + + +@router.get("/{space_id}/shares", response_model=MembersListResponse) +def list_shares( + space_id: str, user: User = Depends(require_memory_spaces_user) +) -> MembersListResponse: + """List a space's shared grants (editor+; the owner is implicit).""" + try: + members = _svc().list_members(space_id, user.user_id, user.email) + except MemorySpaceError as e: + raise _translate(e) + return MembersListResponse( + members=[MemberResponse.from_member(m) for m in members] + ) + + +@router.post( + "/{space_id}/shares", + response_model=MemberResponse, + status_code=status.HTTP_201_CREATED, +) +def add_share( + space_id: str, + request: ShareRequest, + user: User = Depends(require_memory_spaces_user), +) -> MemberResponse: + """Grant a user viewer/editor access to the space (owner only).""" + try: + member = _svc().share( + space_id, user.user_id, user.email, request.email, request.permission + ) + except MemorySpaceError as e: + raise _translate(e) + return MemberResponse.from_member(member) + + +@router.patch("/{space_id}/shares/{email}", response_model=MemberResponse) +def update_share( + space_id: str, + email: str, + request: UpdateShareRequest, + user: User = Depends(require_memory_spaces_user), +) -> MemberResponse: + """Change an existing grant's role (owner only).""" + try: + member = _svc().update_share( + space_id, user.user_id, user.email, email, request.permission + ) + except MemorySpaceError as e: + raise _translate(e) + return MemberResponse.from_member(member) + + +@router.delete( + "/{space_id}/shares/{email}", status_code=status.HTTP_204_NO_CONTENT +) +def remove_share( + space_id: str, email: str, user: User = Depends(require_memory_spaces_user) +) -> None: + """Revoke a user's grant (owner only). Idempotent.""" + try: + _svc().revoke(space_id, user.user_id, user.email, email) + except MemorySpaceError as e: + raise _translate(e) + + +# ---- index (MEMORY.md) ------------------------------------------------- + + +@router.get("/{space_id}/index", response_model=IndexContentResponse) +def read_index( + space_id: str, user: User = Depends(require_memory_spaces_user) +) -> IndexContentResponse: + try: + text = _svc().read_index(space_id, user.user_id, user.email) + except MemorySpaceError as e: + raise _translate(e) + return IndexContentResponse(content=text) + + +@router.put("/{space_id}/index", response_model=IndexContentResponse) +def update_index( + space_id: str, + request: UpdateIndexRequest, + user: User = Depends(require_memory_spaces_user), +) -> IndexContentResponse: + try: + _svc().update_index(space_id, user.user_id, user.email, request.content) + except MemorySpaceError as e: + raise _translate(e) + return IndexContentResponse(content=request.content) + + +# ---- entries ----------------------------------------------------------- + + +@router.get("/{space_id}/entries", response_model=EntriesListResponse) +def list_entries( + space_id: str, + user: User = Depends(require_memory_spaces_user), + entry_type: Optional[EntryType] = Query(None, alias="type"), +) -> EntriesListResponse: + try: + entries = _svc().list_entries( + space_id, user.user_id, user.email, entry_type=entry_type + ) + except MemorySpaceError as e: + raise _translate(e) + return EntriesListResponse(entries=[EntryRefResponse.from_ref(r) for r in entries]) + + +@router.get("/{space_id}/entries/{slug}", response_model=EntryContentResponse) +def read_entry( + space_id: str, slug: str, user: User = Depends(require_memory_spaces_user) +) -> EntryContentResponse: + try: + content = _svc().read_entry(space_id, user.user_id, user.email, slug) + except MemorySpaceError as e: + raise _translate(e) + return EntryContentResponse(slug=slug, content=content) + + +@router.put("/{space_id}/entries/{slug}", response_model=EntryRefResponse) +def upsert_entry( + space_id: str, + slug: str, + request: UpsertEntryRequest, + user: User = Depends(require_memory_spaces_user), +) -> EntryRefResponse: + try: + ref = _svc().write_entry( + space_id, + user.user_id, + user.email, + slug, + request.body, + entry_type=request.entry_type, + description=request.description, + indexed=request.indexed, + ) + except MemorySpaceError as e: + raise _translate(e) + return EntryRefResponse.from_ref(ref) + + +@router.delete("/{space_id}/entries/{slug}", status_code=status.HTTP_204_NO_CONTENT) +def delete_entry( + space_id: str, slug: str, user: User = Depends(require_memory_spaces_user) +) -> None: + try: + _svc().delete_entry(space_id, user.user_id, user.email, slug) + except MemorySpaceError as e: + raise _translate(e) diff --git a/backend/src/apis/app_api/runs/__init__.py b/backend/src/apis/app_api/runs/__init__.py new file mode 100644 index 00000000..194d15f3 --- /dev/null +++ b/backend/src/apis/app_api/runs/__init__.py @@ -0,0 +1 @@ +"""User-facing headless-run surface ("Run now" + headless-grant lifecycle).""" diff --git a/backend/src/apis/app_api/runs/routes.py b/backend/src/apis/app_api/runs/routes.py new file mode 100644 index 00000000..5eb54265 --- /dev/null +++ b/backend/src/apis/app_api/runs/routes.py @@ -0,0 +1,344 @@ +""""Run now" β€” the attended validation surface for the headless harness. + +``POST /runs/now`` executes one agent turn *through the exact machinery a +scheduled run will use* (headless grant β†’ per-owner Cognito mint β†’ runtime +``/invocations`` β†’ server-side SSE drain β†’ governance floor β†’ session +materialization) while the user is present to watch it. It deliberately +does NOT shortcut through the caller's live access token: the point of the +surface is to validate the unattended path end-to-end (scheduled-runs PR-1, +docs/specs/scheduled-agent-runs.md Β§7). + +Gating β€” two independent controls (spec Β§6): + +* ``SCHEDULED_RUNS_ENABLED`` β€” per-environment kill switch (default on). + Off β†’ every route here 404s, as if unmounted. +* ``scheduled-runs`` RBAC capability β€” *who* may use the surface. Granted + to the beta cohort's AppRole; missing β†’ 403. GA = grant to ``default``. + +Auth is the standard SPA cookie dependency (``get_current_user_from_session``) +per the CLAUDE.md app-api rule. The headless grant is **created-on-enable**: +each attended ``POST /runs/now`` pins the caller's live session refresh +token into their grant record (renewing the 30-day login-recency window); +``GET/DELETE /runs/grant`` expose status and revocation. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field + +from apis.shared.auth.dependencies import get_current_user_from_session +from apis.shared.auth.models import User +from apis.shared.feature_flags import scheduled_runs_enabled +from apis.shared.harness import ( + CognitoRefreshBearerAuth, + HeadlessAuthError, + HeadlessGrant, + HeadlessGrantService, + RunResult, + run_agent_headless, +) +from apis.shared.rbac.capabilities import ( + SCHEDULED_RUNS_CAPABILITY, + user_has_capability, +) +from apis.shared.rbac.service import get_app_role_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/runs", tags=["runs"]) + +_MAX_PROMPT_CHARS = 20_000 + +_grant_service: Optional[HeadlessGrantService] = None + + +def get_headless_grant_service() -> HeadlessGrantService: + """Lazy module singleton; tests monkeypatch this factory.""" + global _grant_service + if _grant_service is None: + _grant_service = HeadlessGrantService() + return _grant_service + + +async def require_scheduled_runs_user( + user: User = Depends(get_current_user_from_session), +) -> User: + """Cookie auth + kill switch + cohort capability, in that order. + + 404 when the environment kill switch is off (the surface behaves as if + unmounted β€” runtime-checked so tests and env flips need no module + reload), 403 when the authenticated caller lacks the ``scheduled-runs`` + capability. + """ + if not scheduled_runs_enabled(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + if not await user_has_capability(user, SCHEDULED_RUNS_CAPABILITY): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to scheduled runs.", + ) + return user + + +# ─── API models ──────────────────────────────────────────────────────────── + + +class RunNowRequest(BaseModel): + """Run-config mirrors ``InvocationRequest`` β€” no new config type.""" + + prompt: str = Field(..., min_length=1, max_length=_MAX_PROMPT_CHARS) + title: Optional[str] = Field(None, max_length=200) + model_id: Optional[str] = Field(None, alias="modelId") + rag_assistant_id: Optional[str] = Field(None, alias="ragAssistantId") + # None = the user's defaults (all RBAC-allowed tools), exactly as an + # attended chat turn resolves them β€” see run_agent_headless docstring. + enabled_tools: Optional[List[str]] = Field(None, alias="enabledTools") + agent_type: Optional[str] = Field(None, alias="agentType") + + model_config = {"populate_by_name": True} + + +class ToolTraceEntryResponse(BaseModel): + tool_use_id: str = Field(..., alias="toolUseId") + name: str + input: Dict[str, Any] = Field(default_factory=dict) + result_preview: Optional[str] = Field(None, alias="resultPreview") + is_error: bool = Field(False, alias="isError") + + model_config = {"populate_by_name": True} + + +class OAuthConsentRequiredResponse(BaseModel): + provider_id: str = Field(..., alias="providerId") + authorization_url: str = Field(..., alias="authorizationUrl") + + model_config = {"populate_by_name": True} + + +class RunNowResponse(BaseModel): + run_id: str = Field(..., alias="runId") + session_id: str = Field(..., alias="sessionId") + status: str + final_message: str = Field("", alias="finalMessage") + stop_reason: Optional[str] = Field(None, alias="stopReason") + error: Optional[str] = None + title: Optional[str] = None + tool_trace: List[ToolTraceEntryResponse] = Field( + default_factory=list, alias="toolTrace" + ) + usage: Dict[str, Any] = Field(default_factory=dict) + oauth_required: List[OAuthConsentRequiredResponse] = Field( + default_factory=list, alias="oauthRequired" + ) + started_at: str = Field("", alias="startedAt") + finished_at: str = Field("", alias="finishedAt") + + model_config = {"populate_by_name": True} + + @classmethod + def from_run_result(cls, result: RunResult) -> "RunNowResponse": + return cls( + run_id=result.run_id, + session_id=result.session_id, + status=result.status, + final_message=result.final_message, + stop_reason=result.stop_reason, + error=result.error, + title=result.title, + tool_trace=[ + ToolTraceEntryResponse( + tool_use_id=t.tool_use_id, + name=t.name, + input=t.input, + result_preview=t.result_preview, + is_error=t.is_error, + ) + for t in result.tool_trace + ], + usage=result.usage, + oauth_required=[ + OAuthConsentRequiredResponse( + provider_id=o.provider_id, + authorization_url=o.authorization_url, + ) + for o in result.oauth_required + ], + started_at=result.started_at, + finished_at=result.finished_at, + ) + + +class GrantStatusResponse(BaseModel): + enabled: bool + grant_id: Optional[str] = Field(None, alias="grantId") + created_at: Optional[int] = Field(None, alias="createdAt") + updated_at: Optional[int] = Field(None, alias="updatedAt") + expires_at: Optional[int] = Field(None, alias="expiresAt") + last_used_at: Optional[int] = Field(None, alias="lastUsedAt") + + model_config = {"populate_by_name": True} + + @classmethod + def from_grant(cls, grant: Optional[HeadlessGrant]) -> "GrantStatusResponse": + if grant is None: + return cls(enabled=False) + return cls( + enabled=True, + grant_id=grant.grant_id, + created_at=grant.created_at, + updated_at=grant.updated_at, + expires_at=grant.ttl, + last_used_at=grant.last_used_at, + ) + + +class GrantRevokeResponse(BaseModel): + revoked: bool + + +class GrantEnableResponse(GrantStatusResponse): + """Identical shape to the status response. A distinct name documents + intent at the call site (POST = mutate/enable, GET = read status).""" + + +# ─── Routes ───────────────────────────────────────────────────────────────── + + +async def _resolve_grant(request: Request, user: User) -> HeadlessGrant: + """Create-on-enable: pin the live session's token, else use an existing grant. + + The BFF middleware attaches the caller's ``SessionRecord`` to + ``request.state.bff_session``; when present, the grant is created or + renewed from it (that session's ``created_at`` anchors the 30-day + login-recency window β€” see ``apis.shared.harness.grants``). Without a + session record (e.g. local SKIP_AUTH dev) an already-active grant still + works; having neither is a 409, not a 401 β€” the caller *is* + authenticated, they just have no credential the platform may act + headlessly with. + """ + grants = get_headless_grant_service() + session_record = getattr(request.state, "bff_session", None) + if session_record is not None: + return await grants.enable( + user_id=user.user_id, + username=session_record.username, + refresh_token=session_record.cognito_refresh_token, + token_issued_at=session_record.created_at, + ) + grant = await grants.get_active_grant(user.user_id) + if grant is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "No headless grant exists for this account and the current " + "request carries no session to create one from." + ), + ) + return grant + + +@router.post("/now", response_model=RunNowResponse, response_model_by_alias=True) +async def run_now( + body: RunNowRequest, + request: Request, + user: User = Depends(require_scheduled_runs_user), +) -> RunNowResponse: + """Execute one agent turn as the caller through the headless harness. + + Synchronous from the caller's perspective: the response is the full + ``RunResult`` once the turn drains (bounded by the harness's 300s + budget, matching the chat proxy). The result also lands as a session in + the caller's conversation list, so a closed tab loses nothing. + """ + await _resolve_grant(request, user) + + # The request body is attacker-controlled within the caller's own session, + # and the downstream tool filter performs no RBAC check β€” so narrow the + # requested tools to the caller's actual grant before handing them to the + # headless harness. ``None`` keeps "resolve to the user's defaults". + enabled_tools = body.enabled_tools + if enabled_tools is not None: + allowed = await get_app_role_service().filter_requested_tools(user, enabled_tools) + if len(allowed) != len(enabled_tools): + logger.warning( + "Run-now dropped %d requested tool(s) outside user %s's RBAC grant", + len(enabled_tools) - len(allowed), + user.user_id, + ) + enabled_tools = allowed + + try: + result = await run_agent_headless( + user_id=user.user_id, + prompt=body.prompt, + auth=CognitoRefreshBearerAuth(grants=get_headless_grant_service()), + title=body.title, + model_id=body.model_id, + rag_assistant_id=body.rag_assistant_id, + enabled_tools=enabled_tools, + agent_type=body.agent_type, + trigger="run_now", + ) + except HeadlessAuthError as exc: + # The grant exists but could not mint (Cognito refused β€” token + # expired or revoked upstream). 409, not 401: a 401 here would + # bounce the SPA through the login redirect even though the + # *session* is fine. + logger.warning("Run-now mint failed for user %s: %s", user.user_id, exc) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Headless credential could not be minted; log in again to renew it.", + ) + + logger.info( + "Run-now %s for user %s finished status=%s session=%s", + result.run_id, + user.user_id, + result.status, + result.session_id, + ) + return RunNowResponse.from_run_result(result) + + +@router.post( + "/grant", response_model=GrantEnableResponse, response_model_by_alias=True +) +async def enable_grant( + request: Request, + user: User = Depends(require_scheduled_runs_user), +) -> GrantEnableResponse: + """Create or refresh the caller's headless grant from their live session. + + Lets a user turn on scheduled runs directly (e.g. from the schedules + page) without first having to exercise "Run now". Shares the exact + create-on-enable logic ``run_now`` uses via ``_resolve_grant`` β€” the + same 409 applies if the caller has neither an existing grant nor a + live session to pin one from (e.g. a stale API-key-only context). + """ + grant = await _resolve_grant(request, user) + return GrantEnableResponse.from_grant(grant) + + +@router.get( + "/grant", response_model=GrantStatusResponse, response_model_by_alias=True +) +async def get_grant_status( + user: User = Depends(require_scheduled_runs_user), +) -> GrantStatusResponse: + """The caller's headless-grant status (never the token itself).""" + grant = await get_headless_grant_service().get_active_grant(user.user_id) + return GrantStatusResponse.from_grant(grant) + + +@router.delete("/grant", response_model=GrantRevokeResponse) +async def revoke_grant( + user: User = Depends(require_scheduled_runs_user), +) -> GrantRevokeResponse: + """Revoke the caller's headless grant (total revocation β€” the stored + credential is deleted in the same write).""" + revoked = await get_headless_grant_service().revoke(user.user_id) + return GrantRevokeResponse(revoked=revoked) diff --git a/backend/src/apis/app_api/schedules/__init__.py b/backend/src/apis/app_api/schedules/__init__.py new file mode 100644 index 00000000..7edf9c10 --- /dev/null +++ b/backend/src/apis/app_api/schedules/__init__.py @@ -0,0 +1 @@ +"""Schedule management routes (scheduled agent runs β€” B1, inert CRUD).""" diff --git a/backend/src/apis/app_api/schedules/models.py b/backend/src/apis/app_api/schedules/models.py new file mode 100644 index 00000000..caf19e0b --- /dev/null +++ b/backend/src/apis/app_api/schedules/models.py @@ -0,0 +1,151 @@ +"""Schedule API request/response models.""" + +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from apis.shared.scheduled_prompts.models import ( + IntervalUnit, + ScheduleCadence, + ScheduledPrompt, + ScheduledPromptState, +) +from apis.shared.scheduled_prompts.service import MIN_INTERVAL_MINUTES, interval_to_minutes + +_MAX_PROMPT_CHARS = 20_000 + + +class CreateScheduleRequest(BaseModel): + """Request body for creating a scheduled prompt.""" + + model_config = ConfigDict(populate_by_name=True) + + label: str = Field(..., min_length=1, max_length=200) + prompt_text: str = Field(..., alias="promptText", min_length=1, max_length=_MAX_PROMPT_CHARS) + cadence: ScheduleCadence + hour_local: int = Field(..., alias="hourLocal", ge=0, le=23) + weekday: Optional[int] = Field(None, ge=0, le=6) + interval_value: Optional[int] = Field(None, alias="intervalValue", ge=1) + interval_unit: Optional[IntervalUnit] = Field(None, alias="intervalUnit") + timezone: str = Field(..., min_length=1, max_length=64) + assistant_id: Optional[str] = Field(None, alias="assistantId") + # None = snapshot "all RBAC-allowed at creation" (resolved by the route, + # exactly as an attended chat turn resolves defaults); an explicit list + # snapshots that subset instead. + enabled_tools: Optional[List[str]] = Field(None, alias="enabledTools") + deliver_email: bool = Field(False, alias="deliverEmail") + + @model_validator(mode="after") + def _validate_cadence_fields(self) -> "CreateScheduleRequest": + if self.cadence == "weekly" and self.weekday is None: + raise ValueError("weekday is required when cadence is 'weekly'") + if self.cadence == "interval": + if self.interval_value is None or self.interval_unit is None: + raise ValueError("intervalValue and intervalUnit are required when cadence is 'interval'") + if interval_to_minutes(self.interval_value, self.interval_unit) < MIN_INTERVAL_MINUTES: + raise ValueError(f"interval must be at least {MIN_INTERVAL_MINUTES} minutes") + return self + + +class UpdateScheduleRequest(BaseModel): + """Request body for editing a schedule or pausing/resuming it. + + ``state`` accepts only the user-owned transitions: "paused" and + "active". A schedule in "paused_error" can also be resumed here β€” the + resume is an explicit user decision to try again. + """ + + model_config = ConfigDict(populate_by_name=True) + + label: Optional[str] = Field(None, min_length=1, max_length=200) + prompt_text: Optional[str] = Field(None, alias="promptText", min_length=1, max_length=_MAX_PROMPT_CHARS) + cadence: Optional[ScheduleCadence] = None + hour_local: Optional[int] = Field(None, alias="hourLocal", ge=0, le=23) + weekday: Optional[int] = Field(None, ge=0, le=6) + interval_value: Optional[int] = Field(None, alias="intervalValue", ge=1) + interval_unit: Optional[IntervalUnit] = Field(None, alias="intervalUnit") + timezone: Optional[str] = Field(None, min_length=1, max_length=64) + assistant_id: Optional[str] = Field(None, alias="assistantId") + enabled_tools: Optional[List[str]] = Field(None, alias="enabledTools") + deliver_email: Optional[bool] = Field(None, alias="deliverEmail") + state: Optional[Literal["active", "paused"]] = None + # A bare null cannot express "clear" for assistant_id / enabled_tools (the + # service reads null as "leave unchanged"), so clearing is an explicit + # intent. clear_tools re-snapshots the caller's current RBAC-allowed tools + # (mirrors creation), clear_assistant reverts to the default agent. + clear_assistant: bool = Field(False, alias="clearAssistant") + clear_tools: bool = Field(False, alias="clearTools") + + @model_validator(mode="after") + def _clear_excludes_value(self) -> "UpdateScheduleRequest": + if self.clear_assistant and self.assistant_id is not None: + raise ValueError("clearAssistant cannot be combined with assistantId") + if self.clear_tools and self.enabled_tools is not None: + raise ValueError("clearTools cannot be combined with enabledTools") + return self + + +class ScheduledPromptResponse(BaseModel): + """Public view of a scheduled prompt.""" + + model_config = ConfigDict(populate_by_name=True) + + schedule_id: str = Field(..., alias="scheduleId") + assistant_id: Optional[str] = Field(None, alias="assistantId") + label: str + prompt_text: str = Field(..., alias="promptText") + cadence: ScheduleCadence + hour_local: int = Field(..., alias="hourLocal") + weekday: Optional[int] = None + interval_value: Optional[int] = Field(None, alias="intervalValue") + interval_unit: Optional[IntervalUnit] = Field(None, alias="intervalUnit") + timezone: str + state: ScheduledPromptState + state_reason: Optional[str] = Field(None, alias="stateReason") + next_run_at: Optional[str] = Field(None, alias="nextRunAt") + last_run_at: Optional[str] = Field(None, alias="lastRunAt") + last_run_status: Optional[str] = Field(None, alias="lastRunStatus") + last_run_session_id: Optional[str] = Field(None, alias="lastRunSessionId") + last_error: Optional[str] = Field(None, alias="lastError") + runs_today: int = Field(0, alias="runsToday") + max_runs_per_day: int = Field(24, alias="maxRunsPerDay") + enabled_tools: Optional[List[str]] = Field(None, alias="enabledTools") + deliver_email: bool = Field(False, alias="deliverEmail") + created_at: str = Field(..., alias="createdAt") + updated_at: str = Field(..., alias="updatedAt") + + @classmethod + def from_schedule(cls, schedule: ScheduledPrompt) -> "ScheduledPromptResponse": + return cls( + schedule_id=schedule.schedule_id, + assistant_id=schedule.assistant_id, + label=schedule.label, + prompt_text=schedule.prompt_text, + cadence=schedule.cadence, + hour_local=schedule.hour_local, + weekday=schedule.weekday, + interval_value=schedule.interval_value, + interval_unit=schedule.interval_unit, + timezone=schedule.timezone, + state=schedule.state, + state_reason=schedule.state_reason, + next_run_at=schedule.next_run_at, + last_run_at=schedule.last_run_at, + last_run_status=schedule.last_run_status, + last_run_session_id=schedule.last_run_session_id, + last_error=schedule.last_error, + runs_today=schedule.runs_today, + max_runs_per_day=schedule.max_runs_per_day, + enabled_tools=schedule.enabled_tools, + deliver_email=schedule.deliver_email, + created_at=schedule.created_at, + updated_at=schedule.updated_at, + ) + + +class ScheduledPromptsListResponse(BaseModel): + """Response for listing the caller's scheduled prompts.""" + + model_config = ConfigDict(populate_by_name=True) + + schedules: List[ScheduledPromptResponse] = Field(default_factory=list) diff --git a/backend/src/apis/app_api/schedules/routes.py b/backend/src/apis/app_api/schedules/routes.py new file mode 100644 index 00000000..b403b8b5 --- /dev/null +++ b/backend/src/apis/app_api/schedules/routes.py @@ -0,0 +1,297 @@ +"""Schedule routes β€” CRUD for scheduled agent runs (`/schedules/*`). + +**B1 is deliberately inert**: schedules can be created, listed, edited, +paused/resumed, and deleted here, but nothing fires yet β€” the +dispatcher/worker that reads ``DueScheduleIndex`` and calls +``run_agent_headless`` is B2 (docs/specs/scheduled-agent-runs.md Β§7). + +Gating mirrors the Phase A "Run now" surface exactly (two independent +controls, spec Β§6): + +* ``SCHEDULED_RUNS_ENABLED`` β€” per-environment kill switch (default on). + Off -> every route here 404s, as if unmounted. +* ``scheduled-runs`` RBAC capability -- *who* may use the surface. Granted + to the beta cohort's AppRole; missing -> 403. GA = grant to ``default``. + +Auth is the standard SPA cookie dependency (``get_current_user_from_session``) +per the CLAUDE.md app-api rule. +""" + +from __future__ import annotations + +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status + +from apis.shared.auth.dependencies import get_current_user_from_session +from apis.shared.auth.models import User +from apis.shared.feature_flags import scheduled_runs_enabled +from apis.shared.rbac.capabilities import SCHEDULED_RUNS_CAPABILITY, user_has_capability +from apis.shared.rbac.service import get_app_role_service +from apis.shared.scheduled_prompts.service import ( + MIN_INTERVAL_MINUTES, + UNSET, + ScheduledPromptLimitExceeded, + compute_next_run_at, + create_scheduled_prompt, + interval_to_minutes, + delete_scheduled_prompt, + get_scheduled_prompt, + list_scheduled_prompts, + set_schedule_state, + update_scheduled_prompt, +) + +from apis.app_api.schedules.models import ( + CreateScheduleRequest, + ScheduledPromptResponse, + ScheduledPromptsListResponse, + UpdateScheduleRequest, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/schedules", tags=["schedules"]) + + +async def require_scheduled_runs_user( + user: User = Depends(get_current_user_from_session), +) -> User: + """Cookie auth + kill switch + cohort capability, in that order. + + 404 when the environment kill switch is off (the surface behaves as if + unmounted), 403 when the authenticated caller lacks the + ``scheduled-runs`` capability. Mirrors ``apis.app_api.runs.routes``. + """ + if not scheduled_runs_enabled(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + if not await user_has_capability(user, SCHEDULED_RUNS_CAPABILITY): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to scheduled runs.", + ) + return user + + +async def _resolve_enabled_tools_snapshot(user: User, requested: Optional[List[str]]) -> Optional[List[str]]: + """Snapshot enabled_tools at creation time (Phase A punch #7). + + An explicit list from the caller is intersected with the user's current + RBAC grant before it is frozen β€” the SPA picker filters for UX, but the + request body is attacker-controlled within the caller's own session, so + the server must not let a crafted list enable a tool the AppRole does not + carry (the snapshot is later handed straight to the tool filter, which + performs no RBAC check of its own). ``None`` resolves to the user's + current RBAC-allowed tool set *right now* and freezes it β€” the catalog + shifting later never changes what a sleeping schedule is allowed to call. + """ + if requested is not None: + allowed = await get_app_role_service().filter_requested_tools(user, requested) + if len(allowed) != len(requested): + logger.warning( + "Dropped %d requested tool(s) outside user %s's RBAC grant on schedule create", + len(requested) - len(allowed), + user.user_id, + ) + return allowed + permissions = await get_app_role_service().resolve_user_permissions(user) + return list(permissions.tools) + + +def _require_schedule_or_404(schedule, schedule_id: str): + if schedule is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Schedule not found: {schedule_id}") + return schedule + + +@router.post("", response_model=ScheduledPromptResponse, status_code=status.HTTP_201_CREATED) +async def create_schedule( + request: CreateScheduleRequest, + user: User = Depends(require_scheduled_runs_user), +) -> ScheduledPromptResponse: + enabled_tools = await _resolve_enabled_tools_snapshot(user, request.enabled_tools) + try: + schedule = await create_scheduled_prompt( + user_id=user.user_id, + label=request.label, + prompt_text=request.prompt_text, + cadence=request.cadence, + hour_local=request.hour_local, + timezone_name=request.timezone, + weekday=request.weekday, + interval_value=request.interval_value, + interval_unit=request.interval_unit, + assistant_id=request.assistant_id, + enabled_tools=enabled_tools, + deliver_email=request.deliver_email, + ) + except ScheduledPromptLimitExceeded as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + + return ScheduledPromptResponse.from_schedule(schedule) + + +@router.get("", response_model=ScheduledPromptsListResponse) +async def list_schedules( + user: User = Depends(require_scheduled_runs_user), +) -> ScheduledPromptsListResponse: + schedules = await list_scheduled_prompts(user.user_id) + return ScheduledPromptsListResponse( + schedules=[ScheduledPromptResponse.from_schedule(s) for s in schedules] + ) + + +@router.get("/{schedule_id}", response_model=ScheduledPromptResponse) +async def get_schedule( + schedule_id: str, + user: User = Depends(require_scheduled_runs_user), +) -> ScheduledPromptResponse: + schedule = _require_schedule_or_404(await get_scheduled_prompt(user.user_id, schedule_id), schedule_id) + return ScheduledPromptResponse.from_schedule(schedule) + + +@router.patch("/{schedule_id}", response_model=ScheduledPromptResponse) +async def update_schedule( + schedule_id: str, + request: UpdateScheduleRequest, + user: User = Depends(require_scheduled_runs_user), +) -> ScheduledPromptResponse: + """Edit a schedule's fields and/or transition its state. + + Editing any cadence field (cadence/hour_local/weekday/timezone) on an + *active* schedule recomputes ``next_run_at`` in the same request β€” an + edited schedule never keeps a stale due time. A ``paused`` schedule just + remembers the new cadence for when it resumes (mirrors + ``sync_policies.change_policy_interval``). + """ + schedule = _require_schedule_or_404(await get_scheduled_prompt(user.user_id, schedule_id), schedule_id) + + effective_cadence = request.cadence if request.cadence is not None else schedule.cadence + effective_weekday = request.weekday if request.weekday is not None else schedule.weekday + if effective_cadence == "weekly" and effective_weekday is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="weekday is required when cadence is 'weekly'", + ) + + effective_interval_value = ( + request.interval_value if request.interval_value is not None else schedule.interval_value + ) + effective_interval_unit = ( + request.interval_unit if request.interval_unit is not None else schedule.interval_unit + ) + if effective_cadence == "interval": + if effective_interval_value is None or effective_interval_unit is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="intervalValue and intervalUnit are required when cadence is 'interval'", + ) + if interval_to_minutes(effective_interval_value, effective_interval_unit) < MIN_INTERVAL_MINUTES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"interval must be at least {MIN_INTERVAL_MINUTES} minutes", + ) + + # Clear intent is explicit β€” a bare null means "leave unchanged", so the + # service uses the UNSET sentinel for "untouched". Clearing the assistant + # reverts to the default agent; clearing tools re-snapshots the caller's + # current RBAC-allowed set. An edited tool list is a write path into the + # same frozen snapshot, so it gets the same create-time resolution β€” a + # PATCH must not route around the create-time check. + if request.clear_assistant: + assistant_arg = None + elif request.assistant_id is not None: + assistant_arg = request.assistant_id + else: + assistant_arg = UNSET + + if request.clear_tools: + tools_arg = await _resolve_enabled_tools_snapshot(user, None) + elif request.enabled_tools is not None: + tools_arg = await _resolve_enabled_tools_snapshot(user, request.enabled_tools) + else: + tools_arg = UNSET + + schedule = await update_scheduled_prompt( + user.user_id, + schedule_id, + label=request.label, + prompt_text=request.prompt_text, + cadence=request.cadence, + hour_local=request.hour_local, + weekday=request.weekday, + interval_value=request.interval_value, + interval_unit=request.interval_unit, + timezone_name=request.timezone, + assistant_id=assistant_arg, + enabled_tools=tools_arg, + deliver_email=request.deliver_email, + ) + + if request.state is not None and request.state != schedule.state: + if request.state == "active": + next_run_at = compute_next_run_at( + schedule.cadence, + schedule.hour_local, + schedule.timezone, + weekday=schedule.weekday, + interval_minutes=interval_to_minutes(schedule.interval_value, schedule.interval_unit), + ) + await set_schedule_state(user.user_id, schedule_id, "active", next_run_at=next_run_at) + else: + await set_schedule_state(user.user_id, schedule_id, "paused", state_reason="Paused by user") + schedule = await get_scheduled_prompt(user.user_id, schedule_id) + + return ScheduledPromptResponse.from_schedule(schedule) + + +@router.post("/{schedule_id}/pause", response_model=ScheduledPromptResponse) +async def pause_schedule( + schedule_id: str, + user: User = Depends(require_scheduled_runs_user), +) -> ScheduledPromptResponse: + """Pause a schedule β€” removes it from DueScheduleIndex immediately.""" + schedule = _require_schedule_or_404(await get_scheduled_prompt(user.user_id, schedule_id), schedule_id) + if schedule.state == "paused": + return ScheduledPromptResponse.from_schedule(schedule) + + await set_schedule_state(user.user_id, schedule_id, "paused", state_reason="Paused by user") + schedule = await get_scheduled_prompt(user.user_id, schedule_id) + return ScheduledPromptResponse.from_schedule(schedule) + + +@router.post("/{schedule_id}/resume", response_model=ScheduledPromptResponse) +async def resume_schedule( + schedule_id: str, + user: User = Depends(require_scheduled_runs_user), +) -> ScheduledPromptResponse: + """Resume a paused (or paused_error) schedule β€” recomputes next_run_at + fresh from now and re-adds it to DueScheduleIndex.""" + schedule = _require_schedule_or_404(await get_scheduled_prompt(user.user_id, schedule_id), schedule_id) + if schedule.state == "active": + return ScheduledPromptResponse.from_schedule(schedule) + + next_run_at = compute_next_run_at( + schedule.cadence, + schedule.hour_local, + schedule.timezone, + weekday=schedule.weekday, + interval_minutes=interval_to_minutes(schedule.interval_value, schedule.interval_unit), + ) + await set_schedule_state(user.user_id, schedule_id, "active", next_run_at=next_run_at) + schedule = await get_scheduled_prompt(user.user_id, schedule_id) + return ScheduledPromptResponse.from_schedule(schedule) + + +@router.delete("/{schedule_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_schedule( + schedule_id: str, + user: User = Depends(require_scheduled_runs_user), +) -> None: + """Delete = total revocation (no orphan timers).""" + _require_schedule_or_404(await get_scheduled_prompt(user.user_id, schedule_id), schedule_id) + await delete_scheduled_prompt(user.user_id, schedule_id) + return None diff --git a/backend/src/apis/app_api/sessions/routes.py b/backend/src/apis/app_api/sessions/routes.py index a93e020d..e0f6298c 100644 --- a/backend/src/apis/app_api/sessions/routes.py +++ b/backend/src/apis/app_api/sessions/routes.py @@ -9,6 +9,7 @@ from datetime import datetime, timezone from apis.shared.sessions.models import ( UpdateSessionMetadataRequest, + SessionInterruptRequest, SessionMetadataResponse, SessionMetadata, SessionPreferences, @@ -22,8 +23,11 @@ from apis.shared.sessions.metadata import ( list_user_sessions, get_session_metadata, + mark_session_read, + mark_session_unread, remove_pending_interrupts, session_exists_for_other_user, + set_interrupted_turn, store_session_metadata, ) from .services.session_service import SessionService @@ -152,6 +156,46 @@ async def get_session_metadata_endpoint( ) +@router.post("/{session_id}/read", status_code=status.HTTP_204_NO_CONTENT) +async def mark_session_read_endpoint( + session_id: str, + current_user: User = Depends(get_current_user_from_session) +): + """ + Mark a session as read, clearing the durable ``unread`` flag. + + Called by the SPA when the user opens a session that a scheduled + (unattended) run left unread. Idempotent single-attribute write β€” a + no-op if the session is already read or doesn't exist. Ownership is + enforced inside ``mark_session_read`` via the GSI lookup, so a session + belonging to another user is silently ignored (no state change). + + Requires session-cookie authentication. Returns 204 No Content. + """ + await mark_session_read(session_id=session_id, user_id=current_user.user_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post("/{session_id}/unread", status_code=status.HTTP_204_NO_CONTENT) +async def mark_session_unread_endpoint( + session_id: str, + current_user: User = Depends(get_current_user_from_session) +): + """ + Mark a session as unread, setting the durable ``unread`` flag. + + The manual counterpart to ``POST /{id}/read`` β€” lets a user re-flag a + conversation (e.g. "remind me to revisit this") so the sidebar dot + returns. Idempotent single-attribute write; ownership is enforced inside + ``mark_session_unread`` via the per-user GSI lookup, so a session + belonging to another user is silently ignored (no state change). + + Requires session-cookie authentication. Returns 204 No Content. + """ + await mark_session_unread(session_id=session_id, user_id=current_user.user_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.put("/{session_id}/metadata", response_model=SessionMetadataResponse, response_model_exclude_none=True) async def update_session_metadata_endpoint( session_id: str, @@ -597,6 +641,52 @@ async def get_session_messages_endpoint( ) +@router.post("/{session_id}/interrupt", status_code=204) +async def signal_turn_interrupted_endpoint( + session_id: str, + body: SessionInterruptRequest, + current_user: User = Depends(get_current_user_from_session), +): + """Record that the user deliberately stopped the session's in-flight turn. + + This is the AUTHORITATIVE carrier of stop intent for the interrupted-turn + flow: the transport cannot distinguish a Stop click from a dropped socket + (both surface as a cancelled stream), so the SPA signals intent here + out-of-band when the user clicks Stop β€” via ``fetch(..., {keepalive: + true})`` with the ``X-CSRF-Token`` header (NOT ``navigator.sendBeacon``, + which cannot set headers and would be rejected by CSRFMiddleware). + + Lives on app-api, not inference-api: the AgentCore Runtime data plane + only proxies ``/invocations`` + ``/ping``, so a custom inference-api + route would 404 in cloud. + + ``user_stopped`` takes precedence over the ``connection_lost`` fallback + that inference-api's cancellation backstop may race against this write + (see ``set_interrupted_turn``). No-op for missing sessions β€” and the GSI + lookup inside ``set_interrupted_turn`` is user-scoped, so a session + owned by someone else is also a no-op. Returns 204 either way (the + user's intent is recorded best-effort; the client never waits on it). + """ + user_id = current_user.user_id + + logger.info("POST /sessions/.../interrupt (reason=%s)", body.reason) + + try: + await set_interrupted_turn( + session_id, + user_id, + reason=body.reason, + source="client_signal", + ) + return Response(status_code=204) + except Exception: + logger.error("Error recording turn interruption", exc_info=True) + raise HTTPException( + status_code=500, + detail="Failed to record interruption", + ) + + @router.delete("/{session_id}/pending-interrupts/{interrupt_id:path}", status_code=204) async def dismiss_pending_interrupt_endpoint( session_id: str, diff --git a/backend/src/apis/app_api/sync_policies/__init__.py b/backend/src/apis/app_api/sync_policies/__init__.py new file mode 100644 index 00000000..3b4a7a87 --- /dev/null +++ b/backend/src/apis/app_api/sync_policies/__init__.py @@ -0,0 +1 @@ +"""Sync-policy management routes (KB sync β€” scheduled re-index).""" diff --git a/backend/src/apis/app_api/sync_policies/models.py b/backend/src/apis/app_api/sync_policies/models.py new file mode 100644 index 00000000..94ab2648 --- /dev/null +++ b/backend/src/apis/app_api/sync_policies/models.py @@ -0,0 +1,82 @@ +"""Sync-policy API request/response models""" + +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from apis.shared.sync_policies.models import SyncInterval, SyncPolicy, SyncPolicyState, SyncSourceType + + +class CreateSyncPolicyRequest(BaseModel): + """Request body for creating a sync policy on a content source""" + + model_config = ConfigDict(populate_by_name=True) + + source_type: SyncSourceType = Field(..., alias="sourceType", description="Kind of content source") + source_ref: str = Field( + ..., + alias="sourceRef", + min_length=1, + max_length=128, + description="drive_file: document id; web_crawl: crawl id", + ) + interval: SyncInterval = Field(..., description="Re-sync cadence") + + +class UpdateSyncPolicyRequest(BaseModel): + """Request body for changing a policy's interval or pausing/resuming. + + `state` accepts only the user-owned transitions: "paused_user" (pause) + and "active" (resume). paused_reauth resumes only via a fresh OAuth + consent; the other paused states resume here too since resuming is an + explicit user decision. + """ + + model_config = ConfigDict(populate_by_name=True) + + interval: Optional[SyncInterval] = Field(None, description="New re-sync cadence") + state: Optional[Literal["active", "paused_user"]] = Field(None, description="Pause or resume the policy") + + +class SyncPolicyResponse(BaseModel): + """Public view of a sync policy""" + + model_config = ConfigDict(populate_by_name=True) + + policy_id: str = Field(..., alias="policyId") + assistant_id: str = Field(..., alias="assistantId") + source_type: SyncSourceType = Field(..., alias="sourceType") + source_ref: str = Field(..., alias="sourceRef") + interval: SyncInterval + state: SyncPolicyState + state_reason: Optional[str] = Field(None, alias="stateReason") + next_sync_at: Optional[str] = Field(None, alias="nextSyncAt") + last_sync_at: Optional[str] = Field(None, alias="lastSyncAt") + last_result: Optional[str] = Field(None, alias="lastResult") + created_at: str = Field(..., alias="createdAt") + updated_at: str = Field(..., alias="updatedAt") + + @classmethod + def from_policy(cls, policy: SyncPolicy) -> "SyncPolicyResponse": + return cls( + policy_id=policy.policy_id, + assistant_id=policy.assistant_id, + source_type=policy.source_type, + source_ref=policy.source_ref, + interval=policy.interval, + state=policy.state, + state_reason=policy.state_reason, + next_sync_at=policy.next_sync_at, + last_sync_at=policy.last_sync_at, + last_result=policy.last_result, + created_at=policy.created_at, + updated_at=policy.updated_at, + ) + + +class SyncPoliciesListResponse(BaseModel): + """Response for listing an assistant's sync policies""" + + model_config = ConfigDict(populate_by_name=True) + + policies: List[SyncPolicyResponse] = Field(..., description="Sync policies for the assistant") diff --git a/backend/src/apis/app_api/sync_policies/routes.py b/backend/src/apis/app_api/sync_policies/routes.py new file mode 100644 index 00000000..95cca8ff --- /dev/null +++ b/backend/src/apis/app_api/sync_policies/routes.py @@ -0,0 +1,194 @@ +"""Sync-policy routes β€” manage scheduled re-index of assistant KB sources. + +All routes are edit-gated (owner or editor share), matching the document +management surface: whoever can add knowledge can decide whether it stays +fresh. Every mutation is plain data β€” nothing here schedules work directly; +the dispatcher's DueSyncIndex sweep remains the single trigger +(docs/specs/assistant-kb-sync.md Β§3). +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status + +from apis.shared.auth.dependencies import get_current_user_from_session +from apis.shared.auth.models import User +from apis.shared.assistants.service import resolve_assistant_permission +from apis.shared.sync_policies.service import ( + DuplicateSyncPolicy, + RunNowCooldown, + SyncPolicyLimitExceeded, + change_policy_interval, + create_sync_policy, + delete_reauth_marker, + delete_sync_policy, + get_sync_policy, + list_sync_policies, + set_policy_state, + trigger_run_now, +) +from apis.shared.sync_policies.service import _get_current_timestamp # timestamp house-style + +from apis.app_api.kb_sync import records +from apis.app_api.sync_policies.models import ( + CreateSyncPolicyRequest, + SyncPoliciesListResponse, + SyncPolicyResponse, + UpdateSyncPolicyRequest, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/assistants/{assistant_id}/sync-policies", tags=["sync-policies"]) + + +async def _require_edit_permission(assistant_id: str, current_user: User) -> str: + """Owner or editor share required β€” same gate as the documents surface.""" + assistant, permission = await resolve_assistant_permission( + assistant_id=assistant_id, user_id=current_user.user_id, user_email=current_user.email + ) + if not assistant: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Assistant not found: {assistant_id}") + if permission not in ("owner", "editor"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to manage sync for this assistant", + ) + return assistant.owner_id + + +def _validate_source(assistant_id: str, source_type: str, source_ref: str) -> None: + """The source must exist before a schedule can cover it; drive_file + sources additionally need import provenance to re-fetch from.""" + source = records.get_source_item(assistant_id, source_type, source_ref) + if source is None or source.get("status") == "deleting": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Sync source not found: {source_ref}", + ) + if source_type == "drive_file" and not source.get("sourceFileId"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Device-uploaded documents have no external source to sync from", + ) + + +@router.post("", response_model=SyncPolicyResponse, status_code=status.HTTP_201_CREATED) +async def create_policy( + assistant_id: str, + request: CreateSyncPolicyRequest, + current_user: User = Depends(get_current_user_from_session), +) -> SyncPolicyResponse: + await _require_edit_permission(assistant_id, current_user) + _validate_source(assistant_id, request.source_type, request.source_ref) + + try: + policy = await create_sync_policy( + assistant_id=assistant_id, + source_type=request.source_type, + source_ref=request.source_ref, + interval=request.interval, + created_by_user_id=current_user.user_id, + ) + except DuplicateSyncPolicy: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="This source already has a sync policy" + ) + except SyncPolicyLimitExceeded as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + if request.source_type == "drive_file": + records.update_document_sync_fields(assistant_id, request.source_ref, sync_policy_id=policy.policy_id) + + return SyncPolicyResponse.from_policy(policy) + + +@router.get("", response_model=SyncPoliciesListResponse) +async def list_policies( + assistant_id: str, + current_user: User = Depends(get_current_user_from_session), +) -> SyncPoliciesListResponse: + await _require_edit_permission(assistant_id, current_user) + policies = await list_sync_policies(assistant_id) + return SyncPoliciesListResponse(policies=[SyncPolicyResponse.from_policy(p) for p in policies]) + + +@router.patch("/{policy_id}", response_model=SyncPolicyResponse) +async def update_policy( + assistant_id: str, + policy_id: str, + request: UpdateSyncPolicyRequest, + current_user: User = Depends(get_current_user_from_session), +) -> SyncPolicyResponse: + await _require_edit_permission(assistant_id, current_user) + policy = await get_sync_policy(assistant_id, policy_id) + if policy is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Sync policy not found: {policy_id}") + + if request.state == "active" and policy.state == "paused_reauth": + # Only a fresh OAuth consent resumes a reauth pause β€” resuming here + # would just re-fail and burn a run. + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Reconnect the content source to resume syncing", + ) + + if request.interval is not None and request.interval != policy.interval: + policy = await change_policy_interval(assistant_id, policy_id, request.interval) + + if request.state is not None and request.state != policy.state: + if request.state == "active": + await set_policy_state(assistant_id, policy_id, "active", next_sync_at=_get_current_timestamp()) + else: + await set_policy_state(assistant_id, policy_id, "paused_user", state_reason="Paused by user") + policy = await get_sync_policy(assistant_id, policy_id) + + return SyncPolicyResponse.from_policy(policy) + + +@router.delete("/{policy_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_policy( + assistant_id: str, + policy_id: str, + current_user: User = Depends(get_current_user_from_session), +) -> None: + await _require_edit_permission(assistant_id, current_user) + policy = await get_sync_policy(assistant_id, policy_id) + if policy is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Sync policy not found: {policy_id}") + + await delete_sync_policy(assistant_id, policy_id) + await delete_reauth_marker(policy.created_by_user_id, policy_id) + + if policy.source_type == "web_crawl": + # Un-synced crawl jobs go back to normal 30-day auto-expiry. + from apis.app_api.web_sources.crawl_repository import restore_crawl_ttl + + await restore_crawl_ttl(assistant_id=assistant_id, crawl_id=policy.source_ref) + else: + records.clear_document_sync_policy_id(assistant_id, policy.source_ref) + + return None + + +@router.post("/{policy_id}/run-now", response_model=SyncPolicyResponse, status_code=status.HTTP_202_ACCEPTED) +async def run_now( + assistant_id: str, + policy_id: str, + current_user: User = Depends(get_current_user_from_session), +) -> SyncPolicyResponse: + """Make the policy due immediately. Flows through the normal dispatcher + sweep, so every runaway guard still applies; β‰₯10-minute cooldown.""" + await _require_edit_permission(assistant_id, current_user) + try: + policy = await trigger_run_now(assistant_id, policy_id) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Sync policy not found: {policy_id}") + except ValueError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + except RunNowCooldown: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="A manual sync was already requested recently; try again in a few minutes", + ) + return SyncPolicyResponse.from_policy(policy) diff --git a/backend/src/apis/app_api/web_sources/crawl_repository.py b/backend/src/apis/app_api/web_sources/crawl_repository.py index 2955c7d9..c9514584 100644 --- a/backend/src/apis/app_api/web_sources/crawl_repository.py +++ b/backend/src/apis/app_api/web_sources/crawl_repository.py @@ -292,44 +292,116 @@ async def increment_counters( ) +async def restore_crawl_ttl(*, assistant_id: str, crawl_id: str) -> None: + """Re-apply the finalized-row TTL to a terminal crawl job. + + Inverse of the sync path's finalize_crawl(set_ttl=False): when the sync + policy covering a crawl is deleted, the job goes back to the normal + 30-day auto-expiry so un-synced history doesn't accumulate forever. + Running jobs are left alone (finalize owns their transition). + """ + from botocore.exceptions import ClientError + + try: + _table().update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"CRAWL#{crawl_id}"}, + UpdateExpression="SET #ttl = :ttl", + ExpressionAttributeNames={"#ttl": "ttl", "#status": "status"}, + ExpressionAttributeValues={ + ":ttl": int(time.time()) + _FINALIZED_TTL_DAYS * 86400, + ":running": "running", + }, + ConditionExpression="attribute_exists(PK) AND #status <> :running", + ) + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + return # gone already, or still running β€” nothing to restore + raise + + +async def reset_crawl_for_refresh(*, assistant_id: str, crawl_id: str) -> bool: + """Rearm an existing crawl job for a KB-sync re-crawl. + + Puts the job back to `running` with zeroed counters and a fresh + startedAt, and strips the TTL/error/completedAt from the previous + terminal state (a sync-covered job must never auto-expire β€” see + finalize_crawl set_ttl). Returns False if the job no longer exists. + """ + from botocore.exceptions import ClientError + + expression_attribute_names = {"#status": "status", "#ttl": "ttl", "#err": "error"} + try: + _table().update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"CRAWL#{crawl_id}"}, + UpdateExpression=( + "SET #status = :running, startedAt = :now, updatedAt = :now, " + "discoveredCount = :zero, fetchedCount = :zero, failedCount = :zero " + "REMOVE #ttl, #err, completedAt" + ), + ExpressionAttributeValues={":running": "running", ":now": _now(), ":zero": 0}, + ExpressionAttributeNames=expression_attribute_names, + ConditionExpression="attribute_exists(PK)", + ) + return True + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + logger.warning("Cannot reset missing crawl job %s/%s for refresh", assistant_id, crawl_id) + return False + raise + + async def finalize_crawl( *, assistant_id: str, crawl_id: str, status: CrawlJobStatus, error: Optional[str] = None, + set_ttl: bool = True, ) -> None: """Move a crawl out of `running`. Never raises. Callers must invoke this in a `finally` so a crashed task does not leave a job pinned at `running` forever (which would keep the editor's watcher loop spinning). + + set_ttl=False is the KB-sync path: a crawl job referenced by a sync + policy is that policy's source of truth and must NOT auto-expire β€” + the 30-day TTL reaper deleting it would trip the dispatcher's liveness + check and silently kill the schedule. Sync re-crawls finalize with the + ttl attribute removed instead. """ # DynamoDB TTL requires epoch *seconds* β€” millisecond values are silently # ignored by the reaper. `#ttl` is escaped because `ttl` is a reserved word. - ttl_epoch_seconds = int(time.time()) + _FINALIZED_TTL_DAYS * 86400 expression_attribute_names = {"#status": "status", "#ttl": "ttl"} set_parts = [ "#status = :status", "completedAt = :completed_at", "updatedAt = :completed_at", - "#ttl = :ttl", ] values: dict[str, object] = { ":status": status, ":completed_at": _now(), - ":ttl": ttl_epoch_seconds, } + remove_parts = [] + if set_ttl: + set_parts.append("#ttl = :ttl") + values[":ttl"] = int(time.time()) + _FINALIZED_TTL_DAYS * 86400 + else: + remove_parts.append("#ttl") if error is not None: set_parts.append("#err = :err") expression_attribute_names["#err"] = "error" # Trim long error strings so we never write a >400KB DynamoDB row. values[":err"] = (error or "")[:2000] + update_expression = "SET " + ", ".join(set_parts) + if remove_parts: + update_expression += " REMOVE " + ", ".join(remove_parts) + try: _table().update_item( Key={"PK": f"AST#{assistant_id}", "SK": f"CRAWL#{crawl_id}"}, - UpdateExpression="SET " + ", ".join(set_parts), + UpdateExpression=update_expression, ExpressionAttributeValues=values, ExpressionAttributeNames=expression_attribute_names, ConditionExpression="attribute_exists(PK)", diff --git a/backend/src/apis/app_api/web_sources/crawler.py b/backend/src/apis/app_api/web_sources/crawler.py index cfbaeb04..e4c38b2c 100644 --- a/backend/src/apis/app_api/web_sources/crawler.py +++ b/backend/src/apis/app_api/web_sources/crawler.py @@ -23,6 +23,7 @@ import re import time from collections import defaultdict +from dataclasses import dataclass, field from typing import Awaitable, Callable, Dict, List, Optional, Set, Tuple from urllib.robotparser import RobotFileParser @@ -124,6 +125,73 @@ async def _create_pending_document( return document_id +# ── KB-sync refresh support ────────────────────────────────────────────────── + + +@dataclass +class RefreshDoc: + """What the refresh path needs to know about an existing page document.""" + + document_id: str + source_etag: Optional[str] = None + content_hash: Optional[str] = None + chunk_count: int = 0 + + +@dataclass +class RefreshState: + """Turns a crawl into an upsert-by-URL refresh (docs/specs/assistant-kb-sync.md Β§6.2). + + `docs` maps normalized URL -> existing document; the crawler reuses + those records instead of creating duplicates, conditional-GETs with the + stored ETag, and hash-gates re-staging. `on_result` is the worker's + hook for all sync-bookkeeping writes (etag/hash/chunk-count stash) β€” + the crawler stays ignorant of sync-policy storage. + + Outcomes reported via on_result(url, document_id, outcome, etag, content_hash): + "unchanged" β€” 304 or identical content hash; nothing re-staged + "changed" β€” existing doc, new bytes; emitted BEFORE staging so the + worker can stash the previous chunk count first + "created" β€” page new to this crawl; emitted after staging + `seen_urls` collects every URL that survived the robots gate β€” the + worker diffs it against `docs` for miss counting. Fetch failures ARE + seen (a flaky page is not a missing page); robots-disallowed pages are + NOT (the site affirmatively told us to stop indexing them). + """ + + docs: Dict[str, RefreshDoc] = field(default_factory=dict) + on_result: Optional[ + Callable[[str, str, str, Optional[str], Optional[str]], Awaitable[None]] + ] = None + seen_urls: Set[str] = field(default_factory=set) + changed: int = 0 + unchanged: int = 0 + created: int = 0 + + async def _emit( + self, + url: str, + document_id: str, + outcome: str, + etag: Optional[str] = None, + content_hash: Optional[str] = None, + ) -> None: + if outcome == "changed": + self.changed += 1 + elif outcome == "unchanged": + self.unchanged += 1 + elif outcome == "created": + self.created += 1 + if self.on_result is not None: + await self.on_result(url, document_id, outcome, etag, content_hash) + + +def _content_sha256(markdown: str) -> str: + import hashlib + + return hashlib.sha256(markdown.encode("utf-8")).hexdigest() + + # ── robots.txt cache ───────────────────────────────────────────────────────── @@ -280,12 +348,20 @@ async def wait_for(self, url: str) -> None: async def _fetch_page( - client: httpx.AsyncClient, url: str -) -> Tuple[str, Optional[str]]: - """Fetch a single page. Returns (html, etag). Raises on non-2xx or non-HTML.""" + client: httpx.AsyncClient, url: str, if_none_match: Optional[str] = None +) -> Tuple[str, Optional[str], bool]: + """Fetch a single page. Returns (html, etag, not_modified). + + When `if_none_match` is set (KB-sync refresh with a stored ETag), a 304 + response short-circuits as ("", stored_etag, True) β€” no body transfer, + no re-extraction. Raises on non-2xx (other than 304) or non-HTML. + """ + headers = {"If-None-Match": if_none_match} if if_none_match else None resp = await client.get( - url, follow_redirects=True, timeout=PER_PAGE_TIMEOUT_SECONDS + url, follow_redirects=True, timeout=PER_PAGE_TIMEOUT_SECONDS, headers=headers ) + if if_none_match and resp.status_code == 304: + return "", if_none_match, True resp.raise_for_status() content_type = (resp.headers.get("content-type") or "").lower() if not any( @@ -301,7 +377,7 @@ async def _fetch_page( ) # Use httpx's encoding inference; surface bytes as text for parsing. html = resp.text - return html, resp.headers.get("etag") + return html, resp.headers.get("etag"), False # ── S3 stage ──────────────────────────────────────────────────────────────── @@ -352,6 +428,9 @@ async def run_crawl( Callable[[], httpx.AsyncClient] ] = None, on_progress: Optional[Callable[[str], Awaitable[None]]] = None, + refresh: Optional[RefreshState] = None, + finalize_with_ttl: bool = True, + budget_seconds: Optional[int] = None, ) -> None: """Run a BFS crawl, staging each fetched page as a Document. @@ -361,6 +440,10 @@ async def run_crawl( The two injection points (`http_client_factory`, `on_progress`) exist purely to make unit testing tractable β€” production passes neither. + + `refresh` switches the crawl into KB-sync upsert mode (see + RefreshState); `finalize_with_ttl=False` keeps a sync-covered crawl job + from auto-expiring (see finalize_crawl). """ logger.info( @@ -374,10 +457,17 @@ async def run_crawl( ) async def _run() -> None: + already_recorded = {normalize_url(root_url): root_document_id} + if refresh is not None: + # Upsert mode: every known page reuses its existing document + # record β€” a re-crawl must never duplicate documents by URL. + already_recorded.update( + {url: doc.document_id for url, doc in refresh.docs.items()} + ) creator = _DocumentCreator( assistant_id=assistant_id, user_id=user_id, - already_recorded={normalize_url(root_url): root_document_id}, + already_recorded=already_recorded, ) await increment_counters( assistant_id=assistant_id, @@ -403,6 +493,30 @@ async def _run() -> None: done_event = asyncio.Event() done_event.set() # starts "done" until we launch the first task + async def enqueue_links(html: str, url: str, depth: int) -> None: + if depth >= settings.max_depth: + return + for normalized, _raw in _extract_links(html, url): + if normalized in visited: + continue + if len(visited) >= settings.max_pages: + break + if settings.same_domain_only and not same_registrable_domain( + normalized, root_url + ): + continue + try: + assert_url_is_public(normalized, resolve=False) + except Exception: + continue + visited.add(normalized) + await increment_counters( + assistant_id=assistant_id, + crawl_id=crawl_id, + discovered_delta=1, + ) + await frontier.put((normalized, depth + 1)) + async def worker(url: str, depth: int) -> None: nonlocal in_flight try: @@ -427,40 +541,92 @@ async def worker(url: str, depth: int) -> None: ) return await delay.wait_for(url) + existing = refresh.docs.get(url) if refresh is not None else None + if refresh is not None: + # Seen = survived the robots gate. Fetch failures + # still count as seen (transient outage β‰  missing + # page); robots-disallowed pages returned above and + # do NOT (the site said stop indexing them). + refresh.seen_urls.add(url) document_id = await creator.get_or_create(url) logger.info("Crawl %s fetching %s (depth=%d)", crawl_id, url, depth) try: - html, etag = await _fetch_page(client, url) + html, etag, not_modified = await _fetch_page( + client, + url, + if_none_match=existing.source_etag if existing else None, + ) except Exception as fetch_err: logger.warning("Fetch failed for %s: %s", url, fetch_err) - await update_document_status( + if existing is None: + # Refresh mode never clobbers an existing indexed + # doc's status over a transient fetch error β€” its + # last-good content stays served. + await update_document_status( + assistant_id=assistant_id, + document_id=document_id, + status="failed", + error_message="The page could not be fetched.", + error_details=str(fetch_err)[:500], + ) + await increment_counters( assistant_id=assistant_id, - document_id=document_id, - status="failed", - error_message="The page could not be fetched.", - error_details=str(fetch_err)[:500], + crawl_id=crawl_id, + failed_delta=1, ) + return + + if not_modified: + await refresh._emit(url, document_id, "unchanged", etag) await increment_counters( assistant_id=assistant_id, crawl_id=crawl_id, - failed_delta=1, + fetched_delta=1, ) + # 304 carries no body, so no links to walk from this + # page this run β€” its previously-discovered children + # are still in refresh.docs and get their own fetches + # only if some other page still links to them; the + # miss counter (not this run) decides their fate. return markdown, title = _extract_markdown(html, url) if not markdown.strip(): - await update_document_status( - assistant_id=assistant_id, - document_id=document_id, - status="failed", - error_message="The page had no extractable content.", - ) + if existing is None: + await update_document_status( + assistant_id=assistant_id, + document_id=document_id, + status="failed", + error_message="The page had no extractable content.", + ) await increment_counters( assistant_id=assistant_id, crawl_id=crawl_id, failed_delta=1, ) return + + if refresh is not None: + content_hash = _content_sha256(markdown) + if existing is not None: + if existing.content_hash and existing.content_hash == content_hash: + # Same bytes under a new/absent ETag: record + # the fresh etag so gate 1 works next run, + # skip the re-embed entirely. + await refresh._emit(url, document_id, "unchanged", etag, content_hash) + await increment_counters( + assistant_id=assistant_id, + crawl_id=crawl_id, + fetched_delta=1, + ) + await enqueue_links(html, url, depth) + return + # Changed: let the worker stash the previous + # chunk count BEFORE the S3 overwrite fires the + # ingestion event. + await refresh._emit(url, document_id, "changed", etag, content_hash) + else: + await refresh._emit(url, document_id, "created", etag, content_hash) display_name = ( title or url_extension_hint(url) ).strip() or "page" @@ -500,27 +666,7 @@ async def worker(url: str, depth: int) -> None: if on_progress is not None: await on_progress(url) - if depth < settings.max_depth: - for normalized, _raw in _extract_links(html, url): - if normalized in visited: - continue - if len(visited) >= settings.max_pages: - break - if settings.same_domain_only and not same_registrable_domain( - normalized, root_url - ): - continue - try: - assert_url_is_public(normalized, resolve=False) - except Exception: - continue - visited.add(normalized) - await increment_counters( - assistant_id=assistant_id, - crawl_id=crawl_id, - discovered_delta=1, - ) - await frontier.put((normalized, depth + 1)) + await enqueue_links(html, url, depth) finally: in_flight -= 1 if in_flight == 0 and frontier.empty(): @@ -555,7 +701,7 @@ async def _wrapped(u: str = url, d: int = depth) -> None: error: Optional[str] = None status: str = "complete" try: - await asyncio.wait_for(_run(), timeout=CRAWL_BUDGET_SECONDS) + await asyncio.wait_for(_run(), timeout=budget_seconds or CRAWL_BUDGET_SECONDS) except asyncio.TimeoutError: logger.warning("Crawl %s exceeded budget; marking failed", crawl_id) error = "Crawl exceeded the time budget." @@ -571,6 +717,7 @@ async def _wrapped(u: str = url, d: int = depth) -> None: crawl_id=crawl_id, status=status, # type: ignore[arg-type] error=error, + set_ttl=finalize_with_ttl, ) diff --git a/backend/src/apis/app_api/web_sources/routes.py b/backend/src/apis/app_api/web_sources/routes.py index 4ad9a2a9..f7c8a439 100644 --- a/backend/src/apis/app_api/web_sources/routes.py +++ b/backend/src/apis/app_api/web_sources/routes.py @@ -33,6 +33,7 @@ create_crawl_job, get_crawl_job, list_active_crawls, + list_all_crawls, ) from apis.app_api.web_sources.crawler import run_crawl from apis.app_api.web_sources.models import ( @@ -172,10 +173,9 @@ async def list_crawls( if active: crawls = await list_active_crawls(assistant_id) else: - # The full-history view is reserved for a future "crawl history" - # panel; for now we return active only when asked and an empty list - # otherwise to keep the contract small. - crawls = await list_active_crawls(assistant_id) + # Full history: the sync-policy UI needs completed crawls too β€” a + # web_crawl policy's source is the terminal CrawlJob row. + crawls = await list_all_crawls(assistant_id) return ActiveCrawlsResponse(crawls=crawls) diff --git a/backend/src/apis/inference_api/chat/agent_binding_resolver.py b/backend/src/apis/inference_api/chat/agent_binding_resolver.py new file mode 100644 index 00000000..cfd70f16 --- /dev/null +++ b/backend/src/apis/inference_api/chat/agent_binding_resolver.py @@ -0,0 +1,212 @@ +"""Agent Designer Phase 3 β€” run-time binding resolution (the Harness side). + +At invocation the Harness re-resolves an Agent's ``modelConfig`` + ``bindings`` against +the **invoking** user (D5), not the author β€” an Agent is shared but its capabilities are +gated per user. v1 policy is **block-with-message**: if the invoker lacks a required +capability the turn raises ``AgentBindingBlockedError`` and the route streams a +conversational error (SSE ``stream_error``) rather than silently downgrading. + +This module lives in inference-api and may import only ``apis.shared`` (never +``apis.app_api`` β€” the design-time ``binding_validation`` there is a different concern and +would break the import boundary). It reuses the harness's existing per-primitive access +checks; it invents no new RBAC (D4). + +Phase 3 lands incrementally: +- ``modelConfig`` β†’ ``model_override``, reusing the exact ``AppRoleService.can_access_model`` + gate the harness already enforces (R2). Absent ``modelConfig`` β‡’ no override β‡’ today's + model-resolution chain is untouched. +- ``memory_space`` bindings β†’ index injection + ``memory_*`` tools. +- ``tool`` bindings β†’ the effective tool allowlist (**replace**, mirroring the model + override): when an Agent binds tools they *are* its toolset, re-resolved per invoker via + the same ``AppRoleService.can_access_tool`` gate; a bound tool the invoker lacks blocks the + turn (D5). Absent tool bindings β‡’ the request's ``enabled_tools`` drive the turn as today. +- ``knowledge_base`` stays with the existing RAG path; ``skill`` bindings are still inert + here (their runtime fold interacts with ``agent_type``/skill resolution β€” a later slice). +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import List, Optional + +from apis.shared.assistants.models import Assistant +from apis.shared.auth.models import User +from apis.shared.feature_flags import memory_spaces_enabled +from apis.shared.memory.service import MemorySpaceService +from apis.shared.rbac.service import get_app_role_service + +_ROLE_RANK = {"viewer": 1, "editor": 2, "owner": 3} + + +class AgentBindingBlockedError(Exception): + """The invoking user lacks a capability the Agent requires (D5 block-with-message). + + ``message`` is markdown shown to the user as the assistant turn. + """ + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +@dataclass +class ResolvedModel: + """A governed model selection resolved for the invoking user.""" + + model_id: str + provider: Optional[str] = None + params: Optional[dict] = None + + +@dataclass +class ResolvedMemoryBinding: + """A ``memory_space`` binding resolved for the invoking user. + + ``role``/``access`` decide what the Harness may do: ``access`` is the *authored* + intent (``read``|``readwrite``), ``role`` is the invoker's actual grant. A + ``readwrite`` binding requires the invoker to be ``editor+`` (else the resolver + blocks β€” no silent read-only downgrade, D5). ``always_load`` drives prompt injection. + """ + + space_id: str + space_name: str + role: str + access: str + always_load: Optional[List[str]] = None + + +@dataclass +class ResolvedTools: + """The Agent's ``tool`` bindings resolved to an effective allowlist for the invoker. + + ``tool_ids`` **replaces** the request's ``enabled_tools`` for this turn (an Agent that + binds tools owns its toolset, like ``modelConfig`` owns the model). Every id has already + passed the invoker's ``AppRoleService.can_access_tool`` gate; a bound tool the invoker + could not access blocks the turn before this is constructed (D5), so the list is safe to + hand straight to the tool filter. An empty ``tool_ids`` is meaningful β€” the Agent + deliberately runs with *no* tools β€” and is distinct from ``plan.tools is None`` (no tool + binding, fall through to the request). + """ + + tool_ids: List[str] + + +@dataclass +class AgentInvocationPlan: + """What the Harness should apply for this turn after resolving the Agent. + + ``model_override`` is ``None`` when the Agent pins no model β€” the caller then + resolves the model exactly as today. ``memory`` is ``None`` when the Agent binds no + Memory Space. ``tools`` is ``None`` when the Agent binds no tools β€” the caller then + uses the request's ``enabled_tools`` unchanged. + """ + + model_override: Optional[ResolvedModel] = None + memory: Optional[ResolvedMemoryBinding] = None + tools: Optional[ResolvedTools] = None + + +async def resolve_agent_invocation(assistant: Assistant, invoker: User) -> AgentInvocationPlan: + """Resolve an Agent's governed capabilities for ``invoker``; raise on a block (D5). + + PR-A resolves only ``modelConfig``. The model is access-checked against the invoker + with the same ``AppRoleService.can_access_model`` the harness uses elsewhere (R2), so + an author cannot compose a model the invoker is later blocked on at model-resolution + time. + """ + plan = AgentInvocationPlan() + + model_settings = assistant.model_settings + if model_settings is not None: + app_role_service = get_app_role_service() + if not await app_role_service.can_access_model(invoker, model_settings.model_id): + raise AgentBindingBlockedError( + f"This agent runs on **{model_settings.model_id}**, which isn't available " + "to your account. Ask an administrator for access, or use a different agent." + ) + plan.model_override = ResolvedModel( + model_id=model_settings.model_id, + provider=model_settings.provider, + params=model_settings.params, + ) + + plan.memory = await _resolve_memory(assistant, invoker) + plan.tools = await _resolve_tools(assistant, invoker) + return plan + + +async def _resolve_tools(assistant: Assistant, invoker: User) -> Optional[ResolvedTools]: + """Resolve the Agent's ``tool`` bindings to an effective allowlist for ``invoker`` (D5). + + Each bound tool is re-checked against the invoker with the same + ``AppRoleService.can_access_tool`` gate the harness already enforces (R2), so an author + cannot compose a tool the invoker is later denied. A single missing tool blocks the turn + (block-with-message, no silent drop β€” D5). Returns ``None`` when the Agent binds no tools, + leaving the request's ``enabled_tools`` in force. The RBAC service is fetched lazily (only + when the Agent actually binds tools) β€” mirroring how ``_resolve_memory`` builds its own. + """ + tool_bindings = [b for b in (assistant.bindings or []) if b.kind == "tool"] + if not tool_bindings: + return None + + app_role_service = get_app_role_service() + resolved: List[str] = [] + for binding in tool_bindings: + ref = binding.ref + if not await app_role_service.can_access_tool(invoker, ref): + raise AgentBindingBlockedError( + f"This agent uses the tool **{ref}**, which isn't available to your account. " + "Ask an administrator for access, or use a different agent." + ) + if ref not in resolved: + resolved.append(ref) + + return ResolvedTools(tool_ids=resolved) + + +async def _resolve_memory(assistant: Assistant, invoker: User) -> Optional[ResolvedMemoryBinding]: + """Resolve the Agent's ``memory_space`` binding for ``invoker`` (D5); raise on block. + + v1 supports one Memory Space per Agent (Phase-1 UI writes at most one); any extras are + ignored. Reads permission via ``MemorySpaceService`` (the same identity-based + ``resolve_permission`` the app-api surface uses β€” D4), wrapped in a thread since it's + sync boto3. + """ + memory_bindings = [b for b in (assistant.bindings or []) if b.kind == "memory_space"] + if not memory_bindings: + return None + + if not memory_spaces_enabled(): + # Design-time validation refuses to create these while the flag is off, so + # hitting this means environment drift β€” block rather than silently drop (D5). + raise AgentBindingBlockedError( + "This agent uses Memory, which isn't enabled in this environment." + ) + + binding = memory_bindings[0] + access = (binding.config or {}).get("access", "read") + + service = MemorySpaceService() + space, role = await asyncio.to_thread( + service.resolve_permission, binding.ref, invoker.user_id, invoker.email + ) + if space is None: + raise AgentBindingBlockedError( + "This agent's Memory Space no longer exists. Ask its owner to reconnect it." + ) + + required = "editor" if access == "readwrite" else "viewer" + if role is None or _ROLE_RANK[role] < _ROLE_RANK[required]: + raise AgentBindingBlockedError( + f"This agent needs **{required}** access to its Memory Space " + f'"{space.name}", which your account doesn\'t have.' + ) + + return ResolvedMemoryBinding( + space_id=binding.ref, + space_name=space.name, + role=role, + access=access, + always_load=(binding.config or {}).get("alwaysLoad"), + ) diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index 3df58c02..14b9eef0 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -26,7 +26,7 @@ ErrorCode, build_conversational_error_event, ) -from apis.shared.feature_flags import skills_enabled +from apis.shared.feature_flags import agents_enabled, skills_enabled from apis.shared.files.file_resolver import get_file_resolver from apis.shared.models.managed_models import list_managed_models from apis.shared.platform_settings.models import DEFAULT_CHAT_MODE, ChatModeSettings @@ -41,6 +41,10 @@ ) from apis.shared.rbac.service import get_app_role_service +from apis.inference_api.chat.agent_binding_resolver import ( + AgentBindingBlockedError, + resolve_agent_invocation, +) from apis.shared.sessions.metadata import ensure_session_metadata_exists from apis.shared.user_settings.repository import UserSettingsRepository @@ -400,6 +404,36 @@ def _build_artifact_tools( return tools +def _build_memory_tools(agent_memory, user_id: str, user_email: str) -> list: + """Context-bound Memory-Space tools for an Agent's resolved memory binding. + + ``agent_memory`` is the resolver's ``ResolvedMemoryBinding`` (or ``None``). No binding + β†’ no tools. Read tools (list + read) are always exposed; the write tool only when the + binding grants ``readwrite`` β€” and the service re-checks ``editor+`` on every call, so + this is a UX gate, not the security boundary. Not gated on ``enabled_tools``: the + governing capability is the Agent's binding, not the user's tool picker. + """ + if agent_memory is None: + return [] + + from agents.builtin_tools.memory_spaces import ( + make_memory_list_tool, + make_memory_read_tool, + make_memory_write_tool, + ) + + space_id, space_name = agent_memory.space_id, agent_memory.space_name + tools = [ + make_memory_list_tool(space_id, space_name, user_id, user_email), + make_memory_read_tool(space_id, space_name, user_id, user_email), + ] + if agent_memory.access == "readwrite": + tools.append(make_memory_write_tool(space_id, space_name, user_id, user_email)) + + logger.info(f"Created {len(tools)} memory-space tools for bound space") + return tools + + # ============================================================ # Attachment Partitioning (#206) # ============================================================ @@ -496,6 +530,44 @@ def _build_attachment_guidance( return "\n\n".join(parts) +def _build_interruption_note(reason: str) -> str: + """Reason-driven note prepended to the next turn's prompt when the prior + turn was interrupted (see `clear_interrupted_turn`, whose popped reason + feeds this). + + Why the note lives HERE and not on the interrupted turn itself: the + reason is not knowable at cancellation time β€” the client's `user_stopped` + signal (app-api) races the server-side cancellation backstop + (inference-api), and precedence only settles in the session record. By + the next turn the marker is authoritative. + + The two reasons carry opposite signal, so the guidance differs: + `user_stopped` is deliberate feedback (don't barrel onward); + `connection_lost` (or unclassified) is a technical drop (the user likely + still wants the answer). The note is prepended to the persisted user + message (the `original_message`/displayText split keeps it out of the + UI), so it remains an honest in-history record that ages out via + compaction rather than a permanent synthetic system turn. + """ + if reason == "user_stopped": + guidance = ( + "The user deliberately stopped your previous response before it " + "finished (the last assistant message above is the partial that " + "was delivered). Treat that as meaningful feedback β€” do not " + "resume or repeat it on your own; let the user's message below " + "set the direction." + ) + else: # connection_lost / unknown β€” technical drop, no user intent + guidance = ( + "Your previous response was cut off by a connection interruption " + "β€” the user did not stop it deliberately (the last assistant " + "message above is the partial that was delivered). If the user " + "asks you to continue, pick up where it left off instead of " + "starting over." + ) + return f"\n{guidance}\n" + + async def _build_tabular_inventory( session_id: str, assistant_id: str | None, @@ -1002,6 +1074,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g # turn that isn't an interrupt-resume β€” both a fresh turn and a # continuation supersede it. If a continuation itself re-truncates, the # stream_coordinator intercept re-sets the marker. + interrupted_turn_reason: Optional[str] = None if not is_resume: try: from apis.shared.sessions.metadata import clear_truncated_turn @@ -1009,12 +1082,28 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g except Exception as e: logger.error("Failed to clear stale truncated_turn on new turn: %s", e, exc_info=True) + # Same lifecycle for the interrupted-turn marker: any new non-resume + # turn supersedes a prior interruption, so a stale marker can't + # resurrect the "response interrupted" state against a turn the user + # has moved past. The pop returns the settled reason (user_stopped + # beats connection_lost via write precedence) so this same read+write + # also drives the one-turn interruption note prepended to the prompt + # in the stream generator below. + try: + from apis.shared.sessions.metadata import clear_interrupted_turn + interrupted_turn_reason = await clear_interrupted_turn(input_data.session_id, user_id) + except Exception as e: + logger.error("Failed to clear stale interrupted_turn on new turn: %s", e, exc_info=True) + # First turn β†’ kick off title generation concurrently with the stream. # Runs as a background task so it doesn't add latency to TTFT. The # targeted UpdateExpression in update_session_title is race-safe with - # the post-stream _update_session_metadata write. + # the post-stream _update_session_metadata write. The task handle is + # kept so stream_with_quota_warning can push the finished title to the + # client mid-stream as a `session_title` SSE event. + title_task: Optional["asyncio.Task[str]"] = None if is_new_session and input_data.message: - asyncio.create_task( + title_task = asyncio.create_task( generate_conversation_title( session_id=input_data.session_id, user_id=user_id, @@ -1079,6 +1168,15 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g context_chunks = None augmented_message = input_data.message system_prompt = input_data.system_prompt # Start with provided system prompt + # Agent Designer Phase 3: governed capabilities resolved per invoking user + # (D5). None β‡’ resolve exactly as today. Set in the assistant block below, + # consumed at model resolution / prompt assembly; None on resume/continuation. + agent_model_override = None + agent_memory = None + # Agent Designer: an Agent's ``tool`` bindings, resolved per invoker (D5), replace + # the request's ``enabled_tools`` for the turn (like ``model_override`` replaces the + # model). None β‡’ the Agent binds no tools β‡’ the request's enabled_tools drive the turn. + agent_tools_override = None logger.info( "Invocation request - processing with assistant context" @@ -1183,6 +1281,45 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g if assistant.owner_id != user_id: await mark_share_as_interacted(assistant_id=input_data.rag_assistant_id, user_email=current_user.email) + # KB sync inactivity signal: any user's chat use counts. Throttled + # to one write/day inside bump_last_used_at (conditional update); + # the winning bump also wakes any inactivity-paused sync policies. + # Best-effort β€” a bookkeeping failure must never break a chat turn. + try: + from apis.shared.assistants.service import bump_last_used_at + from apis.shared.sync_policies.service import resume_inactive_policies + + if await bump_last_used_at(input_data.rag_assistant_id): + await resume_inactive_policies(input_data.rag_assistant_id) + except Exception as bump_err: + logger.warning(f"lastUsedAt bump failed for assistant {input_data.rag_assistant_id}: {bump_err}") + + # 2b. Agent Designer Phase 3 β€” resolve the Agent's governed capabilities + # for the INVOKING user (D5), before the expensive KB search. v1 blocks + # with a conversational message when the invoker lacks a required model. + if agents_enabled(): + try: + agent_plan = await resolve_agent_invocation(assistant, current_user) + agent_model_override = agent_plan.model_override + agent_memory = agent_plan.memory + agent_tools_override = agent_plan.tools + except AgentBindingBlockedError as block: + blocked_event = ConversationalErrorEvent( + code=ErrorCode.FORBIDDEN, message=block.message, recoverable=False + ) + return StreamingResponse( + stream_conversational_message( + message=block.message, + stop_reason="error", + metadata_event=blocked_event, + session_id=input_data.session_id, + user_id=user_id, + user_input=input_data.message, + ), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "X-Session-ID": input_data.session_id}, + ) + # 3. Search assistant knowledge base logger.info("Starting knowledge base search for assistant...") try: @@ -1248,6 +1385,31 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g "Assistant has no instructions - using fallback system prompt" ) + # 5b. Agent Designer Phase 3: inject the bound Memory Space content (read-only) + # after instructions, in either branch. Hydration re-reads via the invoker + # (MemorySpaceService re-checks viewer+ internally). Empty for a fresh space. + if agent_memory is not None: + from apis.shared.memory.hydration import render_memory_block, resolve_always_load + from apis.shared.memory.service import MemorySpaceService + + try: + fragments = await asyncio.to_thread( + resolve_always_load, + MemorySpaceService(), + agent_memory.space_id, + user_id, + current_user.email, + agent_memory.always_load, + ) + memory_block = render_memory_block(agent_memory.space_name, fragments) + if memory_block: + system_prompt = f"{system_prompt}\n\n{memory_block}" if system_prompt else memory_block + logger.info("Injected bound Memory Space content into system prompt") + except Exception: + # Never fail a turn on a memory-read hiccup β€” the permission was already + # resolved; injection is best-effort context. + logger.error("Failed to hydrate bound Memory Space; continuing", exc_info=True) + # 6. Save assistant_id to session preferences (persist for future loads) # Skip persistence for preview sessions if not is_preview_session(input_data.session_id): @@ -1418,6 +1580,12 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g # saved preference is silently ignored at chat time (#161). effective_model_id = input_data.model_id effective_provider = input_data.provider + if agent_model_override is not None: + # The Agent's governed modelConfig wins over the request / user-default + # chain. Already access-checked against the invoker in the resolver (R2), + # so the earlier request-only gate at the top doesn't leave a hole. + effective_model_id = agent_model_override.model_id + effective_provider = agent_model_override.provider or effective_provider if not effective_model_id: user_default_id, user_default_provider = await _resolve_user_default_model(user_id) if user_default_id: @@ -1436,6 +1604,12 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g "User default model exists but RBAC denies access; falling back to system default" ) + # Agent-authored params sit as defaults BENEATH explicit request params, + # then flow through _resolve_model_settings' admin bounds/locks like any + # other request params β€” an author can't smuggle out-of-bounds values. + if agent_model_override is not None and agent_model_override.params: + request_inference_params = {**agent_model_override.params, **request_inference_params} + # Single registry lookup resolves caching + inference params + # the Mantle endpoint path, merging admin defaults with request # overrides. @@ -1458,22 +1632,36 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g # keeps the assistant id in the URL for the whole session's # lifetime, so we can trust `input_data.rag_assistant_id` # directly; no preferences fallback needed. + # An Agent's tool bindings replace the request's enabled_tools for this + # turn (D5, resolved per invoker above). None β‡’ no tool binding β‡’ the + # request drives the toolset exactly as today. Drives both the built-in + # extra tools (spreadsheet/artifact gate on specific ids) and get_agent. + effective_enabled_tools = ( + agent_tools_override.tool_ids + if agent_tools_override is not None + else input_data.enabled_tools + ) + extra_tools = _build_spreadsheet_tools( - enabled_tools=input_data.enabled_tools, + enabled_tools=effective_enabled_tools, assistant_id=input_data.rag_assistant_id, session_id=input_data.session_id, user_id=user_id, ) + _build_artifact_tools( - enabled_tools=input_data.enabled_tools, + enabled_tools=effective_enabled_tools, session_id=input_data.session_id, user_id=user_id, + ) + _build_memory_tools( + agent_memory=agent_memory, + user_id=user_id, + user_email=current_user.email, ) agent = await get_agent( session_id=input_data.session_id, user_id=user_id, auth_token=auth_token, - enabled_tools=input_data.enabled_tools, + enabled_tools=effective_enabled_tools, model_id=effective_model_id, system_prompt=system_prompt, # Use assistant's instructions if available caching_enabled=caching_enabled, @@ -1525,6 +1713,36 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g # Create stream with optional quota warning injection async def stream_with_quota_warning() -> AsyncGenerator[str, None]: """Wrap agent stream to inject quota warning at start if needed""" + # One-shot `session_title` SSE: once the concurrent title task + # (kicked off before the quota check on first turns) finishes, + # push the title to the client so the sidebar/header rename in + # parallel with the pending response instead of at stream end. + # Checked between agent events β€” never awaited, so it adds no + # latency; a stream that outruns Nova Micro simply never emits + # and the SPA's post-close metadata refresh covers it. + title_emitted = False + + def _session_title_sse() -> Optional[str]: + nonlocal title_emitted + if title_emitted or title_task is None or not title_task.done(): + return None + title_emitted = True + try: + generated_title = title_task.result() + except Exception as title_err: # noqa: BLE001 - cancelled/failed task must not break the stream + logger.warning("Title task unavailable for SSE emit: %s", title_err) + return None + # Generation failures return the "New Conversation" + # placeholder β€” nothing worth pushing over the wire. + if not generated_title or generated_title == "New Conversation": + return None + payload = { + "type": "session_title", + "sessionId": input_data.session_id, + "title": generated_title, + } + return f"event: session_title\ndata: {json.dumps(payload)}\n\n" + # Yield quota warning event first if applicable if quota_warning_event: yield quota_warning_event.to_sse_format() @@ -1547,7 +1765,7 @@ async def stream_with_quota_warning() -> AsyncGenerator[str, None]: # The original text becomes the single source of truth for UI display, # while the full augmented prompt stays in AgentCore Memory for the LLM. attachment_guidance = _build_attachment_guidance( - diverted_tabular, oversized_inline, input_data.enabled_tools + diverted_tabular, oversized_inline, effective_enabled_tools ) # When multiple spreadsheets are visible, ship the full inventory # up front so the agent can disambiguate intentionally instead of @@ -1555,7 +1773,7 @@ async def stream_with_quota_warning() -> AsyncGenerator[str, None]: tabular_inventory = await _build_tabular_inventory( session_id=input_data.session_id, assistant_id=input_data.rag_assistant_id, - enabled_tools=input_data.enabled_tools, + enabled_tools=effective_enabled_tools, ) # Bind to a new local so we don't trip Python's local-scope rules # inside this generator closure (augmented_message is defined in @@ -1579,6 +1797,20 @@ async def stream_with_quota_warning() -> AsyncGenerator[str, None]: if pending_ctx_block: final_message = f"{pending_ctx_block}\n\n{final_message}" + # Interrupted-turn context: the prior turn ended early (Stop / + # refresh / dropped connection) and its marker was popped at + # turn start β€” tell the model, with reason-appropriate + # guidance, before it reads the new message. Prepended last so + # the note sits topmost. Rides the same `original_message` + # displayText split as the ctx block, so the user never sees + # it while it stays an honest part of persisted history. + # Continuation turns skip this (Strands ignores the message + # there β€” the model just continues the persisted partial). + if interrupted_turn_reason: + final_message = ( + f"{_build_interruption_note(interrupted_turn_reason)}\n\n{final_message}" + ) + message_will_be_modified = ( final_message != input_data.message # RAG augmentation / attachment guidance / inventory or bool(files_to_send) # File attachments @@ -1603,6 +1835,13 @@ async def stream_with_quota_warning() -> AsyncGenerator[str, None]: continue_truncated=is_continuation, ): yield event + # Interleave the finished title between agent events (same + # non-blocking drain pattern as the MCP Apps broker in the + # stream coordinator). SSE events are self-delimited, so + # injecting between events is always frame-safe. + title_sse = _session_title_sse() + if title_sse: + yield title_sse # Resume bookkeeping: any interrupt that was submitted in this # request and is no longer present in the agent's interrupt state diff --git a/backend/src/apis/inference_api/chat/service.py b/backend/src/apis/inference_api/chat/service.py index 14deb742..28258dde 100644 --- a/backend/src/apis/inference_api/chat/service.py +++ b/backend/src/apis/inference_api/chat/service.py @@ -3,6 +3,7 @@ Contains business logic for chat operations, including agent creation and management. """ +import asyncio import json import logging import hashlib @@ -372,12 +373,16 @@ async def generate_conversation_title( logger.info("🎯 Generating title (input length: %d chars)", len(truncated_input)) - # Call Bedrock Nova Micro - response = bedrock_client.converse( + # Call Bedrock Nova Micro in a worker thread. boto3's converse() is + # synchronous β€” awaited inline it would block the event loop for the + # whole Nova round-trip, stalling the agent stream this task runs + # concurrently with. + response = await asyncio.to_thread( + bedrock_client.converse, modelId="us.amazon.nova-micro-v1:0", messages=request_body["messages"], system=request_body["system"], - inferenceConfig=request_body["inferenceConfig"] + inferenceConfig=request_body["inferenceConfig"], ) # Extract generated title from response diff --git a/backend/src/apis/shared/assistants/compat.py b/backend/src/apis/shared/assistants/compat.py new file mode 100644 index 00000000..578446b9 --- /dev/null +++ b/backend/src/apis/shared/assistants/compat.py @@ -0,0 +1,72 @@ +"""Agent Designer Phase 1 β€” legacy-Assistant β†’ Agent compat mapping (D2). + +The Agent contract evolves the ``rag-assistants`` store IN PLACE: there is no +parallel table and no migration. A legacy Assistant row (one with no ``bindings`` / +``modelConfig`` attributes) is projected into the Agent shape *on read* by the pure +functions here β€” nothing is ever backfilled. + +Key grounding facts (verified against the live schema): + +- **No per-assistant model exists today.** The model is resolved per invocation + (request β†’ user default β†’ system default). So an absent ``modelConfig`` maps to + ``None`` meaning "resolve exactly as today" β€” we do NOT fabricate a model id (R1). +- **The KB is not first-class yet (F4 deferred).** ``vector_index_id`` is a *shared* + index name, "not user-configurable". The only stable per-Assistant KB identity is + the assistant id itself (retrieval filters vectors by ``assistant_id``). So the + synthesized ``knowledge_base`` binding uses ``ref == assistant_id`` (R4). When F4 + lands, this ref becomes a real KB id with no shape change here. + +NOTE: bindings carry *refs only*, never bodies β€” the METADATA item is 400 KB-capped +and already holds the full instructions. Any future kind needing a per-binding payload +must go to a child row (``AST#{id}/BINDING#…``), not inline here. +""" + +from typing import List + +from apis.shared.assistants.models import AgentBinding, Assistant + + +def effective_bindings(assistant: Assistant) -> List[AgentBinding]: + """Return the Agent's bindings, synthesizing the legacy KB binding when absent. + + - Stored bindings present β†’ returned verbatim (unknown ``kind`` values survive). + - Stored bindings absent β†’ a single ``knowledge_base`` binding whose ``ref`` is + the assistant id (the KB's only stable identity today). + """ + if assistant.bindings is not None: + return assistant.bindings + + return [ + AgentBinding( + kind="knowledge_base", + ref=assistant.assistant_id, + config={"vectorIndexId": assistant.vector_index_id}, + ) + ] + + +def to_agent_view(assistant: Assistant) -> dict: + """Project an Assistant into the resolved Agent read-shape. + + Returns a plain dict (camelCase keys) β€” the HTTP response model is a route concern + (Phase 3). ``agentId`` aliases ``assistantId``; legacy ids remain valid. ``owner_id`` + is deliberately omitted (never returned to clients), matching ``AssistantResponse``. + """ + return { + "agentId": assistant.assistant_id, + "ownerName": assistant.owner_name, + "name": assistant.name, + "description": assistant.description, + "instructions": assistant.instructions, + "modelConfig": assistant.model_settings.model_dump(by_alias=True) if assistant.model_settings else None, + "bindings": [b.model_dump(by_alias=True) for b in effective_bindings(assistant)], + "visibility": assistant.visibility, + "tags": assistant.tags or [], + "starters": assistant.starters or [], + "emoji": assistant.emoji, + "imageUrl": assistant.image_url, + "usageCount": assistant.usage_count, + "status": assistant.status, + "createdAt": assistant.created_at, + "updatedAt": assistant.updated_at, + } diff --git a/backend/src/apis/shared/assistants/models.py b/backend/src/apis/shared/assistants/models.py index a171c31c..ca8c1e18 100644 --- a/backend/src/apis/shared/assistants/models.py +++ b/backend/src/apis/shared/assistants/models.py @@ -1,9 +1,50 @@ """Assistants API request/response models""" -from typing import List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field +# Agent Designer Phase 1 (D3): the uniform binding kinds. Requests validate against +# this set; storage tolerates unknown kinds on read so records written by newer code +# survive a read/write round trip through older code (forward + rollback compat). +KNOWN_BINDING_KINDS = ("knowledge_base", "tool", "skill", "memory_space") +BindingKind = Literal["knowledge_base", "tool", "skill", "memory_space"] + + +class AgentModelConfig(BaseModel): + """Governed single-select model for an Agent (D3). + + The model is NOT a binding β€” it is a required singleton on the Agent record. + Optional in storage/compat, though: a legacy Assistant has no stored model, and + an absent ``modelConfig`` means "resolve the model exactly as today" (request β†’ + user default β†’ system default). The Agent Designer UI enforces single-select at + write time (Phase 4). + """ + + model_config = ConfigDict(populate_by_name=True) + + model_id: str = Field(..., alias="modelId", description="Selected model identifier") + provider: Optional[str] = Field(None, description="Model provider (e.g. 'bedrock'); mirrors InvocationRequest.provider") + params: Optional[Dict[str, Any]] = Field( + None, description="Model parameters (temperature, maxTokens, …); floats stored via Decimal" + ) + + +class AgentBinding(BaseModel): + """A single primitive binding on an Agent (D3). + + ``kind`` is an open string on read (unknown kinds pass through untouched); the + request layer validates it against ``KNOWN_BINDING_KINDS``. Phase 1 resolves only + ``memory_space`` and ``knowledge_base``; ``tool`` and ``skill`` are accepted and + stored but inert (not resolved) until Phase 2/3. + """ + + model_config = ConfigDict(populate_by_name=True) + + kind: str = Field(..., description="Binding kind (see KNOWN_BINDING_KINDS)") + ref: str = Field(..., description="Primitive identifier this binding points at") + config: Dict[str, Any] = Field(default_factory=dict, description="Kind-specific configuration") + class Assistant(BaseModel): """Complete assistant model (internal use)""" @@ -22,11 +63,26 @@ class Assistant(BaseModel): starters: Optional[List[str]] = Field(default_factory=list, description="Conversation starter prompts") emoji: Optional[str] = Field(None, description="Single emoji character for assistant avatar") usage_count: int = Field(0, alias="usageCount", description="Number of times used") + last_used_at: Optional[str] = Field( + None, + alias="lastUsedAt", + description="ISO 8601 timestamp of last chat use (any user); drives the KB-sync inactivity pause", + ) created_at: str = Field(..., alias="createdAt", description="ISO 8601 timestamp of creation") updated_at: str = Field(..., alias="updatedAt", description="ISO 8601 timestamp of last update") status: Literal["DRAFT", "COMPLETE"] = Field(..., description="Assistant lifecycle status") image_url: Optional[str] = Field(None, alias="imageUrl", description="URL to assistant avatar/image") + # Agent Designer Phase 1 (D3): additive, optional. Absent on every legacy row. + # NOTE (R3): the model field cannot be named ``model_config`` β€” pydantic reserves + # that for ConfigDict β€” so it is ``model_settings`` with the ``modelConfig`` alias. + model_settings: Optional[AgentModelConfig] = Field( + None, alias="modelConfig", description="Governed single-select model (D3); absent = resolve as today" + ) + bindings: Optional[List[AgentBinding]] = Field( + None, description="Uniform primitive bindings (D3); absent = synthesize legacy KB binding via compat" + ) + class CreateAssistantDraftRequest(BaseModel): """Request body for creating a draft assistant (minimal fields)""" @@ -49,6 +105,9 @@ class CreateAssistantRequest(BaseModel): starters: Optional[List[str]] = Field(default_factory=list, description="Conversation starter prompts") emoji: Optional[str] = Field(None, description="Single emoji character for assistant avatar") image_url: Optional[str] = Field(None, alias="imageUrl", description="URL to assistant avatar/image") + # Agent Designer Phase 1 (D3): additive, optional. Validated by binding_validation. + model_settings: Optional[AgentModelConfig] = Field(None, alias="modelConfig", description="Governed single-select model") + bindings: Optional[List[AgentBinding]] = Field(None, description="Uniform primitive bindings") class UpdateAssistantRequest(BaseModel): @@ -65,6 +124,9 @@ class UpdateAssistantRequest(BaseModel): emoji: Optional[str] = Field(None, description="Single emoji character for assistant avatar") status: Optional[Literal["DRAFT", "COMPLETE"]] = Field(None, description="Lifecycle status") image_url: Optional[str] = Field(None, alias="imageUrl", description="URL to assistant avatar/image") + # Agent Designer Phase 1 (D3): additive, optional. Validated by binding_validation. + model_settings: Optional[AgentModelConfig] = Field(None, alias="modelConfig", description="Governed single-select model") + bindings: Optional[List[AgentBinding]] = Field(None, description="Uniform primitive bindings") class AssistantResponse(BaseModel): @@ -107,6 +169,86 @@ class AssistantsListResponse(BaseModel): next_token: Optional[str] = Field(None, alias="nextToken", description="Pagination token for next page") +class AgentResponse(BaseModel): + """Agent read-shape (D3) β€” the projection served by the ``/agents/*`` surface. + + Consumes ``compat.to_agent_view`` output directly. ``agentId`` aliases the + assistant id (legacy ids remain valid). Unlike ``AssistantResponse`` this carries + ``modelConfig`` + ``bindings`` β€” the whole point of the Agent surface. + """ + + model_config = ConfigDict(populate_by_name=True) + + agent_id: str = Field(..., alias="agentId", description="Agent identifier (== legacy assistant id)") + owner_name: str = Field(..., alias="ownerName", description="Owner display name") + name: str = Field(..., description="Agent display name") + description: str = Field(..., description="Short summary") + instructions: str = Field(..., description="System prompt") + model_settings: Optional[AgentModelConfig] = Field(None, alias="modelConfig", description="Governed single-select model") + bindings: List[AgentBinding] = Field(default_factory=list, description="Resolved bindings (legacy KB synthesized)") + visibility: Literal["PRIVATE", "PUBLIC", "SHARED"] = Field(..., description="Access control") + tags: Optional[List[str]] = Field(default_factory=list, description="Search keywords") + starters: Optional[List[str]] = Field(default_factory=list, description="Conversation starter prompts") + emoji: Optional[str] = Field(None, description="Single emoji character for agent avatar") + image_url: Optional[str] = Field(None, alias="imageUrl", description="URL to agent avatar/image") + usage_count: int = Field(..., alias="usageCount", description="Usage count") + status: Literal["DRAFT", "COMPLETE"] = Field(..., description="Lifecycle status") + created_at: str = Field(..., alias="createdAt", description="ISO 8601 creation timestamp") + updated_at: str = Field(..., alias="updatedAt", description="ISO 8601 update timestamp") + + # Share metadata (parity with AssistantResponse; set post-projection) + first_interacted: Optional[bool] = Field(None, alias="firstInteracted") + is_shared_with_me: Optional[bool] = Field(None, alias="isSharedWithMe") + user_permission: Optional[Literal["owner", "editor", "viewer"]] = Field(None, alias="userPermission") + + +class AgentsListResponse(BaseModel): + """Response for listing Agents.""" + + model_config = ConfigDict(populate_by_name=True) + + agents: List[AgentResponse] = Field(..., description="Agents visible to the caller") + next_token: Optional[str] = Field(None, alias="nextToken", description="Pagination token for next page") + + +class AgentSharesResponse(BaseModel): + """Share records for an Agent (agentId == assistantId; same underlying records).""" + + model_config = ConfigDict(populate_by_name=True) + + agent_id: str = Field(..., alias="agentId", description="Agent identifier") + shared_with: List["ShareEntry"] = Field(..., alias="sharedWith", description="Share records (email + permission)") + + +class BindableItem(BaseModel): + """A single bindable primitive in the Agent Designer palette (Phase 2, D4). + + The ``GET /agents/bindable?kind=…`` catalog returns a uniform, RBAC-filtered list + of these so every picker in the Designer consumes the same shape. ``ref`` is the + value the UI stores: ``modelConfig.modelId`` for ``kind == "model"``, otherwise a + ``binding.ref``. ``meta`` carries kind-specific display extras (provider, MCP + server sub-tools, memory-space role, …) that the picker renders but never has to + understand structurally. + """ + + model_config = ConfigDict(populate_by_name=True) + + kind: str = Field(..., description="'model' or a binding kind (see KNOWN_BINDING_KINDS)") + ref: str = Field(..., description="Value to store: modelConfig.modelId (model) or binding.ref") + label: str = Field(..., description="Human-readable display name") + description: str = Field("", description="Short summary for the picker") + meta: Dict[str, Any] = Field(default_factory=dict, description="Kind-specific display extras") + + +class BindableListResponse(BaseModel): + """RBAC-filtered catalog of bindable primitives of one kind (Phase 2).""" + + model_config = ConfigDict(populate_by_name=True) + + kind: str = Field(..., description="The requested primitive kind") + items: List[BindableItem] = Field(default_factory=list, description="Primitives the caller may bind") + + class AssistantTestChatRequest(BaseModel): """Request body for testing assistant chat with RAG""" diff --git a/backend/src/apis/shared/assistants/serialization.py b/backend/src/apis/shared/assistants/serialization.py new file mode 100644 index 00000000..07984ce3 --- /dev/null +++ b/backend/src/apis/shared/assistants/serialization.py @@ -0,0 +1,40 @@ +"""DynamoDB-safe (de)serialization for Agent binding/model config payloads. + +DynamoDB rejects Python ``float`` on write and returns ``Decimal`` on read. The Agent +record's ``modelConfig.params`` (temperature, top_p, …) and binding ``config`` blobs are +free-form, so any float nested in them must round-trip through ``Decimal``. Mirrors the +established pattern in ``apis/shared/sessions/metadata.py`` β€” kept here so the assistants +service persistence path (Phase 1 PR-2) has a single, tested helper. +""" + +from decimal import Decimal +from typing import Any + + +def to_ddb_safe(obj: Any) -> Any: + """Recursively convert floats to ``Decimal`` for a DynamoDB write.""" + if isinstance(obj, bool): + # bool is a subclass of int β€” leave it alone (Decimal(str(True)) would raise). + return obj + if isinstance(obj, float): + return Decimal(str(obj)) + if isinstance(obj, dict): + return {k: to_ddb_safe(v) for k, v in obj.items()} + if isinstance(obj, list): + return [to_ddb_safe(v) for v in obj] + return obj + + +def from_ddb(obj: Any) -> Any: + """Recursively convert ``Decimal`` back to native numbers after a DynamoDB read. + + Integral decimals become ``int``; the rest become ``float`` β€” so ``maxTokens`` reads + back as ``4096`` (int), not ``4096.0``. + """ + if isinstance(obj, Decimal): + return int(obj) if obj == obj.to_integral_value() else float(obj) + if isinstance(obj, dict): + return {k: from_ddb(v) for k, v in obj.items()} + if isinstance(obj, list): + return [from_ddb(v) for v in obj] + return obj diff --git a/backend/src/apis/shared/assistants/service.py b/backend/src/apis/shared/assistants/service.py index ffec25f1..5288781c 100644 --- a/backend/src/apis/shared/assistants/service.py +++ b/backend/src/apis/shared/assistants/service.py @@ -11,10 +11,11 @@ import logging import os import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import List, Optional, Tuple -from .models import Assistant +from .models import AgentBinding, AgentModelConfig, Assistant +from .serialization import from_ddb, to_ddb_safe logger = logging.getLogger(__name__) @@ -88,6 +89,8 @@ async def create_assistant( tags: Optional[List[str]] = None, starters: Optional[List[str]] = None, emoji: Optional[str] = None, + bindings: Optional[List[AgentBinding]] = None, + model_settings: Optional[AgentModelConfig] = None, ) -> Assistant: """ Create a complete assistant with all required fields @@ -129,6 +132,8 @@ async def create_assistant( created_at=now, updated_at=now, status="COMPLETE", + bindings=bindings, + model_settings=model_settings, ) # Store the assistant @@ -156,7 +161,9 @@ async def _create_assistant_cloud(assistant: Assistant, table_name: str) -> None dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(table_name) - item = assistant.model_dump(by_alias=True, exclude_none=True) + # to_ddb_safe: modelConfig.params / binding config may carry floats, which + # DynamoDB rejects β€” convert to Decimal on the whole item (strings pass through). + item = to_ddb_safe(assistant.model_dump(by_alias=True, exclude_none=True)) item["PK"] = f"AST#{assistant.assistant_id}" item["SK"] = "METADATA" @@ -293,6 +300,46 @@ async def resolve_assistant_permission( return assistant, None +async def bump_last_used_at(assistant_id: str, throttle_hours: int = 24) -> bool: + """Record that an assistant was used in chat, throttled to one write + per `throttle_hours`. + + Drives the KB-sync inactivity guard (docs/specs/assistant-kb-sync.md + Β§7.3). A conditional write keeps concurrent chat turns from stampeding: + only the caller that actually advances the stale timestamp gets True β€” + that winner is responsible for resuming any paused_inactive policies. + Any user's use counts, not just the owner's. Never raises. + """ + assistants_table = os.environ.get("DYNAMODB_ASSISTANTS_TABLE_NAME") + if not assistants_table: + return False + + try: + import boto3 + from botocore.exceptions import ClientError + + now_dt = datetime.now(timezone.utc) + floor = (now_dt - timedelta(hours=throttle_hours)).isoformat() + "Z" + table = boto3.resource("dynamodb").Table(assistants_table) + table.update_item( + Key={"PK": f"AST#{assistant_id}", "SK": "METADATA"}, + UpdateExpression="SET lastUsedAt = :now", + ExpressionAttributeValues={":now": now_dt.isoformat() + "Z", ":floor": floor}, + ConditionExpression=( + "attribute_exists(PK) AND (attribute_not_exists(lastUsedAt) OR lastUsedAt < :floor)" + ), + ) + return True + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + return False # fresh enough, or assistant gone β€” nothing to do + logger.warning(f"Failed to bump lastUsedAt for {assistant_id}: {e}") + return False + except Exception as e: + logger.warning(f"Failed to bump lastUsedAt for {assistant_id}: {e}") + return False + + async def assistant_exists(assistant_id: str) -> bool: """ Check if assistant exists (without access check) @@ -345,7 +392,7 @@ async def _get_assistant_cloud(assistant_id: str, owner_id: str, table_name: str logger.warning(f"Access denied: assistant {assistant_id} not owned by user {owner_id}") return None - return Assistant.model_validate(item) + return Assistant.model_validate(from_ddb(item)) except ClientError as e: error_code = e.response.get("Error", {}).get("Code", "Unknown") @@ -387,7 +434,7 @@ async def _get_assistant_cloud_without_ownership_check(assistant_id: str, table_ return None item = response["Item"] - return Assistant.model_validate(item) + return Assistant.model_validate(from_ddb(item)) except ClientError as e: error_code = e.response.get("Error", {}).get("Code", "Unknown") @@ -417,6 +464,8 @@ async def update_assistant( emoji: Optional[str] = None, status: Optional[str] = None, image_url: Optional[str] = None, + bindings: Optional[List[AgentBinding]] = None, + model_settings: Optional[AgentModelConfig] = None, ) -> Optional[Assistant]: """ Update assistant fields (deep merge) @@ -465,6 +514,12 @@ async def update_assistant( updates["status"] = status if image_url is not None: updates["image_url"] = image_url + # Phase 1: an explicit bindings list (incl. []) replaces the set; absent = untouched. + # Clearing modelConfig to null via merge isn't supported yet (exclude_none) β€” R5. + if bindings is not None: + updates["bindings"] = bindings + if model_settings is not None: + updates["model_settings"] = model_settings # Always update the updated_at timestamp updates["updated_at"] = _get_current_timestamp() @@ -524,8 +579,9 @@ async def _update_assistant_cloud(assistant: Assistant, table_name: str) -> None update_parts.append("updatedAt = :updated_at") expression_attribute_values[":updated_at"] = assistant.updated_at - # Update all fields from assistant model (excluding immutable fields) - assistant_dict = assistant.model_dump(by_alias=True, exclude_none=True) + # Update all fields from assistant model (excluding immutable fields). + # to_ddb_safe converts any float in modelConfig.params / binding config to Decimal. + assistant_dict = to_ddb_safe(assistant.model_dump(by_alias=True, exclude_none=True)) # DynamoDB reserved keywords that need to be escaped reserved_keywords = {"status", "name", "data", "size", "type", "value"} @@ -757,7 +813,7 @@ async def _list_user_assistants_cloud( all_assistants = [] for item in owner_response.get("Items", []): try: - all_assistants.append(Assistant.model_validate(item)) + all_assistants.append(Assistant.model_validate(from_ddb(item))) except Exception as e: logger.warning(f"Failed to parse assistant item: {e}") continue diff --git a/backend/src/apis/shared/embeddings/bedrock_embeddings.py b/backend/src/apis/shared/embeddings/bedrock_embeddings.py index 962031b9..22d1ac3d 100644 --- a/backend/src/apis/shared/embeddings/bedrock_embeddings.py +++ b/backend/src/apis/shared/embeddings/bedrock_embeddings.py @@ -292,6 +292,42 @@ async def delete_vectors_for_document_deterministic( return chunk_count +async def delete_vector_tail(document_id: str, from_index: int, to_index: int) -> int: + """ + Delete vectors {document_id}#{i} for i in [from_index, to_index). + + Shrinkage cleanup for KB sync re-ingestion: when a re-indexed document + produces fewer chunks than before, the in-place vector overwrite leaves + stale tail chunks behind β€” this removes exactly that tail. Deletion of + non-existent keys is a no-op in the S3 Vectors API. + + Returns: + Number of keys sent for deletion (0 if the range is empty) + """ + if to_index <= from_index: + return 0 + + client = boto3.client("s3vectors", region_name=AWS_REGION) + vector_bucket = _get_vector_store_bucket() + vector_index = _get_vector_store_index() + + keys = [f"{document_id}#{i}" for i in range(from_index, to_index)] + batch_size = 500 + + for i in range(0, len(keys), batch_size): + batch = keys[i : i + batch_size] + client.delete_vectors( + vectorBucketName=vector_bucket, + indexName=vector_index, + keys=batch, + ) + + logger.info( + f"Tail delete: sent {len(keys)} keys ({from_index}..{to_index - 1}) for document {document_id}" + ) + return len(keys) + + async def delete_vectors_for_assistant(assistant_id: str) -> int: """ Delete ALL vectors belonging to an assistant from the S3 vector store. diff --git a/backend/src/apis/shared/feature_flags.py b/backend/src/apis/shared/feature_flags.py index c7bbbec3..58554484 100644 --- a/backend/src/apis/shared/feature_flags.py +++ b/backend/src/apis/shared/feature_flags.py @@ -1,9 +1,10 @@ """Process-level feature flags resolved from environment variables. -These gate optional product surfaces that ship in the codebase but stay -disabled for an environment until explicitly turned on β€” mirroring the -``FINE_TUNING_ENABLED`` pattern used in app-api. Each flag is read on every -call (not cached at import) so that: +These gate optional product surfaces per environment. Each flag documents +its own default: deferred features default off until explicitly turned on +(the ``FINE_TUNING_ENABLED`` pattern), while shipping features default on +with a kill switch (the ``KB_SYNC_ENABLED`` pattern). Each flag is read on +every call (not cached at import) so that: * import-time callers (conditional router mounting) and per-request callers observe the same value, and @@ -24,3 +25,55 @@ def skills_enabled() -> bool: unmounted / hidden, but all skills data and code remain intact. """ return os.environ.get("SKILLS_ENABLED", "false").lower() == "true" + + +def scheduled_runs_enabled() -> bool: + """Whether the scheduled-runs surface is enabled for this environment. + + Covers the headless "Run now" route and headless-grant lifecycle today, + and the schedule CRUD + dispatcher when Phase B lands. **Default ON + with a kill switch** (house style, mirroring ``CDK_KB_SYNC_ENABLED``): + unset or empty resolves to enabled; only the literal ``"false"`` + (case-insensitive) disables. The CDK side threads + ``config.scheduledRuns.enabled`` into this env var with the same + empty-string-safe ternary, so an unset GitHub Actions variable can + never silently turn the feature off. + + Note this flag gates *feature existence* per environment; *who* can use + it is the ``scheduled-runs`` RBAC capability + (``apis.shared.rbac.capabilities``) β€” two independent controls. + """ + return os.environ.get("SCHEDULED_RUNS_ENABLED", "").strip().lower() != "false" + + +def memory_spaces_enabled() -> bool: + """Whether the Memory Spaces feature is enabled for this environment. + + Covers the user-owned/shareable markdown "second brain" surface (F5): + the app-api ``/memory/spaces`` CRUD, the runtime read/write tools, and + the SPA Memory panel. **Defaults off** (the ``SKILLS_ENABLED`` pattern): + set ``MEMORY_SPACES_ENABLED=true`` to turn it on. While off, the surfaces + are unmounted / hidden but all data and code remain intact. The feature + ships incrementally across several PRs, so it stays dark until complete. + + Note this flag gates *feature existence* per environment; *who* can use it + will be an RBAC capability β€” two independent controls (mirroring + ``scheduled_runs_enabled``). + """ + return os.environ.get("MEMORY_SPACES_ENABLED", "false").lower() == "true" + + +def agents_enabled() -> bool: + """Whether the Agent Designer surface is enabled for this environment. + + Gates the ``/agents/*`` alias router (the governed Agent read/write surface over + the evolved assistant store). **Defaults off** (the ``MEMORY_SPACES_ENABLED`` + pattern): set ``AGENTS_API_ENABLED=true`` to turn it on. The feature ships + incrementally across several PRs (contract β†’ surface β†’ resolution β†’ Designer UI), + so it stays dark until complete β€” the assistant store and its ``/assistants/*`` + surface are unaffected while off. + + Gates *feature existence* per environment; *who* may use a specific agent is the + identity-based access check already enforced by the assistant service. + """ + return os.environ.get("AGENTS_API_ENABLED", "false").lower() == "true" diff --git a/backend/src/apis/shared/harness/__init__.py b/backend/src/apis/shared/harness/__init__.py new file mode 100644 index 00000000..1070d1d1 --- /dev/null +++ b/backend/src/apis/shared/harness/__init__.py @@ -0,0 +1,52 @@ +"""Headless agent-run harness (primitive F1). + +The trigger-agnostic entrypoint for running an agent turn as a user with no +live browser session: schedules, "Run now", A2A, webhooks, and eval harnesses +are all just callers of :func:`run_agent_headless`. + +Lives in ``apis.shared`` because it is consumed by more than one service +(app-api "Run now" routes, the Phase-B dispatcher/worker Lambdas) and must +never be an inference-api route β€” the AgentCore Runtime data plane only +exposes ``/invocations`` + ``/ping`` (see CLAUDE.md, Inference API boundary). +The harness is a *client* of ``/invocations``, not a new server surface. + +Design + spike evidence: docs/specs/harness-entrypoint-spike-findings.md. +""" + +from apis.shared.harness.auth import ( + BearerAuthStrategy, + CognitoRefreshBearerAuth, + HeadlessAuthError, + StaticBearerAuth, +) +from apis.shared.harness.governance import GovernanceFloor, RunAuditRecorder +from apis.shared.harness.grants import ( + HEADLESS_GRANT_MAX_AGE_DAYS, + HeadlessGrant, + HeadlessGrantService, +) +from apis.shared.harness.models import ( + OAuthConsentRequired, + RunResult, + RunStatus, + ToolTraceEntry, +) +from apis.shared.harness.runner import build_invocations_url, run_agent_headless + +__all__ = [ + "BearerAuthStrategy", + "CognitoRefreshBearerAuth", + "GovernanceFloor", + "HEADLESS_GRANT_MAX_AGE_DAYS", + "HeadlessAuthError", + "HeadlessGrant", + "HeadlessGrantService", + "OAuthConsentRequired", + "RunAuditRecorder", + "RunResult", + "RunStatus", + "StaticBearerAuth", + "ToolTraceEntry", + "build_invocations_url", + "run_agent_headless", +] diff --git a/backend/src/apis/shared/harness/auth.py b/backend/src/apis/shared/harness/auth.py new file mode 100644 index 00000000..7366195f --- /dev/null +++ b/backend/src/apis/shared/harness/auth.py @@ -0,0 +1,140 @@ +"""Unattended bearer minting for headless runs. + +The AgentCore Runtime is provisioned with a **Cognito customJwtAuthorizer** +(`inference-agentcore-construct.ts`: discovery URL = the user pool, +`allowedClients` = [BFF app client]). Spike probes against dev-ai proved +(see docs/specs/harness-entrypoint-spike-findings.md, Unknown 1): + +- A platform **workload access token** (`GetWorkloadAccessTokenForUserId`) + is NOT accepted as the `/invocations` bearer β€” it is an opaque encrypted + blob, not a JWT; the gateway rejects it with + ``403 {"message": "OAuth authorization failed: Failed to parse token"}``. +- **SigV4** (`invoke_agent_runtime`) is also rejected once a JWT authorizer + is configured: ``AccessDeniedException: Authorization method mismatch``. + +So the only front door is a real Cognito access token for the owning user, +minted by the platform. :class:`CognitoRefreshBearerAuth` implements that: +exchange the refresh token pinned in the user's **headless-grant record** +(`apis.shared.harness.grants` β€” created when the user enables headless +runs, revocable, TTL-bounded) via `REFRESH_TOKEN_AUTH` + SECRET_HASH β€” the +exact machinery `SessionRefreshMiddleware` already runs for browser +sessions. + +The minted token then works three layers deep: gateway JWT authorizer β†’ +container `get_current_user_trusted` (`sub` β†’ user_id, so memory/RBAC/ +quota/session all resolve to the right user) β†’ forwarded to forward-auth +MCP servers, which validate `client_id == BFF client`. + +The workload identity is still essential β€” but one layer down: *inside* the +runtime, connector tokens are minted from the vault via +`GetWorkloadAccessTokenForUserId` keyed by the `sub` of the bearer we send +(`apis/shared/oauth/agentcore_identity.py`). The front-door bearer and the +vault leg are two different trust boundaries. +""" + +from __future__ import annotations + +import logging +from typing import Optional, Protocol + +from apis.shared.harness.grants import HeadlessGrantService +from apis.shared.sessions_bff.refresh import CognitoRefreshClient, CognitoRefreshError + +logger = logging.getLogger(__name__) + + +class HeadlessAuthError(RuntimeError): + """No bearer could be minted for the requested user. + + Raised when the user has no active headless grant (never enabled, + revoked, or expired past the login-recency window) or when Cognito + refuses the refresh exchange. For a scheduled trigger this should pause + the schedule (analogous to KB-sync's ``paused_reauth``) β€” the user must + log in and re-enable before headless runs can act as them again. + """ + + +class BearerAuthStrategy(Protocol): + """Seam between the runner and however a bearer is obtained. + + Additional strategies (e.g. an M2M client + trusted `user_id` payload, + were the runtime's authorizer ever reconfigured) can be added without + touching the runner. + """ + + async def mint_bearer_for_user(self, user_id: str) -> str: ... + + +class StaticBearerAuth: + """Wrap an already-obtained token (tests; callers with a live token).""" + + def __init__(self, token: str) -> None: + self._token = token + + async def mint_bearer_for_user(self, user_id: str) -> str: + return self._token + + +class CognitoRefreshBearerAuth: + """Mint a per-owner access token from the user's headless grant. + + Resolves the newest active :class:`~apis.shared.harness.grants.HeadlessGrant` + for ``user_id`` (a GSI query β€” no table Scan) and runs the standard + Cognito refresh exchange against its pinned refresh token. Requires the + caller's IAM principal to read/write the BFF sessions table (where + grants live) and the BFF app-client secret β€” app-api's role already + holds both grants. + + Rotation-aware: if Cognito ever rotates the refresh token during a mint + (rotation is off on this pool today), the replacement is persisted back + onto the grant before the access token is returned, so the grant is + never stranded holding a dead token. + """ + + def __init__( + self, + *, + grants: Optional[HeadlessGrantService] = None, + refresh_client: Optional[CognitoRefreshClient] = None, + ) -> None: + self._grants = grants or HeadlessGrantService() + self._refresh_client = refresh_client or CognitoRefreshClient() + + async def mint_bearer_for_user(self, user_id: str) -> str: + """Return a fresh Cognito access token for ``user_id``. + + Raises: + HeadlessAuthError: no active grant, or Cognito refused the + refresh exchange (token expired/revoked upstream). + """ + grant = await self._grants.get_active_grant(user_id) + if grant is None: + raise HeadlessAuthError( + f"No active headless grant for user {user_id}; the user must " + "log in and enable headless runs before the platform can act " + "as them." + ) + try: + refreshed = await self._refresh_client.refresh( + username=grant.username, + refresh_token=grant.cognito_refresh_token, + ) + except CognitoRefreshError as exc: + raise HeadlessAuthError( + f"Cognito refused the refresh exchange for user {user_id} " + f"(grant {grant.grant_id}): {exc}" + ) from exc + + if refreshed.refresh_token != grant.cognito_refresh_token: + logger.info( + "Cognito rotated the refresh token during a headless mint for " + "user %s; persisting the replacement onto grant %s", + user_id, + grant.grant_id, + ) + await self._grants.persist_rotated_refresh_token( + grant.grant_id, refreshed.refresh_token + ) + + await self._grants.record_use(grant.grant_id) + return refreshed.access_token diff --git a/backend/src/apis/shared/harness/governance.py b/backend/src/apis/shared/harness/governance.py new file mode 100644 index 00000000..a5abadc9 --- /dev/null +++ b/backend/src/apis/shared/harness/governance.py @@ -0,0 +1,186 @@ +"""Governance floor (F6a) seams for headless runs. + +The moment an agent turn runs unattended *as a user*, it touches user data +with no human in the loop β€” so every headless run passes through this floor. +The "Run now" phase (PR-1) ships **audit-only + wired seams** (locked +decision, agentic-platform-primitives.md Β§6): the audit checkpoints are +implemented and fail-closed on start; the guardrails and +data-classification checkpoints are deliberate no-op seams whose +implementations are gated to the *scheduled* (fully unattended) trigger in +a later phase. "Run now" is user-initiated and attended β€” the human is in +the loop β€” so the audit trail is the floor it needs. + +Hook points (called by ``run_agent_headless`` in this order): + +1. ``on_run_start`` β€” AUDIT (implemented): who/what/when/trigger, before + any token is minted or model called. **Fail-closed**: no audit record β†’ + no run. +2. ``check_input`` β€” GUARDRAILS seam (no-op): the scheduled-runs phase + calls Bedrock ``ApplyGuardrail`` on the prompt here; a blocked verdict + raises before the run spends tokens or reads user data. +3. ``classify_output`` β€” DATA-CLASSIFICATION seam (no-op): the + scheduled-runs phase runs the PII/FERPA checkpoint over the final + message (and tool-result previews) here, before delivery. +4. ``on_run_end`` β€” AUDIT (implemented): outcome, stop reason, tool + names, usage. Best-effort. + +Audit records are written to the sessions-metadata table under +``PK=USER#{user_id}, SK=RUN#{run_id}``. The session listing queries +``begins_with(SK, 'S#ACTIVE#')`` and the GSI lookups use their own key +shapes, so ``RUN#`` items are invisible to every existing access path. +When the scheduler lands (Phase B), promote run-records to their own table +(or a documented SK family) with a "recent runs per user" GSI. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +import boto3 + +from apis.shared.harness.models import RunResult + +logger = logging.getLogger(__name__) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +class RunAuditRecorder: + """Minimal durable audit trail for headless runs (the implemented slice).""" + + def __init__( + self, *, table_name: Optional[str] = None, region: Optional[str] = None + ) -> None: + self._table_name = table_name or os.environ.get( + "DYNAMODB_SESSIONS_METADATA_TABLE_NAME", "" + ) + self._region = region or os.environ.get("AWS_REGION", "us-west-2") + + def _table(self): + if not self._table_name: + raise RuntimeError( + "DYNAMODB_SESSIONS_METADATA_TABLE_NAME is required for run auditing" + ) + return boto3.resource("dynamodb", region_name=self._region).Table( + self._table_name + ) + + def record_start( + self, + *, + run_id: str, + user_id: str, + session_id: str, + trigger: str, + prompt: str, + ) -> None: + self._table().put_item( + Item={ + "PK": f"USER#{user_id}", + "SK": f"RUN#{run_id}", + "runId": run_id, + "userId": user_id, + "sessionId": session_id, + "trigger": trigger, + "promptSha256": hashlib.sha256(prompt.encode()).hexdigest(), + "promptChars": len(prompt), + "status": "started", + "startedAt": _now_iso(), + } + ) + + def record_end(self, *, result: RunResult) -> None: + self._table().update_item( + Key={"PK": f"USER#{result.user_id}", "SK": f"RUN#{result.run_id}"}, + UpdateExpression=( + "SET #s = :s, stopReason = :sr, errorDetail = :e, " + "toolNames = :t, #u = :u, finishedAt = :f, " + "finalMessageChars = :c" + ), + ExpressionAttributeNames={"#s": "status", "#u": "usage"}, + ExpressionAttributeValues={ + ":s": result.status, + ":sr": result.stop_reason or "", + ":e": (result.error or "")[:1000], + ":t": [t.name for t in result.tool_trace], + # Dynamo rejects floats; usage payloads are ints but guard + # against provider metrics like latency ratios. + ":u": _dynamo_safe(result.usage), + ":f": _now_iso(), + ":c": len(result.final_message), + }, + ) + + +def _dynamo_safe(value: Any) -> Any: + if isinstance(value, float): + return str(value) + if isinstance(value, dict): + return {k: _dynamo_safe(v) for k, v in value.items()} + if isinstance(value, list): + return [_dynamo_safe(v) for v in value] + return value + + +class GovernanceFloor: + """F6a checkpoint bundle every headless run passes through.""" + + def __init__(self, *, audit: Optional[RunAuditRecorder] = None) -> None: + self._audit = audit or RunAuditRecorder() + + async def on_run_start( + self, + *, + run_id: str, + user_id: str, + session_id: str, + trigger: str, + prompt: str, + ) -> None: + """AUDIT β€” implemented. Failure here fails the run (a run that can't + be audited must not execute unattended).""" + self._audit.record_start( + run_id=run_id, + user_id=user_id, + session_id=session_id, + trigger=trigger, + prompt=prompt, + ) + + async def check_input(self, *, prompt: str, user_id: str) -> None: + """GUARDRAILS seam β€” deliberate no-op for attended "Run now". + + Scheduled-runs phase: Bedrock ``ApplyGuardrail`` (source=INPUT) on + the prompt; raise a ``GovernanceBlocked`` error on a blocked + verdict so the runner records an audited, non-retryable failure + before any token is spent. The runner already calls this on every + run, so filling it in requires no control-flow change. + """ + + async def classify_output(self, *, result: RunResult) -> None: + """DATA-CLASSIFICATION seam β€” deliberate no-op for attended "Run now". + + Scheduled-runs phase: PII/FERPA checkpoint over + ``result.final_message`` and ``result.tool_trace`` previews before + delivery. May redact (mutate the result) or block (raise), per + policy. The runner already calls this before delivery on every run. + """ + + async def on_run_end(self, *, result: RunResult) -> None: + """AUDIT β€” implemented. Best-effort: a failed end-write must not + destroy an otherwise-delivered result (the start record already + pins the run's existence).""" + try: + self._audit.record_end(result=result) + except Exception: + logger.error( + "Failed to write run-end audit record for %s", + result.run_id, + exc_info=True, + ) diff --git a/backend/src/apis/shared/harness/grants.py b/backend/src/apis/shared/harness/grants.py new file mode 100644 index 00000000..b9dd6263 --- /dev/null +++ b/backend/src/apis/shared/harness/grants.py @@ -0,0 +1,331 @@ +"""Headless-run grants β€” the durable "act as me" record for unattended runs. + +A headless run mints a per-owner Cognito access token (see +``apis.shared.harness.auth``). The credential that mint consumes must NOT be +a silently-reused BFF browser session: sessions are an authentication +artifact with an 8-hour sliding TTL and no user-visible lifecycle. Instead, +each user who enables headless runs gets an explicit **headless-grant +record** with its own consent/revocation lifecycle: + +* **Create-on-enable** β€” when a user turns on the feature (today: the "Run + now" route; later: the schedules SPA), the grant pins the refresh token + from their *live, attended* session. Enabling again re-pins the token and + slides the expiry window. +* **Lookup** β€” unattended callers resolve the newest active grant by + ``user_id`` via a sparse GSI (a direct query, replacing the spike's + full-table ``Scan``). +* **Revoke** β€” the user (or an admin) can kill the grant at any time; the + stored refresh token is removed in the same write so a revoked record + retains no usable credential. + +Storage: items live in the BFF sessions table (same data classification β€” +it already holds Cognito refresh tokens β€” and the same IAM grants), keyed +``PK=HEADLESS-GRANT#{grant_id}, SK=META`` so they are invisible to every +session access path (sessions key by ``SESSION#{id}``). Only grant items +carry the ``grant_user_id`` attribute, so the ``HeadlessGrantUserIndex`` +GSI (``grant_user_id`` / ``created_at``) is sparse: session rows never +project into it. + +**Login-recency policy (documented product decision):** the platform may +act headlessly as a user only within ``HEADLESS_GRANT_MAX_AGE_DAYS`` +(default **30**, matching the Cognito app client's refresh-token validity β€” +the CDK default we deploy with) of the login that produced the pinned +token. The grant's DynamoDB TTL is anchored to that login +(``token_issued_at``), so the record expires no later than the token it +wraps. Cognito remains the hard ceiling either way: a refresh exchange +against a token older than the pool's validity fails and surfaces as +``HeadlessAuthError``, which scheduled callers should treat as +"pause until the user logs in again" (the KB-sync ``paused_reauth`` +analog). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import time +import uuid +from dataclasses import dataclass +from typing import Optional + +import boto3 +from boto3.dynamodb.conditions import Key +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) + +#: "Must have logged in within N days" β€” see the module docstring. Matches +#: the Cognito refresh-token validity (CDK default: 30 days). +HEADLESS_GRANT_MAX_AGE_DAYS = 30 + +GRANT_USER_INDEX_NAME = "HeadlessGrantUserIndex" + +_STATUS_ACTIVE = "active" +_STATUS_REVOKED = "revoked" + + +def _max_age_seconds() -> int: + days = int(os.environ.get("HEADLESS_GRANT_MAX_AGE_DAYS", HEADLESS_GRANT_MAX_AGE_DAYS)) + return days * 24 * 60 * 60 + + +@dataclass +class HeadlessGrant: + """One user's standing consent for the platform to run as them.""" + + grant_id: str + user_id: str + username: str # Cognito username; required for SECRET_HASH on refresh + cognito_refresh_token: str + status: str # "active" | "revoked" + created_at: int # epoch seconds (grant creation) + updated_at: int # epoch seconds (last enable/re-pin or token rotation) + token_issued_at: int # epoch seconds β€” login that produced the token + ttl: int # epoch seconds; DynamoDB TTL = token_issued_at + max age + last_used_at: Optional[int] = None + revoked_at: Optional[int] = None + + @property + def is_active(self) -> bool: + return self.status == _STATUS_ACTIVE and self.ttl > int(time.time()) + + +class HeadlessGrantService: + """Create-on-enable / lookup / revoke for headless-run grants. + + Async-shaped like ``SessionRepository``: every boto3 round-trip is + offloaded via ``asyncio.to_thread`` so the event loop stays free. + """ + + def __init__( + self, + *, + table_name: Optional[str] = None, + region: Optional[str] = None, + ) -> None: + self._table_name = table_name or os.environ.get("BFF_SESSIONS_TABLE_NAME", "") + self._region = region or os.environ.get("AWS_REGION", "us-west-2") + self._table = None + + @property + def enabled(self) -> bool: + return bool(self._table_name) + + def _get_table(self): + if self._table is None: + if not self._table_name: + raise RuntimeError( + "BFF_SESSIONS_TABLE_NAME is required for headless grants" + ) + self._table = boto3.resource( + "dynamodb", region_name=self._region + ).Table(self._table_name) + return self._table + + @staticmethod + def _key(grant_id: str) -> dict: + return {"PK": f"HEADLESS-GRANT#{grant_id}", "SK": "META"} + + @staticmethod + def _item_to_grant(item: dict) -> HeadlessGrant: + return HeadlessGrant( + grant_id=item["grant_id"], + user_id=item["grant_user_id"], + username=item["username"], + cognito_refresh_token=item.get("cognito_refresh_token", ""), + status=item["status"], + created_at=int(item["created_at"]), + updated_at=int(item["updated_at"]), + token_issued_at=int(item["token_issued_at"]), + ttl=int(item["ttl"]), + last_used_at=int(item["last_used_at"]) if "last_used_at" in item else None, + revoked_at=int(item["revoked_at"]) if "revoked_at" in item else None, + ) + + def _query_grants_sync(self, user_id: str) -> list[dict]: + """Newest-first grant items for a user via the sparse GSI.""" + table = self._get_table() + items: list[dict] = [] + kwargs: dict = { + "IndexName": GRANT_USER_INDEX_NAME, + "KeyConditionExpression": Key("grant_user_id").eq(user_id), + "ScanIndexForward": False, + } + while True: + page = table.query(**kwargs) + items.extend(page.get("Items", [])) + if "LastEvaluatedKey" not in page: + break + kwargs["ExclusiveStartKey"] = page["LastEvaluatedKey"] + return items + + async def get_active_grant(self, user_id: str) -> Optional[HeadlessGrant]: + """Return the newest active, unexpired grant for ``user_id``. + + Expiry is checked application-side too (DynamoDB TTL eviction is + best-effort, not real-time β€” same defense-in-depth as the session + repository). + """ + items = await asyncio.to_thread(self._query_grants_sync, user_id) + for item in items: + grant = self._item_to_grant(item) + if grant.is_active: + return grant + return None + + async def enable( + self, + *, + user_id: str, + username: str, + refresh_token: str, + token_issued_at: Optional[int] = None, + ) -> HeadlessGrant: + """Create (or renew) the user's grant from an attended session. + + Callers pass the refresh token from the user's *live* BFF session + plus that session's ``created_at`` as ``token_issued_at`` β€” the + login instant anchors the grant's TTL, because Cognito's + refresh-token validity runs from token issuance, not from when we + pin it. Re-enabling re-pins the token onto the existing grant + (stable ``grant_id`` for audit continuity) and slides the window. + """ + now = int(time.time()) + issued_at = token_issued_at or now + ttl = issued_at + _max_age_seconds() + + existing = await self.get_active_grant(user_id) + if existing is not None: + def _renew() -> None: + self._get_table().update_item( + Key=self._key(existing.grant_id), + UpdateExpression=( + "SET cognito_refresh_token = :rt, updated_at = :now, " + "token_issued_at = :iss, #ttl = :ttl" + ), + ExpressionAttributeNames={"#ttl": "ttl"}, + ExpressionAttributeValues={ + ":rt": refresh_token, + ":now": now, + ":iss": issued_at, + ":ttl": ttl, + }, + ) + + await asyncio.to_thread(_renew) + logger.info( + "Renewed headless grant %s for user %s", existing.grant_id, user_id + ) + existing.cognito_refresh_token = refresh_token + existing.updated_at = now + existing.token_issued_at = issued_at + existing.ttl = ttl + return existing + + grant = HeadlessGrant( + grant_id=f"hlg-{uuid.uuid4().hex[:12]}", + user_id=user_id, + username=username, + cognito_refresh_token=refresh_token, + status=_STATUS_ACTIVE, + created_at=now, + updated_at=now, + token_issued_at=issued_at, + ttl=ttl, + ) + + def _put() -> None: + self._get_table().put_item( + Item={ + **self._key(grant.grant_id), + "grant_id": grant.grant_id, + "grant_user_id": grant.user_id, + "username": grant.username, + "cognito_refresh_token": grant.cognito_refresh_token, + "status": grant.status, + "created_at": grant.created_at, + "updated_at": grant.updated_at, + "token_issued_at": grant.token_issued_at, + "ttl": grant.ttl, + } + ) + + await asyncio.to_thread(_put) + logger.info("Created headless grant %s for user %s", grant.grant_id, user_id) + return grant + + async def revoke(self, user_id: str) -> bool: + """Revoke every active grant for ``user_id``. + + The stored refresh token is REMOVEd in the same write β€” a revoked + record keeps its audit fields but no usable credential. Returns + True iff at least one grant was revoked. + """ + items = await asyncio.to_thread(self._query_grants_sync, user_id) + now = int(time.time()) + revoked_any = False + for item in items: + if item.get("status") != _STATUS_ACTIVE: + continue + grant_id = str(item["grant_id"]) + + def _revoke(gid: str = grant_id) -> None: + self._get_table().update_item( + Key=self._key(gid), + UpdateExpression=( + "SET #s = :revoked, revoked_at = :now, updated_at = :now " + "REMOVE cognito_refresh_token" + ), + ExpressionAttributeNames={"#s": "status"}, + ExpressionAttributeValues={":revoked": _STATUS_REVOKED, ":now": now}, + ) + + await asyncio.to_thread(_revoke) + logger.info("Revoked headless grant %s for user %s", grant_id, user_id) + revoked_any = True + return revoked_any + + async def persist_rotated_refresh_token( + self, grant_id: str, refresh_token: str + ) -> None: + """Persist a rotated refresh token back onto the grant. + + The pool we deploy does not rotate refresh tokens today, but if + rotation is ever enabled the old token dies the moment Cognito + rotates it β€” failing to persist the replacement would strand the + grant after one headless mint. Callers treat failures as fatal for + the mint (better to fail loudly than to silently burn the grant's + last valid token). + """ + + def _update() -> None: + self._get_table().update_item( + Key=self._key(grant_id), + UpdateExpression=( + "SET cognito_refresh_token = :rt, updated_at = :now" + ), + ConditionExpression="attribute_exists(PK)", + ExpressionAttributeValues={ + ":rt": refresh_token, + ":now": int(time.time()), + }, + ) + + await asyncio.to_thread(_update) + + async def record_use(self, grant_id: str) -> None: + """Best-effort ``last_used_at`` touch β€” never fails a run.""" + + def _touch() -> None: + self._get_table().update_item( + Key=self._key(grant_id), + UpdateExpression="SET last_used_at = :now", + ConditionExpression="attribute_exists(PK)", + ExpressionAttributeValues={":now": int(time.time())}, + ) + + try: + await asyncio.to_thread(_touch) + except (ClientError, RuntimeError) as exc: + logger.warning("Headless grant %s last-used touch failed: %s", grant_id, exc) diff --git a/backend/src/apis/shared/harness/models.py b/backend/src/apis/shared/harness/models.py new file mode 100644 index 00000000..3e1882b1 --- /dev/null +++ b/backend/src/apis/shared/harness/models.py @@ -0,0 +1,66 @@ +"""Result shapes for headless agent runs. + +``RunResult`` is the structured return every trigger consumes (schedule +worker, "Run now" route, future A2A server front). Keep it JSON-friendly: +``asdict(result)`` must serialize cleanly so it can become a run-record +item or an A2A task artifact without translation. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Literal, Optional + +# completed β€” stream drained to `done` with no stream-level error +# error β€” HTTP error, `stream_error`/`error` event, or transport failure +# timeout β€” the SSE stream exceeded the caller's budget +# oauth_required β€” the turn finished but at least one connector tool needs +# user consent (headless runs cannot pop a consent window; +# callers should surface the authorization URL to the user) +RunStatus = Literal["completed", "error", "timeout", "oauth_required"] + + +@dataclass +class ToolTraceEntry: + """One tool invocation observed on the stream.""" + + tool_use_id: str + name: str + input: Dict[str, Any] = field(default_factory=dict) + result_preview: Optional[str] = None + is_error: bool = False + + +@dataclass +class OAuthConsentRequired: + """An `oauth_required` SSE event β€” a connector needs (re-)consent.""" + + provider_id: str + authorization_url: str + + +@dataclass +class RunResult: + """Structured outcome of one headless agent turn.""" + + run_id: str + session_id: str + user_id: str + status: RunStatus + final_message: str = "" + stop_reason: Optional[str] = None + error: Optional[str] = None + title: Optional[str] = None + tool_trace: List[ToolTraceEntry] = field(default_factory=list) + # Accumulated usage/metrics from the stream's `metadata_summary` (turn + # totals) with per-message `metadata` events as fallback. Shape mirrors + # the SSE payloads: {"usage": {...}, "metrics": {...}}. + usage: Dict[str, Any] = field(default_factory=dict) + oauth_required: List[OAuthConsentRequired] = field(default_factory=list) + started_at: str = "" + finished_at: str = "" + # Diagnostic: counts of every SSE event name seen, e.g. {"tool_use": 2}. + events_seen: Dict[str, int] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) diff --git a/backend/src/apis/shared/harness/runner.py b/backend/src/apis/shared/harness/runner.py new file mode 100644 index 00000000..7523d507 --- /dev/null +++ b/backend/src/apis/shared/harness/runner.py @@ -0,0 +1,262 @@ +"""`run_agent_headless` β€” the F1 headless run entrypoint. + +Given ``(user_id, prompt)`` and no live session: mint a per-owner bearer, +POST the AgentCore Runtime ``/invocations`` data plane, drain the SSE stream +server-side, pass the governance floor, and land the result as a retrievable +session. Every trigger (schedule worker, "Run now" route, A2A server, +webhook) is just a caller of this function. + +A2A-readiness: the seam is ``(user_id, prompt, resolved config) β†’ RunResult`` +plus the optional ``on_event`` callback, which relays every typed SSE event +as it arrives β€” an A2A server front can map that stream onto task status +updates without a rewrite. Reminder from CLAUDE.md: if this is ever exposed +as an A2A server, its advertised ``capabilities`` MUST include +``streaming=True``. +""" + +from __future__ import annotations + +import logging +import os +import uuid +from datetime import datetime, timezone +from typing import Any, Awaitable, Callable, Dict, List, Optional +from urllib.parse import quote, urlsplit + +import httpx + +from apis.shared.harness.auth import BearerAuthStrategy, HeadlessAuthError +from apis.shared.harness.governance import GovernanceFloor +from apis.shared.harness.models import RunResult +from apis.shared.harness.sse import InvocationStreamAccumulator, iter_sse_events + +logger = logging.getLogger(__name__) + +# Matches the app-api chat proxy's budget for a full agent turn; the runtime +# data plane itself enforces a hard cap in the same range. +DEFAULT_TIMEOUT_SECONDS = 300.0 + +OnEvent = Callable[[str, Dict[str, Any]], Awaitable[None]] + + +def _build_http_client(timeout_seconds: float) -> httpx.AsyncClient: + """Single seam where the runner's upstream client is constructed. + + Tests substitute a MockTransport-backed client here without patching + the global ``httpx.AsyncClient`` symbol (same pattern as the chat + proxy's ``_build_upstream_client``). + """ + return httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds)) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def build_invocations_url(base_url: str) -> str: + """Resolve the upstream `/invocations` URL from an INFERENCE_API_URL-style base. + + This is the single canonical resolver β€” the app-api chat proxy + (`proxy_routes.py`) and the MCP Apps proxy import it from here. + + Cloud: the base is the AgentCore Runtime data plane + (`https://bedrock-agentcore..amazonaws.com/runtimes/`), + where `` is unencoded in SSM. The data-plane route is + `POST /runtimes/{agentRuntimeArn}/invocations?qualifier={qualifier}` + with `{agentRuntimeArn}` as a single URL-encoded path segment β€” the + ARN's literal `/` and `:` must be percent-encoded or AWS returns 404. + A `qualifier` is also required; we use `DEFAULT`. + + Local dev: the base is `http://localhost:8001`, where `/invocations` + is a real FastAPI route on inference-api directly. No encoding or + qualifier needed. + """ + parts = urlsplit(base_url) + prefix = "/runtimes/" + if parts.netloc.startswith("bedrock-agentcore.") and parts.path.startswith(prefix): + arn = parts.path[len(prefix):] + encoded_arn = quote(arn, safe="") + return ( + f"{parts.scheme}://{parts.netloc}/runtimes/{encoded_arn}" + "/invocations?qualifier=DEFAULT" + ) + return f"{base_url}/invocations" + + +async def run_agent_headless( + *, + user_id: str, + prompt: str, + auth: BearerAuthStrategy, + session_id: Optional[str] = None, + run_id: Optional[str] = None, + title: Optional[str] = None, + model_id: Optional[str] = None, + rag_assistant_id: Optional[str] = None, + enabled_tools: Optional[List[str]] = None, + agent_type: Optional[str] = None, + inference_params: Optional[Dict[str, Any]] = None, + trigger: str = "manual", + invocations_base_url: Optional[str] = None, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + governance: Optional[GovernanceFloor] = None, + on_event: Optional[OnEvent] = None, +) -> RunResult: + """Run one agent turn as ``user_id`` with no live session. + + The run-config parameters (``model_id``, ``rag_assistant_id``, + ``enabled_tools``, ``agent_type``, ``inference_params``) mirror the + existing ``InvocationRequest`` contract β€” the entrypoint resolves *no* + new config type (scheduled-agent-runs.md decision #6). + + ``enabled_tools=None`` means "the user's defaults" β€” inference-api + resolves it to **all RBAC-allowed tools** for the owner, exactly as an + attended chat turn would. That is the right semantic for the attended + "Run now" surface; *schedules* should instead snapshot an explicit tool + list at creation time so a sleeping schedule's behavior doesn't shift + when the catalog or the owner's grants change underneath it + (spike-findings punch list #7 β€” enforce in the Phase B schedule model). + + Returns a ``RunResult`` in all outcomes except audit-start failure and + ``HeadlessAuthError`` (both raise: a run we cannot audit or authenticate + must not execute). + """ + run_id = run_id or f"run-{uuid.uuid4().hex[:12]}" + session_id = session_id or f"headless-{uuid.uuid4().hex[:16]}" + governance = governance or GovernanceFloor() + started_at = _now_iso() + + # F6a checkpoints 1 + 2 β€” before any token mint or model spend. + await governance.on_run_start( + run_id=run_id, + user_id=user_id, + session_id=session_id, + trigger=trigger, + prompt=prompt, + ) + await governance.check_input(prompt=prompt, user_id=user_id) + + result = RunResult( + run_id=run_id, + session_id=session_id, + user_id=user_id, + status="error", + started_at=started_at, + ) + + try: + bearer = await auth.mint_bearer_for_user(user_id) + except HeadlessAuthError: + result.finished_at = _now_iso() + result.error = "auth: could not mint a bearer for the user" + await governance.on_run_end(result=result) + raise + + base_url = invocations_base_url or os.environ.get( + "INFERENCE_API_URL", "http://localhost:8001" + ) + url = build_invocations_url(base_url) + payload: Dict[str, Any] = { + "session_id": session_id, + "message": prompt, + } + if model_id is not None: + payload["model_id"] = model_id + if rag_assistant_id is not None: + payload["rag_assistant_id"] = rag_assistant_id + if enabled_tools is not None: + payload["enabled_tools"] = enabled_tools + if agent_type is not None: + payload["agent_type"] = agent_type + if inference_params is not None: + payload["inference_params"] = inference_params + + acc = InvocationStreamAccumulator() + try: + async with _build_http_client(timeout_seconds) as client: + async with client.stream( + "POST", + url, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {bearer}", + }, + json=payload, + ) as response: + if response.status_code >= 400: + body = await response.aread() + result.error = ( + f"HTTP {response.status_code}: " + f"{body.decode('utf-8', errors='replace')[:500]}" + ) + else: + async for name, data in iter_sse_events( + response.aiter_lines() + ): + acc.handle(name, data) + if on_event is not None: + await on_event(name, data) + except httpx.TimeoutException: + result.status = "timeout" + result.error = f"stream exceeded {timeout_seconds:.0f}s budget" + except httpx.HTTPError as exc: + result.error = f"transport: {type(exc).__name__}: {exc}" + + result.final_message = acc.final_message + result.stop_reason = acc.stop_reason + result.tool_trace = acc.tool_trace + result.usage = acc.finalize_usage() + result.oauth_required = acc.oauth_required + result.title = acc.title + result.events_seen = acc.events_seen + result.error = result.error or acc.error + if result.status != "timeout": + if result.error: + result.status = "error" + elif acc.oauth_required: + result.status = "oauth_required" + elif acc.done: + result.status = "completed" + else: + result.status = "error" + result.error = "stream ended without a done event" + result.finished_at = _now_iso() + + # F6a checkpoint 3 β€” classify before delivery. + await governance.classify_output(result=result) + + # Delivery (minimal F2): the runtime already persisted the session row, + # messages, and an auto-generated title during the turn. Make the row's + # existence unconditional (idempotent belt-and-braces for error paths) + # and let an explicit caller title win over the generated one. + try: + from apis.shared.sessions.metadata import ( + ensure_session_metadata_exists, + set_session_unread, + update_session_title, + ) + + await ensure_session_metadata_exists(session_id, user_id) + if title: + await update_session_title(session_id, user_id, title) + result.title = title + + # A background run produced a response the user wasn't watching β€” flag + # the session unread so the sidebar shows a dot until they open it. Runs + # *after* the SK-rotating writes above, on the row's final SK. Gated on a + # successful completion (no dot for a failed or consent-blocked run) and + # on the background triggers: "schedule" (unattended) and "run_now", + # which is now fire-and-forget β€” the SPA hands the run to a background + # tracker and the user keeps working elsewhere, so the result arrives + # asynchronously in the sidebar just like a scheduled run. "manual" (the + # default, used where a caller drains the result itself) stays excluded. + if trigger in ("schedule", "run_now") and result.status == "completed": + await set_session_unread(session_id, user_id, True) + except Exception: + logger.error( + "Headless run %s: result delivery failed", run_id, exc_info=True + ) + + # F6a checkpoint 4 β€” outcome audit (best-effort inside). + await governance.on_run_end(result=result) + return result diff --git a/backend/src/apis/shared/harness/sse.py b/backend/src/apis/shared/harness/sse.py new file mode 100644 index 00000000..cc478193 --- /dev/null +++ b/backend/src/apis/shared/harness/sse.py @@ -0,0 +1,227 @@ +"""Server-side SSE consumption for `/invocations` streams (Unknown 2). + +Nothing else in the platform reads an `/invocations` SSE stream server-side β€” +the app-api chat proxy only relays bytes to a browser. This module parses the +stream into `(event_name, payload)` pairs and accumulates them into the +fields a `RunResult` needs. + +Wire format (see `agents/main_agent/streaming/stream_processor.py` and the +SSE table in CLAUDE.md): `event: \\ndata: \\n\\n`. A few legacy +paths emit bare `data:` lines whose JSON carries a `type` field β€” the parser +falls back to that. The stream interleaves raw Strands passthrough events +(`event: event`) with the typed events; the accumulator only consumes the +typed ones. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Dict, List, Optional, Tuple + +from apis.shared.harness.models import OAuthConsentRequired, ToolTraceEntry + +logger = logging.getLogger(__name__) + +_TOOL_RESULT_PREVIEW_CHARS = 2000 + + +async def iter_sse_events( + lines: AsyncIterator[str], +) -> AsyncIterator[Tuple[str, Dict[str, Any]]]: + """Parse an SSE line stream into `(event_name, payload)` tuples. + + Follows the SSE framing rules we actually emit: `event:` (optional) and + `data:` lines terminated by a blank line. Multi-`data:`-line events are + joined per the SSE spec. Events with unparseable JSON payloads are + surfaced as `("_unparseable", {"raw": ...})` so callers can count them + without the reader dying mid-stream. + """ + event_name: Optional[str] = None + data_lines: List[str] = [] + + async for raw_line in lines: + line = raw_line.rstrip("\n") + if line == "": + if data_lines or event_name is not None: + data = "\n".join(data_lines) + payload: Dict[str, Any] + try: + payload = json.loads(data) if data else {} + if not isinstance(payload, dict): + payload = {"value": payload} + except json.JSONDecodeError: + yield "_unparseable", {"raw": data[:500]} + event_name, data_lines = None, [] + continue + # Bare `data:` events carry their name in a `type` field. + name = event_name or str(payload.get("type") or "message") + yield name, payload + event_name, data_lines = None, [] + continue + if line.startswith("event:"): + event_name = line[len("event:"):].strip() + elif line.startswith("data:"): + data_lines.append(line[len("data:"):].lstrip()) + # Comments (`:`) and unknown fields are ignored per the SSE spec. + + +@dataclass +class InvocationStreamAccumulator: + """Folds the typed event stream into RunResult-shaped state. + + Text accumulation: assistant text arrives as `content_block_delta` + events (`type == "text"`); a turn with tool use emits several + `message_start`/`message_stop` cycles, so the *final* assistant message + is the text of the last completed message (falling back to any + unterminated buffer, then to the whole-turn transcript). + """ + + done: bool = False + stop_reason: Optional[str] = None + error: Optional[str] = None + title: Optional[str] = None + tool_trace: List[ToolTraceEntry] = field(default_factory=list) + oauth_required: List[OAuthConsentRequired] = field(default_factory=list) + usage: Dict[str, Any] = field(default_factory=dict) + events_seen: Dict[str, int] = field(default_factory=dict) + + _current: List[str] = field(default_factory=list) + _messages: List[str] = field(default_factory=list) + _trace_by_id: Dict[str, ToolTraceEntry] = field(default_factory=dict) + _per_message_usage: Dict[str, Any] = field(default_factory=dict) + + @property + def final_message(self) -> str: + for text in reversed(self._messages + ["".join(self._current)]): + if text.strip(): + return text + return "" + + @property + def transcript(self) -> str: + parts = [m for m in self._messages if m.strip()] + tail = "".join(self._current) + if tail.strip(): + parts.append(tail) + return "\n\n".join(parts) + + def handle(self, name: str, payload: Dict[str, Any]) -> None: + self.events_seen[name] = self.events_seen.get(name, 0) + 1 + + if name == "message_start": + if "".join(self._current).strip(): + self._messages.append("".join(self._current)) + self._current = [] + elif name == "content_block_delta": + if payload.get("type") == "text" and payload.get("text"): + self._current.append(str(payload["text"])) + elif name == "message_stop": + self.stop_reason = payload.get("stopReason") or self.stop_reason + if "".join(self._current).strip(): + self._messages.append("".join(self._current)) + self._current = [] + elif name == "tool_use": + # Two wire shapes: the flat event-formatter payload + # ({toolUseId, name, input}) and the stream-processor passthrough + # ({"tool_use": {tool_use_id, name, input}}) where `input` is a + # *partial JSON string* re-emitted as the model streams the + # arguments. Upsert by id so a streamed tool call folds into one + # trace entry whose input is the last parseable prefix. + data = payload.get("tool_use") + if not isinstance(data, dict): + data = payload + tool_use_id = str( + data.get("toolUseId") or data.get("tool_use_id") or "" + ) + entry = self._trace_by_id.get(tool_use_id) + if entry is None: + entry = ToolTraceEntry(tool_use_id=tool_use_id, name="") + self.tool_trace.append(entry) + if tool_use_id: + self._trace_by_id[tool_use_id] = entry + if data.get("name"): + entry.name = str(data["name"]) + raw_input = data.get("input") + if isinstance(raw_input, dict): + entry.input = raw_input + elif isinstance(raw_input, str) and raw_input: + try: + parsed = json.loads(raw_input) + if isinstance(parsed, dict): + entry.input = parsed + except json.JSONDecodeError: + pass # partial prefix; a later re-emit will complete it + elif name in ("tool_result", "tool_error"): + # Flat event-formatter payload ({toolUseId, result}) or the + # message-shaped passthrough ({"message": {"content": + # [{"toolResult": {toolUseId, status, content: [{text}]}}]}}). + tool_results: List[Dict[str, Any]] = [] + message = payload.get("message") + if isinstance(message, dict): + for block in message.get("content") or []: + if isinstance(block, dict) and isinstance( + block.get("toolResult"), dict + ): + tool_results.append(block["toolResult"]) + if not tool_results: + tool_results.append(payload) + for tr in tool_results: + tool_use_id = str( + tr.get("toolUseId") or tr.get("tool_use_id") or "" + ) + entry = self._trace_by_id.get(tool_use_id) + if entry is None and self.tool_trace: + entry = self.tool_trace[-1] + if entry is None: + continue + result = tr.get("result") or tr.get("error") + if result is None and isinstance(tr.get("content"), list): + result = "\n".join( + str(block.get("text")) + for block in tr["content"] + if isinstance(block, dict) and "text" in block + ) + entry.result_preview = str(result or "")[ + :_TOOL_RESULT_PREVIEW_CHARS + ] + if name == "tool_error" or tr.get("status") == "error": + entry.is_error = True + elif name == "metadata": + # Per-model-call usage; keep the last as a fallback if the turn + # summary never arrives (short turns emit both). + for key in ("usage", "metrics"): + if key in payload: + self._per_message_usage[key] = payload[key] + elif name == "metadata_summary": + # Turn-cumulative totals β€” authoritative for cost attribution. + self.usage.update( + {k: v for k, v in payload.items() if k != "type"} + ) + elif name == "session_title": + self.title = payload.get("title") or self.title + elif name == "oauth_required": + provider = str( + payload.get("providerId") or payload.get("provider_id") or "" + ) + url = str( + payload.get("authorizationUrl") + or payload.get("authorization_url") + or "" + ) + if url: + self.oauth_required.append( + OAuthConsentRequired(provider_id=provider, authorization_url=url) + ) + elif name in ("stream_error", "error"): + self.error = str( + payload.get("message") or payload.get("error") or payload + )[:2000] + elif name == "done": + self.done = True + + def finalize_usage(self) -> Dict[str, Any]: + if self.usage: + return self.usage + return dict(self._per_message_usage) diff --git a/backend/src/apis/shared/memory/__init__.py b/backend/src/apis/shared/memory/__init__.py new file mode 100644 index 00000000..f84942aa --- /dev/null +++ b/backend/src/apis/shared/memory/__init__.py @@ -0,0 +1,67 @@ +"""Memory Spaces β€” user-owned, shareable markdown "second brains" (F5). + +A shared-layer primitive (consumed by app-api and the agent runtime, never +importing either) that stores named, per-owner, optionally-shared markdown +wikis: an always-loaded ``MEMORY.md`` index plus typed entries fetched on +demand. See ``docs/specs/user-markdown-memory.md``. + +PR-1 (this package) is the data layer only β€” models, S3 byte store, DynamoDB +repository, and the permission-gated service. No routes, agent tools, or +system-prompt wiring (those land in later PRs). Gated per environment by the +``MEMORY_SPACES_ENABLED`` flag (default off; see ``apis.shared.feature_flags``). +""" + +from .models import ( + EntryType, + MemoryEntryRef, + MemoryIndex, + MemorySpace, + Role, + ShareRole, + SpaceMember, +) +from .repository import MemorySpaceRepository +from .service import ( + ConsolidationReport, + MemorySpaceConcurrencyError, + MemorySpaceError, + MemorySpaceExport, + MemorySpaceNotFoundError, + MemorySpacePermissionError, + MemorySpaceService, +) +from .store import ( + MemorySpaceStore, + MemorySpaceStoreError, + compute_content_hash, + content_key, + get_memory_space_store, +) +from .templates import TEMPLATES, SpaceTemplate, get_template, is_valid_template + +__all__ = [ + "EntryType", + "MemoryEntryRef", + "MemoryIndex", + "MemorySpace", + "Role", + "ShareRole", + "SpaceMember", + "MemorySpaceRepository", + "MemorySpaceService", + "MemorySpaceExport", + "ConsolidationReport", + "MemorySpaceConcurrencyError", + "MemorySpaceError", + "MemorySpaceNotFoundError", + "MemorySpacePermissionError", + "MemorySpaceStore", + "MemorySpaceStoreError", + "compute_content_hash", + "content_key", + "get_memory_space_store", + "TEMPLATES", + "SpaceTemplate", + "get_template", + "is_valid_template", +] diff --git a/backend/src/apis/shared/memory/hydration.py b/backend/src/apis/shared/memory/hydration.py new file mode 100644 index 00000000..c4b81887 --- /dev/null +++ b/backend/src/apis/shared/memory/hydration.py @@ -0,0 +1,147 @@ +"""Agent Designer Phase 3 β€” Memory-Space content hydration for prompt injection. + +Resolves a ``memory_space`` binding's ``config.alwaysLoad`` list into concrete text +fragments to inject into an Agent's system prompt at invocation. Lives in ``apis.shared`` +so both the Harness (inference-api) and any future app-api preview/"context breakdown" +surface can reuse it. + +``alwaysLoad`` addressing scheme (see ``templates.py`` / the memory spec): +- ``"MEMORY.md"`` β†’ the space index text (``read_index``). +- ``"latest:/"`` β†’ the most-recently-updated manifest entry whose + ``entry_type`` matches ```` and whose slug starts with ```` (e.g. + ``latest:episodic/daily``). If ```` isn't a valid ``EntryType`` the whole + remainder is treated as a slug prefix with no type filter. +- any other string β†’ an exact entry slug (``read_entry``). + +A missing entry is skipped (an empty space, or an entry deleted since the binding was +authored, must never fail the turn). Injection is budget-capped: over-budget fragments are +truncated with a marker so the model knows to fetch the rest via a ``memory_read`` tool. +All reads run through ``MemorySpaceService``, which re-checks the caller's ``viewer+`` grant +internally β€” so hydration cannot leak a space the invoker can't read. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import List, Optional + +from apis.shared.memory.service import MemorySpaceNotFoundError, MemorySpaceService + +_VALID_ENTRY_TYPES = {"entity", "episodic", "fact"} +DEFAULT_ALWAYS_LOAD = ["MEMORY.md"] + +# Total injected memory budget (bytes). ~24 KB β‰ˆ 6k tokens β€” headroom over the +# documented steady state (~200 entries β‰ˆ 4k tokens). Override per environment. +_DEFAULT_MAX_TOTAL_BYTES = 24_000 +_TRUNCATION_MARKER = "\n…[truncated β€” use memory_read to fetch the full entry]" + + +@dataclass +class LoadedFragment: + """One resolved piece of memory to inject: a human label + its text.""" + + label: str + text: str + + +def _max_total_bytes() -> int: + raw = os.environ.get("MEMORY_INJECTION_MAX_BYTES") + if raw: + try: + return max(0, int(raw)) + except ValueError: + pass + return _DEFAULT_MAX_TOTAL_BYTES + + +def _resolve_latest( + service: MemorySpaceService, space_id: str, user_id: str, user_email: Optional[str], rest: str +) -> Optional[tuple]: + """Resolve a ``latest:/`` spec β†’ (slug, text) or None.""" + if "/" in rest: + type_part, prefix = rest.split("/", 1) + else: + type_part, prefix = rest, "" + entry_type = type_part if type_part in _VALID_ENTRY_TYPES else None + # If the first segment isn't a valid type, treat the whole remainder as a slug prefix. + if entry_type is None: + prefix = rest + + entries = service.list_entries(space_id, user_id, user_email, entry_type=entry_type) + if prefix: + entries = [e for e in entries if e.slug.startswith(prefix)] + if not entries: + return None + latest = max(entries, key=lambda e: e.updated) + return latest.slug, service.read_entry(space_id, user_id, user_email, latest.slug) + + +def resolve_always_load( + service: MemorySpaceService, + space_id: str, + user_id: str, + user_email: Optional[str], + always_load: Optional[List[str]], + *, + max_total_bytes: Optional[int] = None, +) -> List[LoadedFragment]: + """Resolve ``always_load`` specs into injectable fragments, within a byte budget. + + Synchronous (``MemorySpaceService`` is sync boto3) β€” callers on the event loop wrap + this in ``asyncio.to_thread``. Never raises for a missing entry; a genuinely broken + read (permission revoked mid-turn, store error) propagates. + """ + specs = always_load if always_load else DEFAULT_ALWAYS_LOAD + budget = _max_total_bytes() if max_total_bytes is None else max_total_bytes + + fragments: List[LoadedFragment] = [] + used = 0 + for spec in specs: + if used >= budget: + break + try: + if spec == "MEMORY.md": + text, label = service.read_index(space_id, user_id, user_email), "MEMORY.md" + elif spec.startswith("latest:"): + resolved = _resolve_latest(service, space_id, user_id, user_email, spec[len("latest:"):]) + if resolved is None: + continue + slug, text = resolved + label = f"{spec} β†’ {slug}" + else: + text, label = service.read_entry(space_id, user_id, user_email, spec), spec + except MemorySpaceNotFoundError: + # Entry/index deleted since the binding was authored β€” skip, don't fail. + continue + + if not text: + continue + + encoded = text.encode("utf-8") + remaining = budget - used + if len(encoded) > remaining: + text = encoded[:remaining].decode("utf-8", errors="ignore") + _TRUNCATION_MARKER + used = budget + else: + used += len(encoded) + fragments.append(LoadedFragment(label=label, text=text)) + + return fragments + + +def render_memory_block(space_name: str, fragments: List[LoadedFragment]) -> str: + """Render resolved fragments into a delimited system-prompt block. + + Empty when there are no fragments (a fresh space injects nothing). + """ + if not fragments: + return "" + parts = [ + f'## Bound Memory β€” "{space_name}"', + "This is your persistent memory for this agent. Fetch more with `memory_read` / " + "list with `memory_list`.", + ] + for frag in fragments: + parts.append(f"### {frag.label}\n{frag.text}") + return "\n\n".join(parts) diff --git a/backend/src/apis/shared/memory/models.py b/backend/src/apis/shared/memory/models.py new file mode 100644 index 00000000..e20856e0 --- /dev/null +++ b/backend/src/apis/shared/memory/models.py @@ -0,0 +1,138 @@ +"""Pydantic models for the Memory Space primitive (PR-1, data layer). + +A **Memory Space** is a named, first-class, per-owner (optionally shared) +markdown "second brain" that agents read and maintain. See +``docs/specs/user-markdown-memory.md``. + +Storage shape (dedicated ``memory-spaces`` DynamoDB table, space-keyed so a +shared space cannot live under one user's partition): + + - ``PK=SPACE#{space_id} SK=META`` β†’ :class:`MemorySpace` + - ``PK=SPACE#{space_id} SK=INDEX`` β†’ :class:`MemoryIndex` (manifest) + - ``PK=SPACE#{space_id} SK=MEMBER#{email}`` β†’ :class:`SpaceMember` + +Two GSIs list a user's spaces (mirroring assistant sharing β€” owned and +shared-in are separate indexes unioned in code): + + - ``OwnerIndex`` ``GSI1PK=OWNER#{owner_id} GSI1SK=SPACE#{space_id}`` + - ``MemberIndex`` ``GSI2PK=MEMBER#{email} GSI2SK=SPACE#{space_id}`` + +Roles mirror assistant sharing: the owner is stored on the space row +(``owner_id``); share records only ever carry ``viewer``/``editor``. + +The markdown bytes (``MEMORY.md`` and each entry) live in S3 +(``apis/shared/memory/store.py``); these rows carry only pointers. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +# Full permission set: owner is implicit (stored on the space row); shares +# only carry the two grantable roles. +Role = Literal["owner", "editor", "viewer"] +ShareRole = Literal["viewer", "editor"] + +# Entry kinds. ``entity`` = a mutable record keyed by subject (a person, a +# project). ``episodic`` = an append-only dated record (a daily log, a brief). +# ``fact`` = a flat distilled fact (the catch-all). +EntryType = Literal["entity", "episodic", "fact"] + + +class MemoryEntryRef(BaseModel): + """Manifest entry for one markdown file in a Memory Space. + + The bytes live content-addressed in the ``memory-spaces`` S3 bucket + (``spaces/{space_id}/{content_hash}``); this lightweight ref lives in the + space's ``INDEX`` row. ``indexed`` copies a small allowlist of frontmatter + fields (e.g. ``status``, ``commitments.due``) so relational/temporal + queries ("who owes what") are a manifest lookup, not a full-corpus scan. + + camelCase aliases round-trip the future API response (FastAPI serializes + by alias) while ``populate_by_name`` allows construction from snake_case. + DynamoDB (de)serialization is handled explicitly in the repository. + """ + + model_config = ConfigDict(populate_by_name=True) + + slug: str = Field(..., description="Stable id within the space, e.g. 'jane-doe'") + entry_type: EntryType = Field( + "fact", alias="type", description="entity | episodic | fact" + ) + description: str = Field( + "", description="One-line summary shown in the index catalog" + ) + content_hash: str = Field( + ..., alias="contentHash", description="sha256 hex of the file bytes" + ) + size: int = Field(..., description="Size of the file in bytes") + s3_key: str = Field( + ..., + alias="s3Key", + description="Object key in the memory-spaces bucket " + "(spaces/{space_id}/{content_hash})", + ) + updated: str = Field("", description="ISO-8601 timestamp of the last write") + updated_by: str = Field( + "", alias="updatedBy", description="user_id of the last writer (attribution)" + ) + indexed: Dict[str, Any] = Field( + default_factory=dict, + description="Allowlisted frontmatter fields copied out for querying", + ) + + +class MemoryIndex(BaseModel): + """The ``INDEX`` row: the machine manifest of a space's entries. + + Distinct from the human-readable ``MEMORY.md`` index text (which lives in + S3 and is pointed to by :attr:`MemorySpace.index_s3_key`). ``version`` is a + monotonically-increasing counter reserved for optimistic-concurrency + control on shared spaces (PR-6); PR-1 writes it but does not yet gate on it. + """ + + model_config = ConfigDict(populate_by_name=True) + + space_id: str = Field(..., alias="spaceId") + entries: List[MemoryEntryRef] = Field(default_factory=list) + version: int = Field(0, description="Optimistic-concurrency counter (PR-6)") + + +class SpaceMember(BaseModel): + """A ``MEMBER#{email}`` row: one shared grant on a space. + + Email-keyed (you invite by email before the grantee has necessarily + logged in), mirroring assistant ``ShareEntry``. The owner is NOT a member + row β€” ownership is stored on the space itself. + """ + + model_config = ConfigDict(populate_by_name=True) + + email: str = Field(..., description="Grantee email (normalized lowercase)") + permission: ShareRole = Field("viewer", description="viewer | editor") + created_at: str = Field("", alias="createdAt") + + +class MemorySpace(BaseModel): + """The ``META`` row: a Memory Space's identity + ownership + index pointer. + + The entries manifest lives on the separate ``INDEX`` row (:class:`MemoryIndex`) + and shared grants on ``MEMBER#`` rows (:class:`SpaceMember`); this row is + what permission checks and space listings read. + """ + + model_config = ConfigDict(populate_by_name=True) + + space_id: str = Field(..., alias="spaceId") + name: str = Field(..., description="User-facing space name") + template: str = Field("blank", description="Template id the space was seeded from") + owner_id: str = Field(..., alias="ownerId", description="user_id of the owner") + owner_email: str = Field("", alias="ownerEmail") + created_at: str = Field("", alias="createdAt") + updated_at: str = Field("", alias="updatedAt") + # Pointer to the MEMORY.md index text in S3 (content-addressed). Optional + # only transiently during creation; always set on a persisted space. + index_s3_key: Optional[str] = Field(None, alias="indexS3Key") + index_content_hash: Optional[str] = Field(None, alias="indexContentHash") diff --git a/backend/src/apis/shared/memory/repository.py b/backend/src/apis/shared/memory/repository.py new file mode 100644 index 00000000..456ba626 --- /dev/null +++ b/backend/src/apis/shared/memory/repository.py @@ -0,0 +1,302 @@ +"""DynamoDB repository for Memory Spaces (PR-1, data layer). + +Owns the row shapes on the dedicated ``memory-spaces`` table and all +(de)serialization between DynamoDB items (camelCase, ``Decimal`` numbers) and +the Pydantic models. No access control lives here β€” that is the service's job +(``resolve_permission``); the repository is a thin, permission-agnostic CRUD +layer, matching how ``apis/shared`` repositories are structured elsewhere. + +Row shapes (see ``models.py``): + + - ``PK=SPACE#{id} SK=META`` + ``GSI1PK=OWNER#{owner_id}`` + - ``PK=SPACE#{id} SK=INDEX`` + - ``PK=SPACE#{id} SK=MEMBER#{email}`` + ``GSI2PK=MEMBER#{email}`` +""" + +from __future__ import annotations + +import logging +import os +from decimal import Decimal +from typing import Any, List, Optional + +try: # boto3 is absent in some local-dev setups + import boto3 + from boto3.dynamodb.conditions import Key + from botocore.exceptions import ClientError +except ImportError: # pragma: no cover - exercised only without boto3 + boto3 = None + Key = None # type: ignore[assignment] + ClientError = Exception # type: ignore[assignment, misc] + +from .models import MemoryEntryRef, MemoryIndex, MemorySpace, SpaceMember + +logger = logging.getLogger(__name__) + + +class OptimisticLockError(RuntimeError): + """A conditional manifest write failed because the row moved under us. + + Raised by :meth:`MemorySpaceRepository.put_index` when a caller supplies an + ``expected_version`` that no longer matches the stored one. The service + translates this into a re-read-and-retry loop (and ultimately a + ``MemorySpaceConcurrencyError`` if it can't converge). Kept repository-local + so this layer stays free of the service's error taxonomy. + """ + +_META_SK = "META" +_INDEX_SK = "INDEX" +_MEMBER_SK_PREFIX = "MEMBER#" + +OWNER_INDEX = "OwnerIndex" +MEMBER_INDEX = "MemberIndex" + + +def _space_pk(space_id: str) -> str: + return f"SPACE#{space_id}" + + +def _member_sk(email: str) -> str: + return f"{_MEMBER_SK_PREFIX}{email.strip().lower()}" + + +def _to_dynamo(obj: Any) -> Any: + """Recursively convert floats to Decimal for DynamoDB writes.""" + if isinstance(obj, float): + return Decimal(str(obj)) + if isinstance(obj, dict): + return {k: _to_dynamo(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_to_dynamo(v) for v in obj] + return obj + + +def _from_dynamo(obj: Any) -> Any: + """Recursively convert Decimal to int/float for JSON-friendly reads.""" + if isinstance(obj, Decimal): + # Whole numbers come back as int (sizes, counts); keep fractions float. + return int(obj) if obj == obj.to_integral_value() else float(obj) + if isinstance(obj, dict): + return {k: _from_dynamo(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_from_dynamo(v) for v in obj] + return obj + + +class MemorySpaceRepository: + """CRUD for the ``memory-spaces`` single table (META / INDEX / MEMBER rows).""" + + def __init__(self, table_name: Optional[str] = None): + self.table_name = table_name or os.environ.get( + "DYNAMODB_MEMORY_SPACES_TABLE_NAME", "memory-spaces" + ) + self._dynamodb = boto3.resource("dynamodb") + self._table = self._dynamodb.Table(self.table_name) + + # ---- serialization ------------------------------------------------- + + @staticmethod + def _space_to_item(space: MemorySpace) -> dict: + item = { + "PK": _space_pk(space.space_id), + "SK": _META_SK, + "GSI1PK": f"OWNER#{space.owner_id}", + "GSI1SK": _space_pk(space.space_id), + "spaceId": space.space_id, + "name": space.name, + "template": space.template, + "ownerId": space.owner_id, + "ownerEmail": space.owner_email, + "createdAt": space.created_at, + "updatedAt": space.updated_at, + } + if space.index_s3_key is not None: + item["indexS3Key"] = space.index_s3_key + if space.index_content_hash is not None: + item["indexContentHash"] = space.index_content_hash + return item + + @staticmethod + def _item_to_space(item: dict) -> MemorySpace: + return MemorySpace( + space_id=item.get("spaceId", ""), + name=item.get("name", ""), + template=item.get("template", "blank"), + owner_id=item.get("ownerId", ""), + owner_email=item.get("ownerEmail", ""), + created_at=item.get("createdAt", ""), + updated_at=item.get("updatedAt", ""), + index_s3_key=item.get("indexS3Key"), + index_content_hash=item.get("indexContentHash"), + ) + + @staticmethod + def _index_to_item(index: MemoryIndex) -> dict: + entries = [ + { + "slug": r.slug, + "type": r.entry_type, + "description": r.description, + "contentHash": r.content_hash, + "size": int(r.size), + "s3Key": r.s3_key, + "updated": r.updated, + "updatedBy": r.updated_by, + "indexed": _to_dynamo(r.indexed), + } + for r in index.entries + ] + return { + "PK": _space_pk(index.space_id), + "SK": _INDEX_SK, + "spaceId": index.space_id, + "entries": entries, + "version": int(index.version), + } + + @staticmethod + def _item_to_index(item: dict, space_id: str) -> MemoryIndex: + entries = [ + MemoryEntryRef( + slug=r.get("slug", ""), + entry_type=r.get("type", "fact"), + description=r.get("description", ""), + content_hash=r.get("contentHash", ""), + size=int(r.get("size", 0)), + s3_key=r.get("s3Key", ""), + updated=r.get("updated", ""), + updated_by=r.get("updatedBy", ""), + indexed=_from_dynamo(r.get("indexed") or {}), + ) + for r in (item.get("entries") or []) + ] + return MemoryIndex( + space_id=space_id, + entries=entries, + version=int(item.get("version", 0)), + ) + + @staticmethod + def _member_to_item(space_id: str, member: SpaceMember) -> dict: + email = member.email.strip().lower() + return { + "PK": _space_pk(space_id), + "SK": _member_sk(email), + "GSI2PK": f"MEMBER#{email}", + "GSI2SK": _space_pk(space_id), + "spaceId": space_id, + "email": email, + "permission": member.permission, + "createdAt": member.created_at, + } + + @staticmethod + def _item_to_member(item: dict) -> SpaceMember: + return SpaceMember( + email=item.get("email", ""), + permission=item.get("permission", "viewer"), + created_at=item.get("createdAt", ""), + ) + + # ---- space META ---------------------------------------------------- + + def put_space(self, space: MemorySpace) -> None: + self._table.put_item(Item=self._space_to_item(space)) + + def get_space(self, space_id: str) -> Optional[MemorySpace]: + resp = self._table.get_item( + Key={"PK": _space_pk(space_id), "SK": _META_SK} + ) + item = resp.get("Item") + return self._item_to_space(item) if item else None + + def list_owned(self, owner_id: str) -> List[MemorySpace]: + resp = self._table.query( + IndexName=OWNER_INDEX, + KeyConditionExpression=Key("GSI1PK").eq(f"OWNER#{owner_id}"), + ) + return [self._item_to_space(i) for i in resp.get("Items", [])] + + def delete_space(self, space_id: str) -> None: + """Delete every row for a space (META + INDEX + all MEMBER rows).""" + resp = self._table.query( + KeyConditionExpression=Key("PK").eq(_space_pk(space_id)) + ) + with self._table.batch_writer() as batch: + for item in resp.get("Items", []): + batch.delete_item(Key={"PK": item["PK"], "SK": item["SK"]}) + + # ---- index manifest ------------------------------------------------ + + def get_index(self, space_id: str) -> MemoryIndex: + resp = self._table.get_item( + Key={"PK": _space_pk(space_id), "SK": _INDEX_SK} + ) + item = resp.get("Item") + if not item: + return MemoryIndex(space_id=space_id, entries=[], version=0) + return self._item_to_index(item, space_id) + + def put_index( + self, index: MemoryIndex, *, expected_version: Optional[int] = None + ) -> None: + """Persist the manifest row. + + With ``expected_version`` the write is conditional on the stored + ``version`` still matching it β€” optimistic concurrency for shared + spaces. On a mismatch it raises :class:`OptimisticLockError` so the + service can re-read and retry. The ``attribute_not_exists`` branch + admits the very first write (``create_space`` seeds version 0, so this + is a safety net rather than the common path). + """ + item = self._index_to_item(index) + if expected_version is None: + self._table.put_item(Item=item) + return + try: + self._table.put_item( + Item=item, + ConditionExpression="attribute_not_exists(PK) OR #v = :expected", + ExpressionAttributeNames={"#v": "version"}, + ExpressionAttributeValues={":expected": int(expected_version)}, + ) + except ClientError as e: # narrow to the conditional-failure case + code = e.response.get("Error", {}).get("Code", "") + if code == "ConditionalCheckFailedException": + raise OptimisticLockError( + f"manifest for space '{index.space_id}' changed concurrently" + ) from e + raise + + # ---- members ------------------------------------------------------- + + def put_member(self, space_id: str, member: SpaceMember) -> None: + self._table.put_item(Item=self._member_to_item(space_id, member)) + + def get_member(self, space_id: str, email: str) -> Optional[SpaceMember]: + resp = self._table.get_item( + Key={"PK": _space_pk(space_id), "SK": _member_sk(email)} + ) + item = resp.get("Item") + return self._item_to_member(item) if item else None + + def delete_member(self, space_id: str, email: str) -> None: + self._table.delete_item( + Key={"PK": _space_pk(space_id), "SK": _member_sk(email)} + ) + + def list_members(self, space_id: str) -> List[SpaceMember]: + resp = self._table.query( + KeyConditionExpression=Key("PK").eq(_space_pk(space_id)) + & Key("SK").begins_with(_MEMBER_SK_PREFIX) + ) + return [self._item_to_member(i) for i in resp.get("Items", [])] + + def list_member_space_ids(self, email: str) -> List[str]: + """Return the space_ids a user (by email) has been granted access to.""" + normalized = email.strip().lower() + resp = self._table.query( + IndexName=MEMBER_INDEX, + KeyConditionExpression=Key("GSI2PK").eq(f"MEMBER#{normalized}"), + ) + return [i.get("spaceId", "") for i in resp.get("Items", []) if i.get("spaceId")] diff --git a/backend/src/apis/shared/memory/service.py b/backend/src/apis/shared/memory/service.py new file mode 100644 index 00000000..a90ea735 --- /dev/null +++ b/backend/src/apis/shared/memory/service.py @@ -0,0 +1,721 @@ +"""Service layer for Memory Spaces (PR-1, data layer). + +Owns space lifecycle, permission resolution, sharing, and entry/index I/O β€” +composing the DynamoDB repository (``repository.py``) with the S3 byte store +(``store.py``). This is the data-layer API that the runtime read/write path +(PR-2/PR-4) and the app-api user surface (PR-5) call; PR-1 adds no routes, +tools, or system-prompt wiring. + +**Access control is identity-based and enforced here**, at the one chokepoint +``resolve_permission`` β€” mirroring ``resolve_assistant_permission``. The owner +is stored on the space; shared grants are ``viewer``/``editor`` member rows. +Every read requires ``viewer+``; every write requires ``editor+``; sharing and +deletion require ``owner``. There is no content inspection β€” governance is the +grant, consistent with how the platform treats every other shared entity. +""" + +from __future__ import annotations + +import logging +import os +import re +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar + +from .models import ( + EntryType, + MemoryEntryRef, + MemoryIndex, + MemorySpace, + Role, + ShareRole, + SpaceMember, +) +from .repository import MemorySpaceRepository, OptimisticLockError +from .store import MemorySpaceStore, compute_content_hash, get_memory_space_store +from .templates import DEFAULT_TEMPLATE_ID, get_template, is_valid_template + +logger = logging.getLogger(__name__) + +_ROLE_RANK: Dict[str, int] = {"viewer": 1, "editor": 2, "owner": 3} + +# Bounded read-modify-retry attempts when a shared space's manifest is being +# edited concurrently. Entry writes touch a single slug, so re-reading the +# fresh manifest and re-applying the change is safe; only a sustained race +# exhausts this and surfaces as a conflict. +_MAX_MANIFEST_RETRIES = 5 + +# Default soft cap on the number of entries (β‰ˆ index lines). Consolidation +# reports when a space is over it β€” it never auto-evicts (that's a judgment +# call for the future LLM pass). β‰ˆ 200 entries β‰ˆ 4k always-loaded tokens/turn. +_DEFAULT_INDEX_CAP = 200 + +# Wikilinks in MEMORY.md: [[slug]] pointers into the entry set. +_WIKILINK_RE = re.compile(r"\[\[([^\[\]]+)\]\]") + +_T = TypeVar("_T") + + +def _index_cap() -> int: + """Soft entry cap, overridable via ``MEMORY_SPACE_INDEX_CAP``.""" + raw = os.environ.get("MEMORY_SPACE_INDEX_CAP") + if raw: + try: + return max(1, int(raw)) + except ValueError: + logger.warning("invalid MEMORY_SPACE_INDEX_CAP=%r; using default", raw) + return _DEFAULT_INDEX_CAP + + +class MemorySpaceError(RuntimeError): + """Base class for memory-space service errors (translated by the API layer).""" + + +class MemorySpaceNotFoundError(MemorySpaceError): + """The space does not exist (or the caller may not even know it does).""" + + +class MemorySpacePermissionError(MemorySpaceError): + """The caller lacks the required role on the space.""" + + +class MemorySpaceConcurrencyError(MemorySpaceError): + """A shared space's manifest kept changing under a bounded retry loop. + + Surfaced to the API layer as ``409 Conflict`` β€” the write is safe to retry + from a fresh read. + """ + + +@dataclass +class MemorySpaceExport: + """The full readable corpus of a space, gathered for a `.zip` download (Β§9). + + Loss-free snapshot: the space metadata, the ``MEMORY.md`` index text, and + every entry paired with its raw bytes (frontmatter intact). ``members`` is + populated only for editor+ callers β€” a viewer gets the corpus without the + grant list, mirroring the ``list_members`` gate. + """ + + space: MemorySpace + role: Role + index_text: str + files: List[Tuple[MemoryEntryRef, bytes]] = field(default_factory=list) + members: List[SpaceMember] = field(default_factory=list) + + +@dataclass +class ConsolidationReport: + """Result of a deterministic consolidation (health) pass over a space (A6). + + The pass auto-fixes only storage hygiene β€” orphaned content-addressed + objects with no manifest/index reference are deleted (``orphans_deleted``). + Everything that needs a judgment call is *reported*, not mutated: + ``duplicate_groups`` (entries sharing a content hash β€” which slug survives + is semantic), ``dead_links`` (``[[slug]]`` pointers in MEMORY.md with no + entry), and ``over_cap`` (entry count past the soft index cap β€” which entry + to drop is semantic). The LLM consolidation pass (Workstream B) extends this + seam to act on those reports. + """ + + space_id: str + entry_count: int + index_cap: int + over_cap: bool + orphans_deleted: int = 0 + duplicate_groups: List[List[str]] = field(default_factory=list) + dead_links: List[str] = field(default_factory=list) + stripped_dead_links: bool = False + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _new_space_id() -> str: + return f"spc_{uuid.uuid4().hex}" + + +def _get_nested(data: Dict[str, Any], dotted: str) -> Any: + """Resolve a dotted key (``commitments.due``) against a nested dict.""" + cur: Any = data + for part in dotted.split("."): + if not isinstance(cur, dict) or part not in cur: + return None + cur = cur[part] + return cur + + +class MemorySpaceService: + """Lifecycle + permission + I/O for Memory Spaces.""" + + def __init__( + self, + repository: Optional[MemorySpaceRepository] = None, + store: Optional[MemorySpaceStore] = None, + ) -> None: + self.repository = repository or MemorySpaceRepository() + self.store = store or get_memory_space_store() + + # ---- permission ---------------------------------------------------- + + def resolve_permission( + self, space_id: str, user_id: str, user_email: Optional[str] = None + ) -> Tuple[Optional[MemorySpace], Optional[Role]]: + """Resolve the caller's role on a space. + + Returns ``(space, role)`` where role is ``owner``/``editor``/``viewer``, + or ``(space, None)`` if the caller has no grant, or ``(None, None)`` if + the space does not exist. Mirrors ``resolve_assistant_permission``. + """ + space = self.repository.get_space(space_id) + if space is None: + return None, None + if space.owner_id == user_id: + return space, "owner" + if user_email: + member = self.repository.get_member(space_id, user_email) + if member is not None: + return space, member.permission + return space, None + + def _require( + self, + space_id: str, + user_id: str, + user_email: Optional[str], + min_role: Role, + ) -> Tuple[MemorySpace, Role]: + space, role = self.resolve_permission(space_id, user_id, user_email) + if space is None: + raise MemorySpaceNotFoundError(f"Memory space '{space_id}' not found") + if role is None or _ROLE_RANK[role] < _ROLE_RANK[min_role]: + raise MemorySpacePermissionError( + f"'{min_role}' access required on memory space '{space_id}'" + ) + return space, role + + # ---- lifecycle ----------------------------------------------------- + + def create_space( + self, + owner_id: str, + owner_email: str, + name: str, + template: str = DEFAULT_TEMPLATE_ID, + ) -> MemorySpace: + """Create a space seeded from a template; returns the persisted space.""" + if not owner_id: + raise MemorySpaceError("owner_id is required to create a space") + if not name or not name.strip(): + raise MemorySpaceError("a memory space name is required") + if not is_valid_template(template): + raise MemorySpaceError(f"unknown template '{template}'") + + tmpl = get_template(template) + space_id = _new_space_id() + now = _now_iso() + + # Seed the human-readable MEMORY.md index in S3. + index_bytes = tmpl.starter_index.encode("utf-8") + index_key = self.store.put( + space_id=space_id, content=index_bytes, content_type="text/markdown" + ) + + space = MemorySpace( + space_id=space_id, + name=name.strip(), + template=template, + owner_id=owner_id, + owner_email=(owner_email or "").strip().lower(), + created_at=now, + updated_at=now, + index_s3_key=index_key, + index_content_hash=compute_content_hash(index_bytes), + ) + self.repository.put_space(space) + self.repository.put_index(MemoryIndex(space_id=space_id, entries=[], version=0)) + logger.info( + "memory-spaces: created space=%s owner=%s template=%s", + space_id, + owner_id, + template, + ) + return space + + def get_space( + self, space_id: str, user_id: str, user_email: Optional[str] = None + ) -> MemorySpace: + space, _ = self._require(space_id, user_id, user_email, "viewer") + return space + + def list_spaces_for_user( + self, user_id: str, user_email: Optional[str] = None + ) -> List[Tuple[MemorySpace, Role]]: + """List ``(space, role)`` for spaces the user owns plus shared-in (deduped). + + Owned spaces resolve to ``owner``; shared-in carry the member's actual + ``viewer``/``editor`` grant, so the SPA can render accurate affordances + without a follow-up call per space. + """ + result: List[Tuple[MemorySpace, Role]] = [] + seen: set[str] = set() + for s in self.repository.list_owned(user_id): + result.append((s, "owner")) + seen.add(s.space_id) + if user_email: + for space_id in self.repository.list_member_space_ids(user_email): + if space_id in seen: + continue + shared = self.repository.get_space(space_id) + if shared is None: + continue + member = self.repository.get_member(space_id, user_email) + result.append((shared, member.permission if member else "viewer")) + seen.add(space_id) + return sorted(result, key=lambda t: t[0].created_at) + + def export_space( + self, space_id: str, user_id: str, user_email: Optional[str] = None + ) -> MemorySpaceExport: + """Gather the full readable corpus of a space for download (viewer+). + + Reads the manifest once and pulls every entry's bytes from the + content-addressed store β€” the loss-free "own your data" export (Β§9). + The app-api layer turns this into a streamed ``.zip``. Members are + included only for editor+ callers (mirrors :meth:`list_members`); a + viewer exports the content they can read without the grant list. + """ + space, role = self._require(space_id, user_id, user_email, "viewer") + index_text = "" + if space.index_s3_key: + index_text = self.store.get(space.index_s3_key).decode("utf-8") + index = self.repository.get_index(space_id) + files = [(ref, self.store.get(ref.s3_key)) for ref in index.entries] + members = ( + self.repository.list_members(space_id) + if _ROLE_RANK[role] >= _ROLE_RANK["editor"] + else [] + ) + return MemorySpaceExport( + space=space, + role=role, + index_text=index_text, + files=files, + members=members, + ) + + def delete_space( + self, space_id: str, user_id: str, user_email: Optional[str] = None + ) -> None: + """Delete a space (owner only): all rows + best-effort S3 objects.""" + space, _ = self._require(space_id, user_id, user_email, "owner") + # Best-effort purge of the byte objects (dedup-aware deletion is a v1 + # concern per the spec's data-governance section; content-addressed + # objects unreferenced after row deletion are the only residue). + index = self.repository.get_index(space_id) + for ref in index.entries: + self.store.delete(ref.s3_key) + if space.index_s3_key: + self.store.delete(space.index_s3_key) + self.repository.delete_space(space_id) + logger.info("memory-spaces: deleted space=%s by user=%s", space_id, user_id) + + def leave_space( + self, space_id: str, user_id: str, user_email: Optional[str] = None + ) -> None: + """Drop the caller's own grant on a space shared with them. + + A member removes *their own* access β€” no owner action required (the + "forget-me on a shared-in space = leave" case from the governance + section). The owner cannot leave; they delete the space instead. + """ + space, role = self.resolve_permission(space_id, user_id, user_email) + if space is None: + raise MemorySpaceNotFoundError(f"Memory space '{space_id}' not found") + if role == "owner": + raise MemorySpaceError( + "the owner cannot leave a space; delete it instead" + ) + if role is None or not user_email: + raise MemorySpacePermissionError( + f"you are not a member of memory space '{space_id}'" + ) + self.repository.delete_member(space_id, user_email) + logger.info("memory-spaces: user=%s left space=%s", user_id, space_id) + + # ---- consolidation (A6) -------------------------------------------- + + def consolidate( + self, + space_id: str, + user_id: str, + user_email: Optional[str] = None, + *, + apply_gc: bool = True, + strip_dead_links: bool = False, + ) -> ConsolidationReport: + """Deterministic consolidation (health) pass over a space (editor+). + + Auto-fixes storage hygiene β€” orphaned content-addressed objects (no + manifest/index reference) are deleted when ``apply_gc``. Everything that + needs judgment is reported, not mutated: duplicate content across slugs, + dead ``[[slug]]`` wikilinks in MEMORY.md, and over-cap entry counts. It + never merges or evicts entries β€” that's the LLM pass (Workstream B) that + extends this seam. ``strip_dead_links`` opts into one safe edit: unlink + dead ``[[slug]]`` pointers (they point nowhere), preserving the prose. + """ + space, _ = self._require(space_id, user_id, user_email, "editor") + index = self.repository.get_index(space_id) + entries = index.entries + slugs = {e.slug for e in entries} + + cap = _index_cap() + + # Duplicate detection: more than one slug sharing a content hash. + by_hash: Dict[str, List[str]] = {} + for e in entries: + by_hash.setdefault(e.content_hash, []).append(e.slug) + duplicate_groups = sorted( + (sorted(s) for s in by_hash.values() if len(s) > 1), + key=lambda g: g[0], + ) + + # Dead-link detection over the MEMORY.md text. + index_text = "" + if space.index_s3_key: + index_text = self.store.get(space.index_s3_key).decode("utf-8") + referenced = {m.strip() for m in _WIKILINK_RE.findall(index_text)} + dead_links = sorted(ref for ref in referenced if ref and ref not in slugs) + + stripped = False + if strip_dead_links and dead_links and space.index_s3_key: + new_text = index_text + for ref in dead_links: + new_text = new_text.replace(f"[[{ref}]]", ref) + if new_text != index_text: + self.update_index(space_id, user_id, user_email, new_text) + space = self.repository.get_space(space_id) or space + stripped = True + + # Orphaned-object GC: keys under the space prefix that no entry or the + # index pointer references (leaks from crashed/raced writes). Safe β€” + # unreferenced content is invisible to every read path. + orphans_deleted = 0 + if apply_gc: + referenced_keys = {e.s3_key for e in entries} + if space.index_s3_key: + referenced_keys.add(space.index_s3_key) + for key in self.store.list_keys(space_id): + if key not in referenced_keys: + self.store.delete(key) + orphans_deleted += 1 + + logger.info( + "memory-spaces: consolidated space=%s entries=%d orphans=%d " + "dups=%d dead_links=%d stripped=%s", + space_id, + len(entries), + orphans_deleted, + len(duplicate_groups), + len(dead_links), + stripped, + ) + return ConsolidationReport( + space_id=space_id, + entry_count=len(entries), + index_cap=cap, + over_cap=len(entries) > cap, + orphans_deleted=orphans_deleted, + duplicate_groups=duplicate_groups, + dead_links=dead_links, + stripped_dead_links=stripped, + ) + + # ---- sharing ------------------------------------------------------- + + def share( + self, + space_id: str, + actor_id: str, + actor_email: Optional[str], + grantee_email: str, + permission: ShareRole = "viewer", + ) -> SpaceMember: + """Grant ``grantee_email`` a role on the space (owner only).""" + self._require(space_id, actor_id, actor_email, "owner") + if permission not in ("viewer", "editor"): + raise MemorySpaceError(f"invalid share permission '{permission}'") + member = SpaceMember( + email=grantee_email.strip().lower(), + permission=permission, + created_at=_now_iso(), + ) + self.repository.put_member(space_id, member) + self._touch(space_id) + return member + + def update_share( + self, + space_id: str, + actor_id: str, + actor_email: Optional[str], + grantee_email: str, + permission: ShareRole, + ) -> SpaceMember: + """Change an existing grant's role (owner only), preserving its origin. + + Distinct from :meth:`share` (upsert-create) so a PATCH gets proper + not-found semantics and keeps the original ``created_at``. + """ + self._require(space_id, actor_id, actor_email, "owner") + if permission not in ("viewer", "editor"): + raise MemorySpaceError(f"invalid share permission '{permission}'") + existing = self.repository.get_member(space_id, grantee_email) + if existing is None: + raise MemorySpaceNotFoundError( + f"'{grantee_email}' is not a member of memory space '{space_id}'" + ) + member = SpaceMember( + email=grantee_email.strip().lower(), + permission=permission, + created_at=existing.created_at, + ) + self.repository.put_member(space_id, member) + self._touch(space_id) + return member + + def revoke( + self, + space_id: str, + actor_id: str, + actor_email: Optional[str], + grantee_email: str, + ) -> None: + """Remove a grant (owner only).""" + self._require(space_id, actor_id, actor_email, "owner") + self.repository.delete_member(space_id, grantee_email) + self._touch(space_id) + + def list_members( + self, space_id: str, user_id: str, user_email: Optional[str] = None + ) -> List[SpaceMember]: + """List a space's shared grants (owner or editor).""" + self._require(space_id, user_id, user_email, "editor") + return self.repository.list_members(space_id) + + # ---- index (MEMORY.md) --------------------------------------------- + + def read_index( + self, space_id: str, user_id: str, user_email: Optional[str] = None + ) -> str: + space, _ = self._require(space_id, user_id, user_email, "viewer") + if not space.index_s3_key: + return "" + return self.store.get(space.index_s3_key).decode("utf-8") + + def update_index( + self, + space_id: str, + user_id: str, + user_email: Optional[str], + body: str, + ) -> MemorySpace: + """Replace the MEMORY.md index text (editor+).""" + space, _ = self._require(space_id, user_id, user_email, "editor") + content = body.encode("utf-8") + old_key = space.index_s3_key + new_key = self.store.put( + space_id=space_id, content=content, content_type="text/markdown" + ) + space.index_s3_key = new_key + space.index_content_hash = compute_content_hash(content) + space.updated_at = _now_iso() + self.repository.put_space(space) + if old_key and old_key != new_key and not self._key_in_use(space_id, old_key): + self.store.delete(old_key) + return space + + # ---- entries ------------------------------------------------------- + + def list_entries( + self, + space_id: str, + user_id: str, + user_email: Optional[str] = None, + *, + entry_type: Optional[EntryType] = None, + where: Optional[Dict[str, Any]] = None, + ) -> List[MemoryEntryRef]: + """List manifest entries, optionally filtered by type and indexed fields. + + ``where`` matches dotted keys against each entry's ``indexed`` map by + exact equality (operator queries like ``"<7d"`` are PR-2). This is the + "who owes what" path β€” a manifest scan, never a body load. + """ + self._require(space_id, user_id, user_email, "viewer") + entries = self.repository.get_index(space_id).entries + if entry_type is not None: + entries = [e for e in entries if e.entry_type == entry_type] + if where: + entries = [ + e + for e in entries + if all(_get_nested(e.indexed, k) == v for k, v in where.items()) + ] + return entries + + def read_entry( + self, + space_id: str, + user_id: str, + user_email: Optional[str], + slug: str, + ) -> str: + self._require(space_id, user_id, user_email, "viewer") + ref = self._find_ref(space_id, slug) + if ref is None: + raise MemorySpaceNotFoundError( + f"entry '{slug}' not found in space '{space_id}'" + ) + return self.store.get(ref.s3_key).decode("utf-8") + + def write_entry( + self, + space_id: str, + user_id: str, + user_email: Optional[str], + slug: str, + body: str, + *, + entry_type: EntryType = "fact", + description: str = "", + indexed: Optional[Dict[str, Any]] = None, + ) -> MemoryEntryRef: + """Create or replace an entry (editor+); updates the manifest.""" + self._require(space_id, user_id, user_email, "editor") + if not slug or not slug.strip(): + raise MemorySpaceError("an entry slug is required") + + content = body.encode("utf-8") + s3_key = self.store.put( + space_id=space_id, content=content, content_type="text/markdown" + ) + ref = MemoryEntryRef( + slug=slug, + entry_type=entry_type, + description=description, + content_hash=compute_content_hash(content), + size=len(content), + s3_key=s3_key, + updated=_now_iso(), + updated_by=user_id, + indexed=indexed or {}, + ) + + def apply(index: MemoryIndex) -> List[MemoryEntryRef]: + old = [e for e in index.entries if e.slug == slug] + kept = [e for e in index.entries if e.slug != slug] + kept.append(ref) + kept.sort(key=lambda e: e.slug) + index.entries = kept + return old + + old, final_index = self._mutate_index(space_id, apply) + + # GC any object the replaced entry uniquely referenced. + for prev in old: + if prev.s3_key != s3_key and not self._key_in_use( + space_id, prev.s3_key, index=final_index + ): + self.store.delete(prev.s3_key) + return ref + + def delete_entry( + self, + space_id: str, + user_id: str, + user_email: Optional[str], + slug: str, + ) -> None: + """Remove an entry from the manifest (editor+) and GC its object.""" + self._require(space_id, user_id, user_email, "editor") + + def apply(index: MemoryIndex) -> List[MemoryEntryRef]: + removed = [e for e in index.entries if e.slug == slug] + if not removed: + raise MemorySpaceNotFoundError( + f"entry '{slug}' not found in space '{space_id}'" + ) + index.entries = [e for e in index.entries if e.slug != slug] + return removed + + removed, final_index = self._mutate_index(space_id, apply) + for prev in removed: + if not self._key_in_use(space_id, prev.s3_key, index=final_index): + self.store.delete(prev.s3_key) + + # ---- helpers ------------------------------------------------------- + + def _mutate_index( + self, space_id: str, apply: Callable[[MemoryIndex], "_T"] + ) -> Tuple["_T", MemoryIndex]: + """Read-modify-conditional-write the manifest with bounded retry. + + ``apply(index)`` mutates ``index.entries`` in place and returns any + value the caller needs afterward (e.g. the replaced refs to GC). The + helper bumps the version and persists conditionally on the version it + read; on a concurrent change it re-reads and re-applies, converging + because entry writes touch a single slug. Exhausting the retries raises + :class:`MemorySpaceConcurrencyError`. Returns ``(apply_result, final_index)``. + """ + for attempt in range(_MAX_MANIFEST_RETRIES): + index = self.repository.get_index(space_id) + expected = index.version + result = apply(index) # may raise (e.g. NotFound) β€” propagate as-is + index.version = expected + 1 + try: + self.repository.put_index(index, expected_version=expected) + except OptimisticLockError: + if attempt + 1 >= _MAX_MANIFEST_RETRIES: + raise MemorySpaceConcurrencyError( + f"memory space '{space_id}' is being edited concurrently; " + "retry the write" + ) + continue + return result, index + # Unreachable: the loop either returns or raises above. + raise MemorySpaceConcurrencyError(space_id) + + def _find_ref(self, space_id: str, slug: str) -> Optional[MemoryEntryRef]: + for ref in self.repository.get_index(space_id).entries: + if ref.slug == slug: + return ref + return None + + def _key_in_use( + self, + space_id: str, + s3_key: str, + *, + index: Optional[MemoryIndex] = None, + ) -> bool: + """True if any entry or the space index still references ``s3_key``. + + Objects are content-addressed, so identical content under different + slugs shares one object β€” never delete a key another ref still points + at. + """ + idx = index if index is not None else self.repository.get_index(space_id) + if any(e.s3_key == s3_key for e in idx.entries): + return True + space = self.repository.get_space(space_id) + return bool(space and space.index_s3_key == s3_key) + + def _touch(self, space_id: str) -> None: + space = self.repository.get_space(space_id) + if space is not None: + space.updated_at = _now_iso() + self.repository.put_space(space) diff --git a/backend/src/apis/shared/memory/store.py b/backend/src/apis/shared/memory/store.py new file mode 100644 index 00000000..757935fe --- /dev/null +++ b/backend/src/apis/shared/memory/store.py @@ -0,0 +1,243 @@ +"""S3-backed store for a Memory Space's markdown bytes (PR-1). + +A Memory Space is a per-owner (optionally shared) markdown "second brain": +an always-loaded index (``MEMORY.md``) plus a set of typed entry files. The +bytes are too large / too many to inline in DynamoDB (400 KB item limit), so +they live in the ``memory-spaces`` S3 bucket and the DynamoDB rows carry only +lightweight manifests (``MemoryEntryRef``) plus a pointer to the index object. + +This store is a faithful sibling of ``apis/shared/skills/resource_store.py`` +(the skills reference-file / progressive-disclosure mechanism the Memory +Spaces spec re-scopes from per-skill to per-space): + + - Objects are **content-addressed**: the key is + ``spaces/{space_id}/{content_hash}`` where ``content_hash`` is the sha256 + hex of the bytes. Identical content within a space dedupes to one object, + and every write produces an immutable object β€” a new edit is a new object + plus a manifest-pointer swap, which keeps concurrency clean (PR-6) and + makes the readable-path export (spec Β§9) a pure function of the manifest. + - The manifest / index pointer references objects by key; the bytes never + travel through DynamoDB. + +Boundary: this module lives under ``apis/shared/memory/`` and is import-clean +(it never imports ``app_api``/``inference_api``). The user-facing CRUD path +(app-api, PR-5) and the runtime read/write path (inference-api, PR-2/PR-4) +both reach it through ``apis.shared``. + +Configuration: the bucket name comes from ``S3_MEMORY_SPACES_BUCKET_NAME`` +(set on both compute roles by the CDK ``MemorySpacesConstruct`` wiring). When +boto3 or the bucket name is absent (local dev without AWS), the store is +``enabled == False`` and every operation raises ``MemorySpaceStoreError`` so a +misconfigured write surfaces loudly rather than silently "succeeding" with no +bytes persisted. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +from typing import Optional + +try: # boto3 is absent in some local-dev setups + import boto3 + from botocore.exceptions import ClientError +except ImportError: # pragma: no cover - exercised only without boto3 + boto3 = None + ClientError = Exception # type: ignore[assignment, misc] + +logger = logging.getLogger(__name__) + +# AWS-managed (SSE-S3 / AES256) encryption, matching the bucket default and +# the skills/artifacts/file-upload buckets. +_SSE_ALGORITHM = "AES256" + + +class MemorySpaceStoreError(RuntimeError): + """Raised when the store is asked to do work it cannot complete. + + Covers both "storage not configured" (no bucket / no boto3) and an + unexpected S3 failure, so callers have one error type to translate. + """ + + +def content_key(space_id: str, content_hash: str) -> str: + """Return the content-addressed object key for a space's markdown file.""" + return f"spaces/{space_id}/{content_hash}" + + +def compute_content_hash(content: bytes) -> str: + """Return the sha256 hex digest used as the content address.""" + return hashlib.sha256(content).hexdigest() + + +class MemorySpaceStore: + """Put / get / delete a Memory Space's markdown bytes in S3.""" + + def __init__( + self, + bucket_name: Optional[str] = None, + s3_client: Optional[object] = None, + ) -> None: + self.bucket_name = bucket_name or os.environ.get( + "S3_MEMORY_SPACES_BUCKET_NAME" + ) + # Allow an explicit client (tests inject a moto client); otherwise it + # is created lazily on first use so importing the module never needs + # AWS creds. + self._s3 = s3_client + + @property + def enabled(self) -> bool: + """True when a bucket is configured and boto3 is importable.""" + return bool(self.bucket_name) and boto3 is not None + + def _client(self): + if self._s3 is None: + if boto3 is None: # pragma: no cover - import-guarded above + raise MemorySpaceStoreError( + "memory space storage unavailable: boto3 is not installed" + ) + self._s3 = boto3.client("s3") + return self._s3 + + def _require_enabled(self) -> None: + if not self.enabled: + raise MemorySpaceStoreError( + "memory space storage is not configured " + "(S3_MEMORY_SPACES_BUCKET_NAME is unset)" + ) + + def put(self, *, space_id: str, content: bytes, content_type: str) -> str: + """Persist markdown bytes content-addressed; return the object key. + + Computes the sha256 of ``content``, derives the + ``spaces/{space_id}/{content_hash}`` key, and uploads. If an object + already exists at that key (same content), the upload is skipped + (dedupe) β€” the key is returned either way. + """ + self._require_enabled() + digest = compute_content_hash(content) + key = content_key(space_id, digest) + client = self._client() + + if self._object_exists(key): + logger.info( + "memory-spaces: dedupe hit for space=%s key=%s (%d bytes)", + space_id, + key, + len(content), + ) + return key + + try: + client.put_object( + Bucket=self.bucket_name, + Key=key, + Body=content, + ContentType=content_type or "text/markdown", + ServerSideEncryption=_SSE_ALGORITHM, + ) + except ClientError as e: # pragma: no cover - network/permission path + logger.error( + "memory-spaces: put failed for space=%s key=%s: %s", + space_id, + key, + e, + ) + raise MemorySpaceStoreError( + f"failed to store memory bytes for space '{space_id}'" + ) from e + + logger.info( + "memory-spaces: stored space=%s key=%s (%d bytes)", + space_id, + key, + len(content), + ) + return key + + def get(self, s3_key: str) -> bytes: + """Return the bytes for an object key. Raises if missing/unavailable.""" + self._require_enabled() + client = self._client() + try: + response = client.get_object(Bucket=self.bucket_name, Key=s3_key) + return response["Body"].read() + except ClientError as e: + code = e.response.get("Error", {}).get("Code", "") + if code in ("NoSuchKey", "404"): + raise MemorySpaceStoreError( + f"memory file not found at key '{s3_key}'" + ) from e + logger.error("memory-spaces: get failed for key=%s: %s", s3_key, e) + raise MemorySpaceStoreError( + f"failed to read memory file at key '{s3_key}'" + ) from e + + def list_keys(self, space_id: str) -> list[str]: + """List every object key stored under a space's prefix. + + Used by the consolidation pass to find orphaned objects (keys no + manifest entry or index pointer references) for garbage collection. + Returns an empty list when storage is not configured. + """ + if not self.enabled: + return [] + client = self._client() + prefix = f"spaces/{space_id}/" + keys: list[str] = [] + token: Optional[str] = None + while True: + kwargs = {"Bucket": self.bucket_name, "Prefix": prefix} + if token: + kwargs["ContinuationToken"] = token + try: + resp = client.list_objects_v2(**kwargs) + except ClientError as e: # pragma: no cover - network/permission path + logger.error("memory-spaces: list failed for space=%s: %s", space_id, e) + raise MemorySpaceStoreError( + f"failed to list memory objects for space '{space_id}'" + ) from e + keys.extend(obj["Key"] for obj in resp.get("Contents", [])) + if not resp.get("IsTruncated"): + break + token = resp.get("NextContinuationToken") + return keys + + def delete(self, s3_key: str) -> None: + """Delete an object key. Best-effort β€” never raises on the storage + miss path (deleting an already-absent object is a no-op in S3).""" + if not self.enabled: + return + client = self._client() + try: + client.delete_object(Bucket=self.bucket_name, Key=s3_key) + except ClientError: # pragma: no cover - best-effort cleanup + logger.warning( + "memory-spaces: delete failed for key=%s", s3_key, exc_info=True + ) + + def _object_exists(self, key: str) -> bool: + client = self._client() + try: + client.head_object(Bucket=self.bucket_name, Key=key) + return True + except ClientError as e: + code = e.response.get("Error", {}).get("Code", "") + if code in ("404", "NoSuchKey", "NotFound"): + return False + # Any other error (permissions, throttling) is real β€” surface it + # rather than masquerading as "absent" and double-uploading. + raise + + +_store: Optional[MemorySpaceStore] = None + + +def get_memory_space_store() -> MemorySpaceStore: + """Get or create the process-global memory-space store.""" + global _store + if _store is None: + _store = MemorySpaceStore() + return _store diff --git a/backend/src/apis/shared/memory/templates.py b/backend/src/apis/shared/memory/templates.py new file mode 100644 index 00000000..a18660c9 --- /dev/null +++ b/backend/src/apis/shared/memory/templates.py @@ -0,0 +1,109 @@ +"""Space templates β€” presets that seed a new Memory Space (PR-1). + +A template supplies the starter ``MEMORY.md`` index text, the entry types the +space uses, and the ``always_load`` rule (which entries hydrate at an agent's +wake-up). Templates keep the Oliver-class ergonomics without hardcoding any +one use case: "Oliver" is the ``chief-of-staff`` template + a bound agent. + +PR-1 ships the registry and starter index text. Richer template seeding +(pre-created example entries, per-type frontmatter scaffolds) can be layered +in PR-2 when the read path lands; the shape here is deliberately minimal. +""" + +from __future__ import annotations + +from typing import Dict, List + +from pydantic import BaseModel, Field + +from .models import EntryType + + +class SpaceTemplate(BaseModel): + """A preset for creating a Memory Space.""" + + template_id: str + name: str + description: str + entry_types: List[EntryType] = Field(default_factory=list) + # Entries hydrated at wake-up. ``MEMORY.md`` is the index; ``latest:{type}`` + # resolves to the most-recent entry of that type (the Oliver rule). + always_load: List[str] = Field(default_factory=lambda: ["MEMORY.md"]) + starter_index: str = "# Memory\n" + + +_BLANK = SpaceTemplate( + template_id="blank", + name="Blank Wiki", + description="An empty space. Start from scratch.", + entry_types=["entity", "episodic", "fact"], + always_load=["MEMORY.md"], + starter_index=( + "# Memory\n\n" + "This is your memory index. One-line pointers to the things worth " + "remembering go here; the details live in linked entries.\n" + ), +) + +_CHIEF_OF_STAFF = SpaceTemplate( + template_id="chief-of-staff", + name="Chief of Staff", + description=( + "An institutional-memory space for a chief-of-staff-style assistant: " + "people, projects, and commitments, with a daily log and periodic " + "briefs." + ), + entry_types=["entity", "episodic", "fact"], + always_load=["MEMORY.md", "latest:episodic/daily", "latest:episodic/brief"], + starter_index=( + "# Memory\n\n" + "## Strategic priorities\n" + "_What matters most right now. Everything maps back to these._\n\n" + "## Key people\n" + "_Pointers to `people/` entries β€” role, what they care about, what's " + "owed in both directions._\n\n" + "## Active projects\n" + "_Pointers to `projects/` entries β€” status and stakeholders._\n\n" + "## Open commitments\n" + "_Who owes what, and by when. The commitments sections in each person " + "entry are the CRM._\n" + ), +) + +_RESEARCH_NOTEBOOK = SpaceTemplate( + template_id="research-notebook", + name="Research Notebook", + description=( + "A space for research work: papers, threads of inquiry, and open " + "questions, with a running log." + ), + entry_types=["entity", "episodic", "fact"], + always_load=["MEMORY.md", "latest:episodic/log"], + starter_index=( + "# Memory\n\n" + "## Threads of inquiry\n" + "_Active lines of research β€” pointers to their entries._\n\n" + "## Papers & sources\n" + "_Key references worth remembering._\n\n" + "## Open questions\n" + "_What we don't yet know and want to resolve._\n" + ), +) + + +TEMPLATES: Dict[str, SpaceTemplate] = { + t.template_id: t + for t in (_BLANK, _CHIEF_OF_STAFF, _RESEARCH_NOTEBOOK) +} + +DEFAULT_TEMPLATE_ID = "blank" + + +def get_template(template_id: str) -> SpaceTemplate: + """Return a template by id, or raise ``KeyError`` if unknown.""" + return TEMPLATES[template_id] + + +def is_valid_template(template_id: str) -> bool: + """True when ``template_id`` names a known template.""" + return template_id in TEMPLATES diff --git a/backend/src/apis/shared/oauth/agentcore_identity.py b/backend/src/apis/shared/oauth/agentcore_identity.py index ba694a6f..ea8f0fb5 100644 --- a/backend/src/apis/shared/oauth/agentcore_identity.py +++ b/backend/src/apis/shared/oauth/agentcore_identity.py @@ -88,64 +88,29 @@ async def poll_for_token(self) -> str: _CALLBACK_URL_ENV = "AGENTCORE_LOCAL_OAUTH_CALLBACK_URL" -def _vendor_baseline_params( - provider_type: Optional[str], *, force_authentication: bool = False -) -> Dict[str, str]: - """Hardcoded params AgentCore Identity *requires* for a given vendor. - - Per the AgentCore Identity authentication docs - (https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity-authentication.html), - Google must receive `access_type=offline` to issue a refresh token β€” - without it the vault entry expires after ~1 hour with no refresh - path. This is non-negotiable: it always wins over admin-supplied - extras to prevent an admin from accidentally turning it off. - - When `force_authentication=True` we additionally send `prompt=consent` - for Google. Google only re-issues a refresh token on subsequent - authorizations if the user is shown the consent screen again β€” so a - user who clicks "Disconnect" then "Reconnect" would otherwise get a - vault entry with an access token but no refresh token, putting them - right back in the hourly-reconsent loop on the next access-token - expiry. We only set this on the explicit re-consent path; first-time - consent and silent refreshes don't trigger it. - """ - if not provider_type: - return {} - if provider_type.lower() == "google": - params = {"access_type": "offline"} - if force_authentication: - params["prompt"] = "consent" - return params - return {} - - def custom_parameters_for( - provider_type: Optional[str], admin_extras: Optional[Dict[str, str]] = None, - *, - force_authentication: bool = False, ) -> Optional[Dict[str, str]]: - """Build the `customParameters` payload AgentCore Identity wants forwarded. - - Merges admin-supplied extras (e.g. Google `hd=mycorp.com` for domain - restriction) with the hardcoded vendor baseline. Baseline keys win on - conflict β€” admins cannot turn off a documented requirement. - - `force_authentication=True` mirrors the same flag on - `get_token_for_user`: when the caller is forcing AgentCore to bypass - the vault and walk the user through consent again, this enables any - vendor-specific extras needed for that path (e.g. Google's - `prompt=consent` so a refresh token is re-issued). - - Returns None when the merged result would be empty, so callers can - pass the value through to the SDK unconditionally without sending - an empty `customParameters` map. + """Normalize a connector's admin-supplied OAuth `customParameters`. + + Returns the connector's configured extras verbatim, or None when empty + so callers can pass the value straight through to the SDK without + sending an empty `customParameters` map. + + Vendor-specific requirements are NOT hardcoded here β€” they're supplied + per-connector via the admin "Custom OAuth Parameters" field. Per the + AgentCore Identity docs + (https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity-authentication.html) + the delivered vendors do not add these on their own, so for a Google + connector an admin must configure `access_type=offline` (to have Google + issue a refresh token at all) and typically `prompt=consent` (so a + refresh token is re-issued on reconnect). Because every call site now + forwards the same stored value, the `customParameters` map matches + across the consent grant and later token retrievals β€” AgentCore factors + it into the vault key, so a mismatch would otherwise falsely report + consent-required. """ - baseline = _vendor_baseline_params( - provider_type, force_authentication=force_authentication - ) - merged = {**(admin_extras or {}), **baseline} - return merged or None + return dict(admin_extras) if admin_extras else None @dataclass(frozen=True) diff --git a/backend/src/apis/shared/oauth/models.py b/backend/src/apis/shared/oauth/models.py index f965bf08..a1b1cdcf 100644 --- a/backend/src/apis/shared/oauth/models.py +++ b/backend/src/apis/shared/oauth/models.py @@ -114,12 +114,13 @@ class OAuthProvider: # CANVAS; both are None for Google/Microsoft/GitHub. oauth_discovery_url: Optional[str] = None authorization_server_metadata: Optional[Dict[str, Any]] = None - # Vendor-specific OAuth parameters merged into AgentCore Identity's - # `customParameters` at request time. Examples: Google `hd=mycorp.com` - # to restrict to a Workspace domain, `prompt=consent` to force the - # consent screen. Hardcoded baselines (e.g. Google's - # `access_type=offline`) win on conflict β€” admins cannot accidentally - # turn off a documented requirement. + # Vendor-specific OAuth parameters forwarded verbatim into AgentCore + # Identity's `customParameters` at request time. The delivered vendors + # don't add these on their own, so a Google connector must set + # `access_type=offline` here (for Google to issue a refresh token) and + # typically `prompt=consent` (so a refresh token is re-issued on + # reconnect); other examples: `hd=mycorp.com` to restrict to a Workspace + # domain. See the AgentCore Identity docs for each vendor's requirement. custom_parameters: Optional[Dict[str, str]] = None # Maps this connector to a file-source adapter (e.g. "google-drive"), # making it selectable as a file source in the assistant editor. The diff --git a/backend/src/apis/shared/rbac/capabilities.py b/backend/src/apis/shared/rbac/capabilities.py new file mode 100644 index 00000000..328eed0c --- /dev/null +++ b/backend/src/apis/shared/rbac/capabilities.py @@ -0,0 +1,44 @@ +"""Feature-capability checks riding the AppRole grant system. + +A *capability* is a feature-level permission ("may this user use scheduled +runs?") rather than a resource-level one ("may this user call this tool?"). +Rather than adding a net-new allowlist table or a new grant axis for the +first capability, capability ids are granted through the mature **tools +grant axis**: an admin adds the capability id to a role's ``grantedTools`` +(e.g. a ``scheduled_runs_beta`` role granting ``scheduled-runs``), and this +module checks it via the same cached RBAC resolution path every tool check +uses (scheduled-agent-runs.md Β§6 β€” "reuses the mature RBAC resolution path, +no net-new allowlist table"). + +Consequences to be aware of: + +* A wildcard tools grant (``*`` β€” the seeded ``system_admin`` role) holds + every capability implicitly. Admins are in every beta by construction. +* Capability ids share a namespace with tool ids. They never collide with + a real tool in practice (no tool in the catalog is named like a feature), + and a granted capability id simply matches no tool at agent-build time β€” + but pick ids that read as features, not tools. +* GA for a capability = grant its id to the ``default`` role. One config + change, no redeploy (scheduled-agent-runs.md Β§6, "GA path"). + +If capabilities outgrow this (per-capability metadata, UI surfacing), the +RBAC gap ledger already names the real fix: extend the grant vocabulary +with a first-class axis (agentic-platform-primitives.md Β§1, RBAC row). +""" + +from __future__ import annotations + +from apis.shared.auth.models import User +from apis.shared.rbac.service import get_app_role_service + +#: Gates the headless-runs surface ("Run now" today; schedule CRUD in +#: Phase B). Granted to the beta cohort's role; GA = grant to ``default``. +SCHEDULED_RUNS_CAPABILITY = "scheduled-runs" + + +async def user_has_capability(user: User, capability_id: str) -> bool: + """True iff ``user`` resolves the capability through their AppRoles. + + Wildcard tool grants satisfy every capability (see module docstring). + """ + return await get_app_role_service().can_access_tool(user, capability_id) diff --git a/backend/src/apis/shared/rbac/service.py b/backend/src/apis/shared/rbac/service.py index fb1b6d40..8e1899db 100644 --- a/backend/src/apis/shared/rbac/service.py +++ b/backend/src/apis/shared/rbac/service.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from apis.shared.auth.models import User +from apis.shared.tools.scoped_ids import base_tool_id from .models import AppRole, UserEffectivePermissions from .repository import AppRoleRepository @@ -213,6 +214,33 @@ async def get_accessible_tools(self, user: User) -> List[str]: permissions = await self.resolve_user_permissions(user) return permissions.tools + async def filter_requested_tools( + self, user: User, requested: List[str] + ) -> List[str]: + """Intersect a client-requested tool list with the user's RBAC grant. + + Client-supplied ``enabled_tools`` (from the SPA tool picker, a + "Run now" body, or a schedule-creation request) must never *grant* + access the caller's AppRole does not already carry β€” the picker is a + UI convenience, not a security boundary. This narrows the request to + what the user may actually invoke, preserving the caller's order and + scoping (mirrors ``_apply_enabled_skills_filter``'s narrow-never-grant + contract on the skills axis). + + A ``"*"`` grant passes everything through. A scoped id (``base::tool``) + is allowed when its base server id is granted, so a role that grants a + whole MCP server still admits that server's per-tool selections. + """ + permissions = await self.resolve_user_permissions(user) + allowed = set(permissions.tools) + if "*" in allowed: + return list(requested) + return [ + tool_id + for tool_id in requested + if tool_id in allowed or base_tool_id(tool_id) in allowed + ] + async def get_accessible_models(self, user: User) -> List[str]: """Get list of model IDs user can access.""" permissions = await self.resolve_user_permissions(user) diff --git a/backend/src/apis/shared/scheduled_prompts/__init__.py b/backend/src/apis/shared/scheduled_prompts/__init__.py new file mode 100644 index 00000000..dfa800c3 --- /dev/null +++ b/backend/src/apis/shared/scheduled_prompts/__init__.py @@ -0,0 +1,46 @@ +"""Scheduled prompts β€” schedule data model + CRUD for scheduled agent runs. + +B1 (this PR) only creates/edits/pauses/deletes the record. The dispatcher +that reads DueScheduleIndex and actually fires a run is B2 +(docs/specs/scheduled-agent-runs.md Β§7). +""" + +from .models import ( + DUE_INDEX_PK, + ScheduleCadence, + ScheduledPrompt, + ScheduledPromptState, +) +from .service import ( + ScheduledPromptLimitExceeded, + compute_next_run_at, + create_scheduled_prompt, + delete_scheduled_prompt, + get_scheduled_prompt, + list_due_schedules, + list_scheduled_prompts, + max_schedules_per_user, + rearm_schedule, + record_run_result, + set_schedule_state, + update_scheduled_prompt, +) + +__all__ = [ + "DUE_INDEX_PK", + "ScheduleCadence", + "ScheduledPrompt", + "ScheduledPromptLimitExceeded", + "ScheduledPromptState", + "compute_next_run_at", + "create_scheduled_prompt", + "delete_scheduled_prompt", + "get_scheduled_prompt", + "list_due_schedules", + "list_scheduled_prompts", + "max_schedules_per_user", + "rearm_schedule", + "record_run_result", + "set_schedule_state", + "update_scheduled_prompt", +] diff --git a/backend/src/apis/shared/scheduled_prompts/models.py b/backend/src/apis/shared/scheduled_prompts/models.py new file mode 100644 index 00000000..f0f69d87 --- /dev/null +++ b/backend/src/apis/shared/scheduled_prompts/models.py @@ -0,0 +1,87 @@ +"""Scheduled-prompt models β€” the schedule data model for scheduled agent runs. + +A ScheduledPrompt is inert data: nothing fires unless the (future, B2) +dispatcher reads it from the sparse DueScheduleIndex, so deleting the record +is total revocation of the schedule (docs/specs/scheduled-agent-runs.md Β§5). +This module (B1) only creates/edits/pauses/deletes the record β€” the +dispatcher/worker that actually calls ``run_agent_headless`` lands in B2. + +Stored in the sessions-metadata table under the owning user (adjacency list): + PK: USER#{user_id} + SK: SCHEDPROMPT#{schedule_id} + +DueScheduleIndex (sparse) keys are present ONLY while state == "active" β€” a +paused schedule is physically invisible to the dispatcher, not filtered at +query time (mirrors SyncPolicy's DueSyncIndex discipline exactly). +""" + +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +ScheduleCadence = Literal["daily", "weekday", "weekly", "interval"] +IntervalUnit = Literal["minutes", "hours"] +ScheduledPromptState = Literal["active", "paused", "paused_error"] + +#: Sentinel partition key for the sparse due index. Single logical partition +#: β€” fine at our scale; shard to SCHEDDUE#{0..N} if writes ever demand it. +DUE_INDEX_PK = "SCHEDDUE" + + +class ScheduledPrompt(BaseModel): + """Complete scheduled-prompt model (internal use).""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + schedule_id: str = Field(..., alias="scheduleId", description="Schedule identifier (sched-{12-hex})") + user_id: str = Field(..., alias="userId", description="Owning user; every record lives under USER#{user_id}") + assistant_id: Optional[str] = Field( + None, alias="assistantId", description="Target assistant's ast-id; None runs the default agent" + ) + label: str = Field(..., min_length=1, max_length=200, description="Human-readable schedule name, e.g. 'Morning Briefing'") + prompt_text: str = Field(..., alias="promptText", min_length=1, max_length=20_000, description="The prompt to run") + cadence: ScheduleCadence = Field(..., description="Re-run cadence (bounded enum β€” no cron)") + hour_local: int = Field(..., alias="hourLocal", ge=0, le=23, description="Local hour of day to run, 0-23; ignored for 'interval'") + weekday: Optional[int] = Field( + None, ge=0, le=6, description="0=Monday..6=Sunday; required when cadence == 'weekly'" + ) + # Custom "every N" cadence: interpreted as a fixed delta from the previous + # fire (no wall-clock anchor). Stored as value + unit so the UI can render + # "every 6 hours" losslessly; the engine converts to minutes on demand. + interval_value: Optional[int] = Field( + None, alias="intervalValue", ge=1, description="Magnitude of the custom interval; required when cadence == 'interval'" + ) + interval_unit: Optional["IntervalUnit"] = Field( + None, alias="intervalUnit", description="Unit for interval_value ('minutes'|'hours'); required when cadence == 'interval'" + ) + timezone: str = Field(..., description="IANA timezone, e.g. 'America/Boise'") + state: ScheduledPromptState = Field("active", description="Lifecycle state; only 'active' schedules appear in DueScheduleIndex") + state_reason: Optional[str] = Field(None, alias="stateReason", description="Human-readable reason for a paused state") + next_run_at: Optional[str] = Field(None, alias="nextRunAt", description="ISO 8601 UTC next due time; drives DueScheduleIndex sort key") + last_run_at: Optional[str] = Field(None, alias="lastRunAt", description="ISO 8601 timestamp of the last completed run") + last_run_status: Optional[str] = Field(None, alias="lastRunStatus", description="Outcome of the last completed run") + last_run_session_id: Optional[str] = Field(None, alias="lastRunSessionId", description="Session the last run materialized as") + last_error: Optional[str] = Field(None, alias="lastError", description="Error detail from the last failed run") + consecutive_failures: int = Field( + 0, + alias="consecutiveFailures", + description="Consecutive error runs; reset to 0 on a completed run. Drives the worker's repeated-failure breaker (mirrors SyncPolicy).", + ) + # Runaway guards (B1 fields only β€” B2 dispatcher/worker enforce these; + # present now so B2 needs no migration). + runs_today: int = Field(0, alias="runsToday", description="Runs fired today (runaway-guard counter)") + runs_today_date: Optional[str] = Field( + None, alias="runsTodayDate", description="UTC date (YYYY-MM-DD) runsToday is counting against" + ) + max_runs_per_day: int = Field( + 24, alias="maxRunsPerDay", description="Runaway guard ceiling; the dispatcher auto-pauses a schedule that exceeds this in a day" + ) + # Snapshot at creation (Phase A punch #7) β€” never resolved lazily at fire + # time, so the catalog shifting under a sleeping schedule can't cause a + # least-surprise violation. + enabled_tools: Optional[List[str]] = Field( + None, alias="enabledTools", description="Tool ids snapshot at creation time; None means 'all RBAC-allowed at creation'" + ) + deliver_email: bool = Field(False, alias="deliverEmail", description="v1.5 connector-email opt-in; inert in B1") + created_at: str = Field(..., alias="createdAt", description="ISO 8601 timestamp of creation") + updated_at: str = Field(..., alias="updatedAt", description="ISO 8601 timestamp of last update") diff --git a/backend/src/apis/shared/scheduled_prompts/service.py b/backend/src/apis/shared/scheduled_prompts/service.py new file mode 100644 index 00000000..d3311250 --- /dev/null +++ b/backend/src/apis/shared/scheduled_prompts/service.py @@ -0,0 +1,558 @@ +"""Scheduled-prompt repository β€” DynamoDB storage for scheduled agent runs. + +Lives in apis.shared because it will have (eventually) three independent +consumers: app-api (this PR's CRUD routes), the B2 dispatcher Lambda (due +sweep), and the B2 worker Lambda (run bookkeeping). B1 only wires the first. + +Storage (sessions-metadata table, adjacency list): + PK: USER#{user_id} | SK: SCHEDPROMPT#{schedule_id} + DueScheduleIndex (sparse): GSI3_PK = "SCHEDDUE", GSI3_SK = "{next_run_at}#{schedule_id}" + DueScheduleIndex keys exist only while state == "active". + +Cadence -> next_run_at is computed here, timezone-aware, so the (future) +dispatcher stays a dumb "who's due" query β€” no cron strings in the engine +(docs/specs/scheduled-agent-runs.md Β§5). +""" + +import logging +import os +import uuid +from datetime import date, datetime, timedelta, timezone +from typing import List, Optional, Union +from zoneinfo import ZoneInfo + +from .models import DUE_INDEX_PK, IntervalUnit, ScheduleCadence, ScheduledPrompt, ScheduledPromptState + +logger = logging.getLogger(__name__) + +#: Floor for the custom "every N" cadence. Below this a schedule fires faster +#: than the runaway guard (``max_runs_per_day``) tolerates and just self-pauses; +#: 15 minutes keeps the smallest interval useful without inviting a hot loop. +MIN_INTERVAL_MINUTES = 15 + + +class _Unset: + """Singleton sentinel meaning "argument not provided". + + Distinct from ``None``, which on the *clearable* update fields + (``assistant_id`` / ``enabled_tools``) means "clear this field". A plain + ``None`` default cannot tell "leave unchanged" from "clear". + """ + + _instance: Optional["_Unset"] = None + + def __new__(cls) -> "_Unset": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: # pragma: no cover - debug aid only + return "UNSET" + + +#: Sentinel for update_scheduled_prompt's clearable fields (see _Unset). +UNSET = _Unset() + +DEFAULT_MAX_SCHEDULES_PER_USER = 20 + +_WEEKDAY_CADENCES = {"weekday"} # Monday-Friday + + +class ScheduledPromptLimitExceeded(Exception): + """Raised when a user already has the maximum number of schedules.""" + + +def _iso(dt: datetime) -> str: + """Serialize a UTC datetime as strict ISO 8601 with a ``Z`` suffix. + + ``datetime.isoformat()`` renders the offset as ``+00:00``; normalize to + ``Z`` so the result is valid ISO 8601 that JavaScript's ``Date`` parses + (matches the sync_policies house style β€” see that module's docstring for + the Safari ``Invalid Date`` history). + """ + return dt.isoformat().replace("+00:00", "Z") + + +def _get_current_timestamp() -> str: + return _iso(datetime.now(timezone.utc)) + + +def _generate_schedule_id() -> str: + return f"sched-{uuid.uuid4().hex[:12]}" + + +def _table_name() -> str: + table = os.environ.get("DYNAMODB_SESSIONS_METADATA_TABLE_NAME") + if not table: + raise RuntimeError("DYNAMODB_SESSIONS_METADATA_TABLE_NAME environment variable is required") + return table + + +def _get_table(): + import boto3 + + return boto3.resource("dynamodb").Table(_table_name()) + + +def _due_sort_key(next_run_at: str, schedule_id: str) -> str: + return f"{next_run_at}#{schedule_id}" + + +def max_schedules_per_user() -> int: + return int(os.environ.get("SCHEDULED_RUNS_MAX_PER_USER", DEFAULT_MAX_SCHEDULES_PER_USER)) + + +def interval_to_minutes(value: Optional[int], unit: Optional[IntervalUnit]) -> Optional[int]: + """Canonicalize an interval value+unit to whole minutes. + + Returns ``None`` when either half is missing so callers can pass a + schedule's interval fields through unconditionally (a non-interval + schedule carries ``None`` for both). + """ + if value is None or unit is None: + return None + return value * 60 if unit == "hours" else value + + +def compute_next_run_at( + cadence: ScheduleCadence, + hour_local: int, + timezone_name: str, + weekday: Optional[int] = None, + from_time: Optional[datetime] = None, + interval_minutes: Optional[int] = None, +) -> str: + """Compute the next due timestamp (ISO 8601 UTC) for a cadence. + + Timezone-aware: the schedule's ``hour_local``/``weekday`` are interpreted + in ``timezone_name`` (IANA), then converted to UTC. Always returns a + time strictly after ``from_time`` (defaults to now) β€” a schedule created + at 9:05am local for "daily at 9am" fires tomorrow, not immediately. + + ``weekday`` is 0=Monday..6=Sunday (``datetime.weekday()`` convention) and + is required for cadence == "weekly"; ignored otherwise. ``interval_minutes`` + is required for cadence == "interval" (a plain delta from ``from_time`` β€” + no wall-clock or timezone anchor); ignored otherwise. + """ + if cadence == "interval": + if interval_minutes is None or interval_minutes <= 0: + raise ValueError("interval_minutes is required and must be positive when cadence == 'interval'") + base_utc = (from_time or datetime.now(timezone.utc)).astimezone(timezone.utc) + return _iso(base_utc + timedelta(minutes=interval_minutes)) + + tz = ZoneInfo(timezone_name) + base = (from_time or datetime.now(timezone.utc)).astimezone(tz) + + candidate = base.replace(hour=hour_local, minute=0, second=0, microsecond=0) + + if cadence == "daily": + if candidate <= base: + candidate += timedelta(days=1) + elif cadence == "weekday": + if candidate <= base: + candidate += timedelta(days=1) + while candidate.weekday() >= 5: # Sat=5, Sun=6 + candidate += timedelta(days=1) + elif cadence == "weekly": + if weekday is None: + raise ValueError("weekday is required when cadence == 'weekly'") + days_ahead = (weekday - candidate.weekday()) % 7 + candidate += timedelta(days=days_ahead) + if candidate <= base: + candidate += timedelta(days=7) + else: + raise ValueError(f"Unknown cadence: {cadence}") + + return _iso(candidate.astimezone(timezone.utc)) + + +async def create_scheduled_prompt( + user_id: str, + label: str, + prompt_text: str, + cadence: ScheduleCadence, + hour_local: int, + timezone_name: str, + weekday: Optional[int] = None, + interval_value: Optional[int] = None, + interval_unit: Optional[IntervalUnit] = None, + assistant_id: Optional[str] = None, + enabled_tools: Optional[List[str]] = None, + deliver_email: bool = False, +) -> ScheduledPrompt: + """Create an active scheduled prompt, due at the next occurrence of its cadence. + + Enforces the per-user schedule cap (bounded by the cap, so the list scan + to check it is cheap). ``enabled_tools`` is a snapshot β€” the caller + passes the RBAC-resolved tool set at creation time; it is never + re-resolved lazily at fire time (Phase A punch #7). + """ + existing = await list_scheduled_prompts(user_id) + if len(existing) >= max_schedules_per_user(): + raise ScheduledPromptLimitExceeded( + f"User {user_id} already has {len(existing)} scheduled prompts (max {max_schedules_per_user()})" + ) + + now = _get_current_timestamp() + next_run_at = compute_next_run_at( + cadence, + hour_local, + timezone_name, + weekday=weekday, + interval_minutes=interval_to_minutes(interval_value, interval_unit), + ) + schedule = ScheduledPrompt( + schedule_id=_generate_schedule_id(), + user_id=user_id, + assistant_id=assistant_id, + label=label, + prompt_text=prompt_text, + cadence=cadence, + hour_local=hour_local, + weekday=weekday, + interval_value=interval_value, + interval_unit=interval_unit, + timezone=timezone_name, + state="active", + next_run_at=next_run_at, + runs_today=0, + runs_today_date=None, + enabled_tools=enabled_tools, + deliver_email=deliver_email, + created_at=now, + updated_at=now, + ) + + item = schedule.model_dump(by_alias=True, exclude_none=True) + item["PK"] = f"USER#{user_id}" + item["SK"] = f"SCHEDPROMPT#{schedule.schedule_id}" + item["GSI3_PK"] = DUE_INDEX_PK + item["GSI3_SK"] = _due_sort_key(next_run_at, schedule.schedule_id) + + _get_table().put_item(Item=item) + logger.info(f"Created scheduled prompt {schedule.schedule_id} ({cadence}) for user {user_id}") + return schedule + + +async def get_scheduled_prompt(user_id: str, schedule_id: str) -> Optional[ScheduledPrompt]: + response = _get_table().get_item(Key={"PK": f"USER#{user_id}", "SK": f"SCHEDPROMPT#{schedule_id}"}) + item = response.get("Item") + return ScheduledPrompt.model_validate(item) if item else None + + +async def list_scheduled_prompts(user_id: str) -> List[ScheduledPrompt]: + from boto3.dynamodb.conditions import Key + + response = _get_table().query( + KeyConditionExpression=Key("PK").eq(f"USER#{user_id}") & Key("SK").begins_with("SCHEDPROMPT#") + ) + schedules = [] + for item in response.get("Items", []): + try: + schedules.append(ScheduledPrompt.model_validate(item)) + except Exception as e: + logger.warning(f"Failed to parse scheduled prompt item: {e}") + return schedules + + +async def list_due_schedules(now: Optional[str] = None, limit: int = 20) -> List[ScheduledPrompt]: + """Query the sparse DueScheduleIndex for active schedules whose next_run_at has passed. + + Returns schedules most-overdue first. Paused schedules have no GSI keys + and are physically absent from this index. Not called by anything yet + in B1 β€” this is the exact query shape B2's dispatcher will use. + """ + from boto3.dynamodb.conditions import Key + + now = now or _get_current_timestamp() + response = _get_table().query( + IndexName="DueScheduleIndex", + # '~' sorts after '#' and all timestamp characters, so this covers + # every "{ts}#{schedule_id}" key with ts <= now. + KeyConditionExpression=Key("GSI3_PK").eq(DUE_INDEX_PK) & Key("GSI3_SK").lte(f"{now}~"), + Limit=limit, + ScanIndexForward=True, + ) + schedules = [] + for item in response.get("Items", []): + try: + schedules.append(ScheduledPrompt.model_validate(item)) + except Exception as e: + logger.warning(f"Failed to parse due scheduled prompt item: {e}") + return schedules + + +async def set_schedule_state( + user_id: str, + schedule_id: str, + state: ScheduledPromptState, + next_run_at: Optional[str] = None, + state_reason: Optional[str] = None, +) -> bool: + """Transition a schedule's lifecycle state. + + Transition to "active" requires next_run_at and (re)adds the GSI keys; + any paused state REMOVEs them so the schedule drops out of + DueScheduleIndex. Returns False if the schedule does not exist. + """ + from botocore.exceptions import ClientError + + if state == "active" and not next_run_at: + raise ValueError("next_run_at is required when activating a scheduled prompt") + + names = {"#state": "state"} + values = {":state": state, ":updated_at": _get_current_timestamp()} + set_parts = ["#state = :state", "updatedAt = :updated_at"] + remove_parts = [] + + if state == "active": + set_parts += ["nextRunAt = :next", "GSI3_PK = :gsipk", "GSI3_SK = :gsisk"] + values[":next"] = next_run_at + values[":gsipk"] = DUE_INDEX_PK + values[":gsisk"] = _due_sort_key(next_run_at, schedule_id) + remove_parts.append("stateReason") + else: + remove_parts += ["GSI3_PK", "GSI3_SK"] + if state_reason: + set_parts.append("stateReason = :reason") + values[":reason"] = state_reason + + expression = "SET " + ", ".join(set_parts) + if remove_parts: + expression += " REMOVE " + ", ".join(remove_parts) + + try: + _get_table().update_item( + Key={"PK": f"USER#{user_id}", "SK": f"SCHEDPROMPT#{schedule_id}"}, + UpdateExpression=expression, + ExpressionAttributeNames=names, + ExpressionAttributeValues=values, + ConditionExpression="attribute_exists(PK)", + ) + logger.info(f"Scheduled prompt {schedule_id} -> {state}" + (f" ({state_reason})" if state_reason else "")) + return True + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + logger.warning(f"Cannot set state on missing scheduled prompt {schedule_id}") + return False + raise + + +async def rearm_schedule( + user_id: str, + schedule_id: str, + expected_next_run_at: str, + new_next_run_at: str, +) -> bool: + """Advance next_run_at, conditional on the currently stored value. + + Mirrors ``sync_policies.rearm_policy``: the (future) B2 dispatcher + re-arms BEFORE invoking the worker, so a double-fired tick is idempotent + β€” the second dispatcher loses the conditional write and skips the + schedule. Returns True if this caller won. Not called by anything yet + in B1; exercised directly by tests so B2 needs no behavior change here. + """ + from botocore.exceptions import ClientError + + try: + _get_table().update_item( + Key={"PK": f"USER#{user_id}", "SK": f"SCHEDPROMPT#{schedule_id}"}, + UpdateExpression="SET nextRunAt = :new, GSI3_SK = :gsisk, updatedAt = :updated_at", + ExpressionAttributeValues={ + ":new": new_next_run_at, + ":gsisk": _due_sort_key(new_next_run_at, schedule_id), + ":updated_at": _get_current_timestamp(), + ":expected": expected_next_run_at, + }, + ConditionExpression="nextRunAt = :expected", + ) + return True + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + logger.info(f"Scheduled prompt {schedule_id} already re-armed by another dispatcher; skipping") + return False + raise + + +async def record_run_result( + user_id: str, + schedule_id: str, + status: str, + session_id: Optional[str] = None, + error: Optional[str] = None, +) -> int: + """Record a run's outcome; return the new consecutive-failure streak. + + Bumps the runaway-guard counter (``runs_today``/``runs_today_date``, + reset on a UTC date rollover) and maintains ``consecutive_failures`` β€” a + ``"completed"`` run resets it to 0, any other status increments it + (mirrors ``sync_policies.update_sync_result``). The B2 worker uses the + returned streak to trip the repeated-failure breaker at any threshold; + the dispatcher reads ``runs_today`` to auto-pause a schedule that exceeds + ``max_runs_per_day``. + """ + today = date.today().isoformat() + existing = await get_scheduled_prompt(user_id, schedule_id) + runs_today = 1 + if existing is not None and existing.runs_today_date == today: + runs_today = existing.runs_today + 1 + + prior_failures = existing.consecutive_failures if existing is not None else 0 + consecutive_failures = 0 if status == "completed" else prior_failures + 1 + + now = _get_current_timestamp() + values = { + ":now": now, + ":status": status, + ":runs_today": runs_today, + ":today": today, + ":cf": consecutive_failures, + } + set_parts = [ + "lastRunAt = :now", + "lastRunStatus = :status", + "updatedAt = :now", + "runsToday = :runs_today", + "runsTodayDate = :today", + "consecutiveFailures = :cf", + ] + if session_id is not None: + set_parts.append("lastRunSessionId = :session_id") + values[":session_id"] = session_id + if error is not None: + set_parts.append("lastError = :error") + values[":error"] = error + + _get_table().update_item( + Key={"PK": f"USER#{user_id}", "SK": f"SCHEDPROMPT#{schedule_id}"}, + UpdateExpression="SET " + ", ".join(set_parts), + ExpressionAttributeValues=values, + ) + return consecutive_failures + + +async def update_scheduled_prompt( + user_id: str, + schedule_id: str, + *, + label: Optional[str] = None, + prompt_text: Optional[str] = None, + cadence: Optional[ScheduleCadence] = None, + hour_local: Optional[int] = None, + weekday: Optional[int] = None, + interval_value: Optional[int] = None, + interval_unit: Optional[IntervalUnit] = None, + timezone_name: Optional[str] = None, + assistant_id: Union[str, None, _Unset] = UNSET, + enabled_tools: Union[List[str], None, _Unset] = UNSET, + deliver_email: Optional[bool] = None, +) -> Optional[ScheduledPrompt]: + """Edit a schedule's fields in place. + + Any of ``cadence``/``hour_local``/``weekday``/``timezone_name`` changes + the due time: for an *active* schedule this recomputes and re-arms + ``next_run_at`` (and its GSI key) in the same write; a paused schedule + just remembers the new cadence for when it resumes (mirrors + ``sync_policies.change_policy_interval``). Returns None if the schedule + does not exist. + + ``assistant_id`` and ``enabled_tools`` are *clearable*: pass ``UNSET`` + (the default) to leave them untouched, a value to set them, or ``None`` + to clear them (the attribute is removed β€” the schedule falls back to the + default agent / all-RBAC-allowed tools). All other fields keep the plain + ``None`` == "leave unchanged" contract. + """ + schedule = await get_scheduled_prompt(user_id, schedule_id) + if schedule is None: + return None + + new_cadence = cadence if cadence is not None else schedule.cadence + new_hour_local = hour_local if hour_local is not None else schedule.hour_local + new_weekday = weekday if weekday is not None else schedule.weekday + new_interval_value = interval_value if interval_value is not None else schedule.interval_value + new_interval_unit = interval_unit if interval_unit is not None else schedule.interval_unit + new_timezone = timezone_name if timezone_name is not None else schedule.timezone + + cadence_changed = ( + new_cadence != schedule.cadence + or new_hour_local != schedule.hour_local + or new_weekday != schedule.weekday + or new_interval_value != schedule.interval_value + or new_interval_unit != schedule.interval_unit + or new_timezone != schedule.timezone + ) + + names: dict = {} + values = {":updated_at": _get_current_timestamp()} + set_parts = ["updatedAt = :updated_at"] + + if cadence_changed: + names["#tz"] = "timezone" + set_parts += ["cadence = :cadence", "hourLocal = :hour_local", "#tz = :timezone"] + values[":cadence"] = new_cadence + values[":hour_local"] = new_hour_local + values[":timezone"] = new_timezone + if new_weekday is not None: + set_parts.append("weekday = :weekday") + values[":weekday"] = new_weekday + if new_interval_value is not None: + set_parts.append("intervalValue = :interval_value") + values[":interval_value"] = new_interval_value + if new_interval_unit is not None: + set_parts.append("intervalUnit = :interval_unit") + values[":interval_unit"] = new_interval_unit + + if schedule.state == "active": + next_run_at = compute_next_run_at( + new_cadence, + new_hour_local, + new_timezone, + weekday=new_weekday, + interval_minutes=interval_to_minutes(new_interval_value, new_interval_unit), + ) + set_parts += ["nextRunAt = :next", "GSI3_PK = :gsipk", "GSI3_SK = :gsisk"] + values[":next"] = next_run_at + values[":gsipk"] = DUE_INDEX_PK + values[":gsisk"] = _due_sort_key(next_run_at, schedule_id) + + # Non-clearable fields: None == "leave unchanged". + for attr, value in (("label", label), ("promptText", prompt_text), ("deliverEmail", deliver_email)): + if value is not None: + set_parts.append(f"{attr} = :{attr}") + values[f":{attr}"] = value + + # Clearable fields: UNSET == leave, None == clear (REMOVE the attribute), + # any other value == set. + remove_parts: List[str] = [] + for attr, value in (("assistantId", assistant_id), ("enabledTools", enabled_tools)): + if isinstance(value, _Unset): + continue + if value is None: + remove_parts.append(attr) + else: + set_parts.append(f"{attr} = :{attr}") + values[f":{attr}"] = value + + update_expression = "SET " + ", ".join(set_parts) + if remove_parts: + update_expression += " REMOVE " + ", ".join(remove_parts) + + update_kwargs = { + "Key": {"PK": f"USER#{user_id}", "SK": f"SCHEDPROMPT#{schedule_id}"}, + "UpdateExpression": update_expression, + "ExpressionAttributeValues": values, + } + if names: + update_kwargs["ExpressionAttributeNames"] = names + _get_table().update_item(**update_kwargs) + + return await get_scheduled_prompt(user_id, schedule_id) + + +async def delete_scheduled_prompt(user_id: str, schedule_id: str) -> bool: + """Delete = total revocation. No orphan timers: the row's absence is + the only signal the (future) dispatcher needs β€” there is nothing else + to clean up.""" + _get_table().delete_item(Key={"PK": f"USER#{user_id}", "SK": f"SCHEDPROMPT#{schedule_id}"}) + logger.info(f"Deleted scheduled prompt {schedule_id} for user {user_id}") + return True diff --git a/backend/src/apis/shared/sessions/metadata.py b/backend/src/apis/shared/sessions/metadata.py index 0a00d807..faf24091 100644 --- a/backend/src/apis/shared/sessions/metadata.py +++ b/backend/src/apis/shared/sessions/metadata.py @@ -18,8 +18,12 @@ # Relative imports from shared sessions module from .models import ExportReceipt, MessageMetadata, PausedTurnSnapshot, PendingInterrupt, SessionMetadata, SessionPreferences -# Import preview session helper -from agents.main_agent.session.preview_session_manager import is_preview_session +# Preview-session helper β€” a dependency-free leaf in apis.shared so this +# module stays importable in the lean scheduled-runs Lambda image, which +# omits the agents/strands packages the old agents-side helper pulled in. +# The headless delivery path (ensure_session_metadata_exists) calls +# is_preview_session, so a lazy import would only defer the crash. +from .preview import is_preview_session logger = logging.getLogger(__name__) @@ -847,6 +851,72 @@ async def update_session_title(session_id: str, user_id: str, title: str) -> Non logger.error(f"update_session_title failed: {e}", exc_info=True) +async def set_session_unread(session_id: str, user_id: str, unread: bool) -> None: + """Set (or clear) the durable ``unread`` flag on the session row. + + Targeted ``UpdateExpression`` on the current SK β€” mirrors + ``update_session_title`` so it can run concurrently with the full-row + merge without clobbering ``messageCount`` / ``lastMessageAt`` / ``title``. + ``unread`` is NOT part of the SK, so no SK rotation is needed. Looks up the + current SK via the GSI because the SK contains a timestamp. + + Set ``True`` from the unattended-run delivery path (a scheduled run the user + didn't witness); cleared via ``mark_session_read`` when they open it. + + No-op when the session row doesn't exist (preview sessions, sessions + deleted mid-turn) β€” best-effort, never raises. + """ + if is_preview_session(session_id): + return + + sessions_metadata_table = os.environ.get("DYNAMODB_SESSIONS_METADATA_TABLE_NAME") + if not sessions_metadata_table: + raise RuntimeError("DYNAMODB_SESSIONS_METADATA_TABLE_NAME environment variable is required") + + try: + import boto3 + + dynamodb = boto3.resource("dynamodb") + table = dynamodb.Table(sessions_metadata_table) + + existing = await _get_session_by_gsi(session_id, user_id, table) + if not existing: + logger.info(f"set_session_unread: session {session_id} not found, skipping") + return + sk = existing.get("SK") + if not sk: + logger.warning(f"set_session_unread: session {session_id} has no SK") + return + + table.update_item( + Key={"PK": f"USER#{user_id}", "SK": sk}, + UpdateExpression="SET unread = :u", + ExpressionAttributeValues={":u": unread}, + ) + logger.info(f"πŸ’Ύ Set unread={unread} for session {session_id}") + except Exception as e: + logger.error(f"set_session_unread failed: {e}", exc_info=True) + + +async def mark_session_read(session_id: str, user_id: str) -> None: + """Clear the ``unread`` flag on a session (user opened it). + + Thin wrapper over ``set_session_unread(..., False)`` β€” the read verb the + app-api ``POST /sessions/{id}/read`` endpoint calls. + """ + await set_session_unread(session_id, user_id, False) + + +async def mark_session_unread(session_id: str, user_id: str) -> None: + """Set the ``unread`` flag on a session (user marked it unread manually). + + Thin wrapper over ``set_session_unread(..., True)`` β€” the unread verb the + app-api ``POST /sessions/{id}/unread`` endpoint calls. Ownership is enforced + inside ``set_session_unread`` via the per-user GSI lookup. + """ + await set_session_unread(session_id, user_id, True) + + async def update_session_activity( session_id: str, user_id: str, @@ -2202,3 +2272,147 @@ async def clear_truncated_turn(session_id: str, user_id: str) -> None: logger.info("Cleared truncated_turn for session %s", session_id) except Exception as e: logger.error("Failed to clear truncated_turn: %s", e, exc_info=True) + + +async def set_interrupted_turn( + session_id: str, + user_id: str, + reason: str = "unknown", + source: str = "cancellation", +) -> None: + """Mark that the last turn was interrupted before completion. + + Interruptions come from two racing sources that write the same session + record: the client stop signal (app-api ``POST /sessions/{id}/interrupt``, + ``reason="user_stopped"``) and the stream cancellation backstop + (inference-api, ``reason="connection_lost"`` fallback). ``user_stopped`` + is the stronger signal, so a ``user_stopped`` write is unconditional + while a fallback write is guarded by a condition so it can never + downgrade an already-recorded ``user_stopped`` β€” whichever source lands + first, the final reason is correct. Idempotent. Best-effort: a write + failure logs but never breaks the live flow. No-op when the session + record is missing or the table env var is unset. + """ + sessions_metadata_table = os.environ.get("DYNAMODB_SESSIONS_METADATA_TABLE_NAME") + if not sessions_metadata_table: + logger.warning("DYNAMODB_SESSIONS_METADATA_TABLE_NAME not set; skipping interrupted_turn persistence") + return + + if reason not in ("user_stopped", "connection_lost", "unknown"): + reason = "unknown" + + try: + import boto3 + from datetime import datetime, timezone + from botocore.exceptions import ClientError + + dynamodb = boto3.resource("dynamodb") + table = dynamodb.Table(sessions_metadata_table) + + existing = await _get_session_by_gsi(session_id, user_id, table) + if not existing: + logger.info("Skipping interrupted_turn write β€” session %s not found", session_id) + return + + sk = existing.get("SK") + if not sk: + logger.warning("Session %s has no SK; cannot update interrupted_turn", session_id) + return + + now_iso = datetime.now(timezone.utc).isoformat() + update_kwargs: dict = { + "Key": {"PK": f"USER#{user_id}", "SK": sk}, + "UpdateExpression": "SET #lti = :lti, #ltr = :ltr, #ltia = :ltia", + "ExpressionAttributeNames": { + "#lti": "lastTurnInterrupted", + "#ltr": "lastTurnInterruptReason", + "#ltia": "lastTurnInterruptedAt", + }, + "ExpressionAttributeValues": { + ":lti": True, + ":ltr": reason, + ":ltia": now_iso, + }, + } + + # A fallback (non-user_stopped) write must not clobber a stronger + # user_stopped reason the beacon may have already landed. The + # condition is evaluated against the pre-update item, so this is + # race-safe regardless of which source writes first. + if reason != "user_stopped": + update_kwargs["ConditionExpression"] = "attribute_not_exists(#ltr) OR #ltr <> :user_stopped" + update_kwargs["ExpressionAttributeValues"][":user_stopped"] = "user_stopped" + + try: + table.update_item(**update_kwargs) + logger.info( + "Persisted interrupted_turn marker for session %s (reason=%s, source=%s)", + session_id, reason, source, + ) + except ClientError as ce: + if ce.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + logger.info( + "Skipping interrupted_turn downgrade for session %s β€” user_stopped already recorded", + session_id, + ) + else: + raise + except Exception as e: + logger.error("Failed to persist interrupted_turn: %s", e, exc_info=True) + + +async def clear_interrupted_turn(session_id: str, user_id: str) -> Optional[str]: + """Pop the interrupted-turn marker, returning the reason it recorded. + + Called at the start of any new turn that isn't an interrupt-resume, so a + stale marker can't resurrect the "response interrupted" state against a + turn the user has already moved past. The return value lets the same + single read+write also drive the next-turn model note (see the + interruption-note prepend in inference-api's invocations route) β€” + ``None`` when no marker was set. + + The REMOVE uses ``ReturnValues=UPDATED_OLD`` so the pop is atomic at the + write: a marker updated between the GSI lookup and the update is still + captured and cleared. Best-effort like its siblings β€” a failure logs and + returns ``None``. + """ + sessions_metadata_table = os.environ.get("DYNAMODB_SESSIONS_METADATA_TABLE_NAME") + if not sessions_metadata_table: + return None + + try: + import boto3 + + dynamodb = boto3.resource("dynamodb") + table = dynamodb.Table(sessions_metadata_table) + + existing = await _get_session_by_gsi(session_id, user_id, table) + if not existing: + return None + + sk = existing.get("SK") + if not sk: + return None + + if "lastTurnInterrupted" not in existing: + return None # Already clear + + response = table.update_item( + Key={"PK": f"USER#{user_id}", "SK": sk}, + UpdateExpression="REMOVE #lti, #ltr, #ltia", + ExpressionAttributeNames={ + "#lti": "lastTurnInterrupted", + "#ltr": "lastTurnInterruptReason", + "#ltia": "lastTurnInterruptedAt", + }, + ReturnValues="UPDATED_OLD", + ) + cleared_reason = (response.get("Attributes") or {}).get("lastTurnInterruptReason") + logger.info( + "Cleared interrupted_turn for session %s (reason=%s)", + session_id, cleared_reason, + ) + return cleared_reason if isinstance(cleared_reason, str) else None + except Exception as e: + logger.error("Failed to clear interrupted_turn: %s", e, exc_info=True) + return None diff --git a/backend/src/apis/shared/sessions/models.py b/backend/src/apis/shared/sessions/models.py index 738e0384..0dad8339 100644 --- a/backend/src/apis/shared/sessions/models.py +++ b/backend/src/apis/shared/sessions/models.py @@ -229,6 +229,21 @@ class SessionMetadata(BaseModel): alias="lastTurnContinuable", description="True when the last turn ended in a recoverable max_tokens truncation; lets the 'Continue' affordance survive a page refresh. Cleared at the start of any new (non-interrupt-resume) turn", ) + last_turn_interrupted: Optional[bool] = Field( + default=None, + alias="lastTurnInterrupted", + description="True when the last turn was interrupted before completion (user Stop, refresh, or dropped connection). Lets a reload show the 'response interrupted' state. Cleared at the start of any new (non-interrupt-resume) turn", + ) + last_turn_interrupt_reason: Optional[Literal["user_stopped", "connection_lost", "unknown"]] = Field( + default=None, + alias="lastTurnInterruptReason", + description="Why the last turn was interrupted. 'user_stopped' (deliberate Stop, from the client beacon) wins over the 'connection_lost' cancellation fallback", + ) + last_turn_interrupted_at: Optional[str] = Field( + default=None, + alias="lastTurnInterruptedAt", + description="ISO 8601 timestamp when the interruption was detected", + ) # Denormalized cost + context aggregates for the session-cost badge. # Maintained by _bump_session_aggregates after each turn (write-time @@ -268,6 +283,16 @@ class SessionMetadata(BaseModel): description="Receipts for conversation exports to connected apps, newest appended last", ) + # Durable "unread" flag: set when an UNATTENDED run (scheduled / headless) + # completes in this session, since the user by definition wasn't watching. + # Interactive turns never set it (the client tracks same-tab unread state). + # Cleared server-side when the user opens the session (mark_session_read), + # so the dot survives reload and reaches other devices. Not part of the SK. + unread: Optional[bool] = Field( + False, + description="True when an unattended (scheduled) run left a response the user hasn't opened yet", + ) + class UpdateSessionMetadataRequest(BaseModel): """Request body for updating session metadata""" @@ -286,6 +311,20 @@ class UpdateSessionMetadataRequest(BaseModel): agent_type: Optional[Literal["skill", "chat"]] = Field(None, alias="agentType", description="Agent mode for this conversation") +class SessionInterruptRequest(BaseModel): + """Request body for the client stop signal (POST /sessions/{id}/interrupt). + + Only `user_stopped` is accepted from the client β€” it is the one reason + that requires user attestation. `connection_lost` is never client-sent; + it is inferred server-side by the stream-cancellation backstop and would + otherwise let a client downgrade a deliberate stop. + """ + + reason: Literal["user_stopped"] = Field( + description="Interruption reason. Only the deliberate Stop is client-attested", + ) + + class SessionMetadataResponse(BaseModel): """Response containing session metadata""" @@ -326,11 +365,30 @@ class SessionMetadataResponse(BaseModel): alias="lastTurnContinuable", description="True when the last turn ended in a recoverable max_tokens truncation, so the client can re-show the 'Continue' affordance after a refresh", ) + last_turn_interrupted: Optional[bool] = Field( + default=None, + alias="lastTurnInterrupted", + description="True when the last turn was interrupted before completion (user Stop, refresh, or dropped connection), so the client can show the 'response interrupted' state after a reload", + ) + last_turn_interrupt_reason: Optional[str] = Field( + default=None, + alias="lastTurnInterruptReason", + description="Why the last turn was interrupted: 'user_stopped' (deliberate Stop) or 'connection_lost' (refresh / dropped connection). Drives whether a 'Continue' affordance is offered on reload", + ) + last_turn_interrupted_at: Optional[str] = Field( + default=None, + alias="lastTurnInterruptedAt", + description="ISO 8601 timestamp when the interruption was detected", + ) export_receipts: Optional[List[ExportReceipt]] = Field( default=None, alias="exportReceipts", description="Receipts for conversation exports to connected apps, newest appended last; lets the UI restore a 'Saved Β· Open' affordance after a reload", ) + unread: Optional[bool] = Field( + False, + description="True when an unattended (scheduled) run left a response the user hasn't opened yet; drives the blue unread dot in the session list", + ) class SessionsListResponse(BaseModel): diff --git a/backend/src/apis/shared/sessions/preview.py b/backend/src/apis/shared/sessions/preview.py new file mode 100644 index 00000000..5727230a --- /dev/null +++ b/backend/src/apis/shared/sessions/preview.py @@ -0,0 +1,29 @@ +"""Preview-session detection β€” a dependency-free leaf helper. + +Preview sessions are the in-memory, never-persisted sessions the assistant +form-builder uses for testing (they keep conversation context for the turn +but are deliberately excluded from a user's saved history). Detecting one is +a pure prefix check. + +This lives in ``apis.shared`` β€” with **no imports** β€” on purpose: the +canonical implementation used to live in +``agents.main_agent.session.preview_session_manager``, whose module also +pulls in ``strands`` and ``agents.main_agent.config`` for the unrelated +``PreviewSessionManager`` class. That made ``apis.shared.sessions.metadata`` +(which only needs the prefix check) transitively depend on the whole agent +stack, and the lean scheduled-runs Lambda image β€” which deliberately omits +``agents``/``strands`` β€” crashed at delivery time trying to import it. + +The ``"preview-"`` literal is mirrored in +``agents.main_agent.config.constants.Prefixes.PREVIEW_SESSION`` and +``apis.inference_api.chat.routes``; ``tests`` assert they stay in lockstep +(``test_preview_prefix_consistency``). +""" + +#: Session-id prefix marking an in-memory preview session (no persistence). +PREVIEW_SESSION_PREFIX = "preview-" + + +def is_preview_session(session_id: str) -> bool: + """True for an in-memory preview session (excluded from persistence).""" + return session_id.startswith(PREVIEW_SESSION_PREFIX) diff --git a/backend/src/apis/shared/sync_policies/__init__.py b/backend/src/apis/shared/sync_policies/__init__.py new file mode 100644 index 00000000..db0684a2 --- /dev/null +++ b/backend/src/apis/shared/sync_policies/__init__.py @@ -0,0 +1,49 @@ +"""Sync policies β€” scheduled re-index of assistant knowledge-base sources.""" + +from .models import ( + DUE_INDEX_PK, + SyncInterval, + SyncPolicy, + SyncPolicyState, + SyncRunResult, + SyncSourceType, +) +from .service import ( + DuplicateSyncPolicy, + SyncPolicyLimitExceeded, + compute_next_sync_at, + create_sync_policy, + delete_sync_policies_for_assistant, + delete_sync_policies_for_source, + delete_sync_policy, + get_sync_policy, + list_due_policies, + list_sync_policies, + max_policies_per_assistant, + rearm_policy, + record_sync_result, + set_policy_state, +) + +__all__ = [ + "DUE_INDEX_PK", + "DuplicateSyncPolicy", + "SyncInterval", + "SyncPolicy", + "SyncPolicyLimitExceeded", + "SyncPolicyState", + "SyncRunResult", + "SyncSourceType", + "compute_next_sync_at", + "create_sync_policy", + "delete_sync_policies_for_assistant", + "delete_sync_policies_for_source", + "delete_sync_policy", + "get_sync_policy", + "list_due_policies", + "list_sync_policies", + "max_policies_per_assistant", + "rearm_policy", + "record_sync_result", + "set_policy_state", +] diff --git a/backend/src/apis/shared/sync_policies/models.py b/backend/src/apis/shared/sync_policies/models.py new file mode 100644 index 00000000..6d001fa3 --- /dev/null +++ b/backend/src/apis/shared/sync_policies/models.py @@ -0,0 +1,59 @@ +"""Sync policy models β€” scheduled re-index of assistant knowledge-base sources. + +A SyncPolicy is the single source of truth for "this content source resyncs". +It is inert data: nothing fires unless the dispatcher reads it from the +DueSyncIndex, so deleting the record is total revocation of the schedule. + +Stored in the assistants table using the adjacency list pattern: + PK: AST#{assistant_id} + SK: SYNCPOL#{policy_id} + +GSI4 (DueSyncIndex) keys are present ONLY while state == "active" (sparse +index) β€” a paused policy is physically invisible to the dispatcher, not +filtered at query time. +""" + +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +SyncSourceType = Literal["web_crawl", "drive_file"] +SyncInterval = Literal["daily", "weekly", "monthly"] +SyncPolicyState = Literal["active", "paused_error", "paused_inactive", "paused_reauth", "paused_user"] +SyncRunResult = Literal["changed", "unchanged", "failed", "skipped"] + +# Sentinel partition key for the sparse due index. Single logical partition β€” +# fine at our scale; shard to SYNCDUE#{0..N} if writes ever demand it. +DUE_INDEX_PK = "SYNCDUE" + + +class SyncPolicy(BaseModel): + """Complete sync policy model (internal use)""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + policy_id: str = Field(..., alias="policyId", description="Sync policy identifier (syn-{12-hex})") + assistant_id: str = Field(..., alias="assistantId", description="Parent assistant identifier") + source_type: SyncSourceType = Field(..., alias="sourceType", description="Kind of content source this policy re-syncs") + source_ref: str = Field( + ..., + alias="sourceRef", + description="web_crawl: crawl_id of the CrawlJob to re-run; drive_file: document_id holding the import provenance", + ) + interval: SyncInterval = Field(..., description="Re-sync cadence (bounded enum β€” no cron)") + state: SyncPolicyState = Field("active", description="Lifecycle state; only 'active' policies appear in DueSyncIndex") + state_reason: Optional[str] = Field(None, alias="stateReason", description="Human-readable reason for a paused state") + next_sync_at: Optional[str] = Field(None, alias="nextSyncAt", description="ISO 8601 next due time; drives DueSyncIndex sort key") + last_sync_at: Optional[str] = Field(None, alias="lastSyncAt", description="ISO 8601 timestamp of the last completed run") + last_result: Optional[SyncRunResult] = Field(None, alias="lastResult", description="Outcome of the last completed run") + consecutive_failures: int = Field(0, alias="consecutiveFailures", description="Transient-failure streak (circuit breaker input)") + consecutive_not_found: int = Field(0, alias="consecutiveNotFound", description="Source-gone streak (404 fast path input)") + sync_run_started_at: Optional[str] = Field( + None, alias="syncRunStartedAt", description="Set while a worker run is in flight; stale stamps are treated as crashed runs" + ) + last_manual_run_at: Optional[str] = Field( + None, alias="lastManualRunAt", description="Last 'Sync now' trigger; enforces the manual-run cooldown" + ) + created_by_user_id: str = Field(..., alias="createdByUserId", description="User whose credentials background fetches use") + created_at: str = Field(..., alias="createdAt", description="ISO 8601 timestamp of creation") + updated_at: str = Field(..., alias="updatedAt", description="ISO 8601 timestamp of last update") diff --git a/backend/src/apis/shared/sync_policies/service.py b/backend/src/apis/shared/sync_policies/service.py new file mode 100644 index 00000000..0915e0f6 --- /dev/null +++ b/backend/src/apis/shared/sync_policies/service.py @@ -0,0 +1,480 @@ +"""Sync policy repository β€” DynamoDB storage for scheduled KB re-sync policies. + +Lives in apis.shared because it has three independent consumers: app-api +(CRUD routes), the sync dispatcher Lambda (due sweep), and the sync worker +Lambda (run bookkeeping). + +Storage (assistants table, adjacency list): + PK: AST#{assistant_id} | SK: SYNCPOL#{policy_id} + GSI4 (DueSyncIndex, sparse): GSI4_PK = "SYNCDUE", GSI4_SK = "{next_sync_at}#{policy_id}" + GSI4 keys exist only while state == "active". +""" + +import logging +import os +import uuid +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from .models import DUE_INDEX_PK, SyncInterval, SyncPolicy, SyncPolicyState, SyncRunResult, SyncSourceType + +logger = logging.getLogger(__name__) + +INTERVAL_DELTAS = { + "daily": timedelta(days=1), + "weekly": timedelta(days=7), + "monthly": timedelta(days=30), +} + +DEFAULT_MAX_POLICIES_PER_ASSISTANT = 10 + + +class SyncPolicyLimitExceeded(Exception): + """Raised when an assistant already has the maximum number of sync policies.""" + + +class DuplicateSyncPolicy(Exception): + """Raised when the source already has a sync policy on this assistant.""" + + +def _iso(dt: datetime) -> str: + """Serialize a UTC datetime as strict ISO 8601 with a ``Z`` suffix. + + ``datetime.isoformat()`` renders the offset as ``+00:00``; we normalize that + to ``Z`` so the result is valid ISO 8601 that JavaScript's ``Date`` parses. + A previous ``isoformat() + "Z"`` produced ``…+00:00Z`` β€” both an offset and a + Z β€” which is invalid and yields ``Invalid Date`` in strict engines (Safari), + leaving the sync status blank in the UI. + """ + return dt.isoformat().replace("+00:00", "Z") + + +def _get_current_timestamp() -> str: + """Get current timestamp in ISO 8601 format""" + return _iso(datetime.now(timezone.utc)) + + +def _generate_policy_id() -> str: + return f"syn-{uuid.uuid4().hex[:12]}" + + +def _table_name() -> str: + table = os.environ.get("DYNAMODB_ASSISTANTS_TABLE_NAME") + if not table: + raise RuntimeError("DYNAMODB_ASSISTANTS_TABLE_NAME environment variable is required") + return table + + +def _get_table(): + import boto3 + + return boto3.resource("dynamodb").Table(_table_name()) + + +def _due_sort_key(next_sync_at: str, policy_id: str) -> str: + return f"{next_sync_at}#{policy_id}" + + +def max_policies_per_assistant() -> int: + return int(os.environ.get("KB_SYNC_MAX_POLICIES_PER_ASSISTANT", DEFAULT_MAX_POLICIES_PER_ASSISTANT)) + + +def compute_next_sync_at(interval: SyncInterval, from_time: Optional[datetime] = None) -> str: + """Compute the next due timestamp for an interval, ISO 8601.""" + base = from_time or datetime.now(timezone.utc) + return _iso(base + INTERVAL_DELTAS[interval]) + + +async def create_sync_policy( + assistant_id: str, + source_type: SyncSourceType, + source_ref: str, + interval: SyncInterval, + created_by_user_id: str, +) -> SyncPolicy: + """Create an active sync policy due one interval from now. + + Enforces the per-assistant policy cap and one-policy-per-source + uniqueness (both checked against the current policy list β€” bounded by + the cap, so the scan is cheap). + """ + existing = await list_sync_policies(assistant_id) + if len(existing) >= max_policies_per_assistant(): + raise SyncPolicyLimitExceeded( + f"Assistant {assistant_id} already has {len(existing)} sync policies (max {max_policies_per_assistant()})" + ) + if any(p.source_ref == source_ref for p in existing): + raise DuplicateSyncPolicy(f"Source {source_ref} already has a sync policy on assistant {assistant_id}") + + now = _get_current_timestamp() + policy = SyncPolicy( + policy_id=_generate_policy_id(), + assistant_id=assistant_id, + source_type=source_type, + source_ref=source_ref, + interval=interval, + state="active", + next_sync_at=compute_next_sync_at(interval), + created_by_user_id=created_by_user_id, + created_at=now, + updated_at=now, + ) + + item = policy.model_dump(by_alias=True, exclude_none=True) + item["PK"] = f"AST#{assistant_id}" + item["SK"] = f"SYNCPOL#{policy.policy_id}" + item["GSI4_PK"] = DUE_INDEX_PK + item["GSI4_SK"] = _due_sort_key(policy.next_sync_at, policy.policy_id) + + _get_table().put_item(Item=item) + logger.info(f"Created sync policy {policy.policy_id} ({source_type}/{interval}) for assistant {assistant_id}") + return policy + + +async def get_sync_policy(assistant_id: str, policy_id: str) -> Optional[SyncPolicy]: + response = _get_table().get_item(Key={"PK": f"AST#{assistant_id}", "SK": f"SYNCPOL#{policy_id}"}) + item = response.get("Item") + return SyncPolicy.model_validate(item) if item else None + + +async def list_sync_policies(assistant_id: str) -> List[SyncPolicy]: + from boto3.dynamodb.conditions import Key + + response = _get_table().query( + KeyConditionExpression=Key("PK").eq(f"AST#{assistant_id}") & Key("SK").begins_with("SYNCPOL#") + ) + policies = [] + for item in response.get("Items", []): + try: + policies.append(SyncPolicy.model_validate(item)) + except Exception as e: + logger.warning(f"Failed to parse sync policy item: {e}") + return policies + + +async def list_due_policies(now: Optional[str] = None, limit: int = 20) -> List[SyncPolicy]: + """Query the sparse DueSyncIndex for active policies whose next_sync_at has passed. + + Returns policies most-overdue first. Paused policies have no GSI4 keys + and are physically absent from this index. + """ + from boto3.dynamodb.conditions import Key + + now = now or _get_current_timestamp() + response = _get_table().query( + IndexName="DueSyncIndex", + # '~' sorts after '#' and all timestamp characters, so this covers + # every "{ts}#{policy_id}" key with ts <= now. + KeyConditionExpression=Key("GSI4_PK").eq(DUE_INDEX_PK) & Key("GSI4_SK").lte(f"{now}~"), + Limit=limit, + ScanIndexForward=True, + ) + policies = [] + for item in response.get("Items", []): + try: + policies.append(SyncPolicy.model_validate(item)) + except Exception as e: + logger.warning(f"Failed to parse due sync policy item: {e}") + return policies + + +async def set_policy_state( + assistant_id: str, + policy_id: str, + state: SyncPolicyState, + next_sync_at: Optional[str] = None, + state_reason: Optional[str] = None, +) -> bool: + """Transition a policy's lifecycle state. + + Transition to "active" requires next_sync_at and (re)adds the GSI4 keys; + any paused state REMOVEs them so the policy drops out of DueSyncIndex. + Returns False if the policy does not exist. + """ + from botocore.exceptions import ClientError + + if state == "active" and not next_sync_at: + raise ValueError("next_sync_at is required when activating a sync policy") + + names = {"#state": "state"} + values = {":state": state, ":updated_at": _get_current_timestamp()} + set_parts = ["#state = :state", "updatedAt = :updated_at"] + remove_parts = [] + + if state == "active": + set_parts += ["nextSyncAt = :next", "GSI4_PK = :gsi4pk", "GSI4_SK = :gsi4sk"] + values[":next"] = next_sync_at + values[":gsi4pk"] = DUE_INDEX_PK + values[":gsi4sk"] = _due_sort_key(next_sync_at, policy_id) + remove_parts.append("stateReason") + else: + remove_parts += ["GSI4_PK", "GSI4_SK"] + if state_reason: + set_parts.append("stateReason = :reason") + values[":reason"] = state_reason + + expression = "SET " + ", ".join(set_parts) + if remove_parts: + expression += " REMOVE " + ", ".join(remove_parts) + + try: + _get_table().update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"SYNCPOL#{policy_id}"}, + UpdateExpression=expression, + ExpressionAttributeNames=names, + ExpressionAttributeValues=values, + ConditionExpression="attribute_exists(PK)", + ) + logger.info(f"Sync policy {policy_id} -> {state}" + (f" ({state_reason})" if state_reason else "")) + return True + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + logger.warning(f"Cannot set state on missing sync policy {policy_id}") + return False + raise + + +async def rearm_policy( + assistant_id: str, + policy_id: str, + expected_next_sync_at: str, + new_next_sync_at: str, + mark_run_started: bool = False, +) -> bool: + """Advance next_sync_at, conditional on the currently stored value. + + The dispatcher re-arms BEFORE invoking the worker; the condition makes a + double-fired tick idempotent β€” the second dispatcher loses the + conditional write and skips the policy. Returns True if this caller won. + + mark_run_started stamps syncRunStartedAt in the same write, so winning + the re-arm and claiming the in-flight slot are atomic. + """ + from botocore.exceptions import ClientError + + update_expression = "SET nextSyncAt = :new, GSI4_SK = :gsi4sk, updatedAt = :updated_at" + values = { + ":new": new_next_sync_at, + ":gsi4sk": _due_sort_key(new_next_sync_at, policy_id), + ":updated_at": _get_current_timestamp(), + ":expected": expected_next_sync_at, + } + if mark_run_started: + update_expression += ", syncRunStartedAt = :run_started" + values[":run_started"] = values[":updated_at"] + + try: + _get_table().update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"SYNCPOL#{policy_id}"}, + UpdateExpression=update_expression, + ExpressionAttributeValues=values, + ConditionExpression="nextSyncAt = :expected", + ) + return True + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + logger.info(f"Sync policy {policy_id} already re-armed by another dispatcher; skipping") + return False + raise + + +async def record_sync_result(assistant_id: str, policy_id: str, result: SyncRunResult, not_found: bool = False) -> None: + """Record a completed run's outcome and maintain the breaker counters. + + Success-ish results (changed/unchanged/skipped) reset both streaks; + "failed" increments consecutiveFailures, plus consecutiveNotFound when + the failure was a definitive source-gone (404). Clears the in-flight + run stamp either way. + """ + now = _get_current_timestamp() + values = {":now": now, ":result": result} + set_parts = ["lastSyncAt = :now", "lastResult = :result", "updatedAt = :now"] + + if result == "failed": + values[":one"] = 1 + values[":zero"] = 0 + set_parts.append("consecutiveFailures = if_not_exists(consecutiveFailures, :zero) + :one") + if not_found: + set_parts.append("consecutiveNotFound = if_not_exists(consecutiveNotFound, :zero) + :one") + else: + set_parts.append("consecutiveNotFound = :zero") + else: + values[":zero"] = 0 + set_parts += ["consecutiveFailures = :zero", "consecutiveNotFound = :zero"] + + _get_table().update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"SYNCPOL#{policy_id}"}, + UpdateExpression="SET " + ", ".join(set_parts) + " REMOVE syncRunStartedAt", + ExpressionAttributeValues=values, + ) + + +# ── Reauth markers ─────────────────────────────────────────────────────────── +# +# When the worker pauses a policy for re-consent it also writes a marker at +# PK=USER#{user_id}, SK=SYNCREAUTH#{policy_id} carrying {assistantId, +# providerId}. Consent completion queries this partition instead of scanning +# the table for the user's paused policies. Markers are advisory: resume +# re-verifies the policy still exists and is paused_reauth, so stale markers +# are harmless and are deleted on sight. + + +async def put_reauth_marker(user_id: str, assistant_id: str, policy_id: str, provider_id: str) -> None: + _get_table().put_item( + Item={ + "PK": f"USER#{user_id}", + "SK": f"SYNCREAUTH#{policy_id}", + "assistantId": assistant_id, + "policyId": policy_id, + "providerId": provider_id, + "createdAt": _get_current_timestamp(), + } + ) + + +async def delete_reauth_marker(user_id: str, policy_id: str) -> None: + _get_table().delete_item(Key={"PK": f"USER#{user_id}", "SK": f"SYNCREAUTH#{policy_id}"}) + + +async def resume_reauth_policies(user_id: str, provider_id: str) -> int: + """Reactivate the user's paused_reauth policies for a provider. + + Called from the OAuth consent-completion path: a fresh grant is the ONLY + thing that resumes a reauth pause. Resumed policies come due immediately. + Returns the number resumed. + """ + from boto3.dynamodb.conditions import Key + + response = _get_table().query( + KeyConditionExpression=Key("PK").eq(f"USER#{user_id}") & Key("SK").begins_with("SYNCREAUTH#") + ) + resumed = 0 + now = _get_current_timestamp() + for marker in response.get("Items", []): + if marker.get("providerId") != provider_id: + continue + assistant_id = marker["assistantId"] + policy_id = marker["policyId"] + policy = await get_sync_policy(assistant_id, policy_id) + if policy is not None and policy.state == "paused_reauth": + await set_policy_state(assistant_id, policy_id, "active", next_sync_at=now) + resumed += 1 + await delete_reauth_marker(user_id, policy_id) + if resumed: + logger.info(f"Resumed {resumed} paused_reauth sync policies for user {user_id} / provider {provider_id}") + return resumed + + +async def resume_inactive_policies(assistant_id: str) -> int: + """Reactivate an assistant's paused_inactive policies (due immediately). + + Called from the lastUsedAt bump path: the first use of a dormant + assistant refreshes its knowledge base within a dispatcher tick. + """ + resumed = 0 + now = _get_current_timestamp() + for policy in await list_sync_policies(assistant_id): + if policy.state == "paused_inactive": + await set_policy_state(assistant_id, policy.policy_id, "active", next_sync_at=now) + resumed += 1 + return resumed + + +async def change_policy_interval(assistant_id: str, policy_id: str, interval: SyncInterval) -> Optional[SyncPolicy]: + """Change a policy's cadence. Active policies get re-armed one new + interval out (and the due-index key moves with it); paused policies + just remember the new interval for when they resume.""" + policy = await get_sync_policy(assistant_id, policy_id) + if policy is None: + return None + + values = {":interval": interval, ":updated_at": _get_current_timestamp()} + set_parts = ["#interval = :interval", "updatedAt = :updated_at"] + if policy.state == "active": + next_sync_at = compute_next_sync_at(interval) + set_parts += ["nextSyncAt = :next", "GSI4_SK = :gsi4sk"] + values[":next"] = next_sync_at + values[":gsi4sk"] = _due_sort_key(next_sync_at, policy_id) + + _get_table().update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"SYNCPOL#{policy_id}"}, + UpdateExpression="SET " + ", ".join(set_parts), + ExpressionAttributeNames={"#interval": "interval"}, + ExpressionAttributeValues=values, + ) + return await get_sync_policy(assistant_id, policy_id) + + +RUN_NOW_COOLDOWN_SECONDS = 600 + + +class RunNowCooldown(Exception): + """Raised when a manual sync is requested within the cooldown window.""" + + +async def trigger_run_now(assistant_id: str, policy_id: str) -> SyncPolicy: + """Make a policy due immediately (manual "Sync now"). + + Flows through the normal dispatcher path so every guard still applies. + Only active policies can be manually run β€” paused states have their own + explicit resume affordances. A conditional write on lastManualRunAt + enforces the 10-minute cooldown atomically. + """ + from botocore.exceptions import ClientError + + policy = await get_sync_policy(assistant_id, policy_id) + if policy is None: + raise KeyError(policy_id) + if policy.state != "active": + raise ValueError(f"Cannot run-now a policy in state {policy.state}") + + now_dt = datetime.now(timezone.utc) + now = _iso(now_dt) + cooldown_floor = _iso(now_dt - timedelta(seconds=RUN_NOW_COOLDOWN_SECONDS)) + try: + _get_table().update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"SYNCPOL#{policy_id}"}, + UpdateExpression="SET nextSyncAt = :now, GSI4_SK = :gsi4sk, lastManualRunAt = :now, updatedAt = :now", + ExpressionAttributeValues={ + ":now": now, + ":gsi4sk": _due_sort_key(now, policy_id), + ":floor": cooldown_floor, + }, + ConditionExpression="attribute_not_exists(lastManualRunAt) OR lastManualRunAt < :floor", + ) + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + raise RunNowCooldown(policy_id) from e + raise + return await get_sync_policy(assistant_id, policy_id) + + +async def delete_sync_policy(assistant_id: str, policy_id: str) -> bool: + _get_table().delete_item(Key={"PK": f"AST#{assistant_id}", "SK": f"SYNCPOL#{policy_id}"}) + logger.info(f"Deleted sync policy {policy_id} for assistant {assistant_id}") + return True + + +async def delete_sync_policies_for_source(assistant_id: str, source_ref: str) -> int: + """Delete all policies referencing a source (document or crawl job). + + Called from the source's delete path so a removed document/crawl never + leaves a live schedule behind. + """ + policies = await list_sync_policies(assistant_id) + deleted = 0 + for policy in policies: + if policy.source_ref == source_ref: + await delete_sync_policy(assistant_id, policy.policy_id) + deleted += 1 + return deleted + + +async def delete_sync_policies_for_assistant(assistant_id: str) -> int: + """Delete every sync policy under an assistant (assistant delete cascade).""" + policies = await list_sync_policies(assistant_id) + for policy in policies: + await delete_sync_policy(assistant_id, policy.policy_id) + if policies: + logger.info(f"Deleted {len(policies)} sync policies for assistant {assistant_id}") + return len(policies) diff --git a/backend/src/apis/shared/users/repository.py b/backend/src/apis/shared/users/repository.py index 1888715f..2f375f07 100644 --- a/backend/src/apis/shared/users/repository.py +++ b/backend/src/apis/shared/users/repository.py @@ -11,6 +11,18 @@ logger = logging.getLogger(__name__) +def _heal_iso(value: str) -> str: + """Repair legacy ``…+00:00Z`` timestamps persisted before the sync fix. + + Rows written by the old ``isoformat() + "Z"`` code carry both an offset and + a ``Z``, which is invalid ISO 8601 and parses to ``Invalid Date`` in strict + engines (Safari). ``last_login_at`` self-heals on next login, but + ``created_at`` is preserved forever β€” so normalize on read to a single + trailing ``Z`` (a no-op for already-valid values). + """ + return value.replace("+00:00Z", "Z") if value else value + + class UserRepository: """DynamoDB repository for user operations. @@ -289,7 +301,7 @@ def _profile_to_item(self, profile: UserProfile) -> dict: def _item_to_profile(self, item: dict) -> UserProfile: """Convert DynamoDB item to UserProfile.""" - created_at = item.get("createdAt", "") + created_at = _heal_iso(item.get("createdAt", "")) return UserProfile( user_id=item["userId"], email=item["email"], @@ -298,14 +310,14 @@ def _item_to_profile(self, item: dict) -> UserProfile: picture=item.get("picture"), email_domain=item.get("emailDomain", ""), created_at=created_at, - last_login_at=item.get("lastLoginAt", created_at), + last_login_at=_heal_iso(item.get("lastLoginAt", "")) or created_at, status=item.get("status", "active") ) def _item_to_list_item(self, item: dict) -> UserListItem: """Convert DynamoDB item to UserListItem.""" # GSI queries may not project lastLoginAt, but GSI2SK/GSI3SK contain the same value - last_login = ( + last_login = _heal_iso( item.get("lastLoginAt") or item.get("GSI3SK") # StatusLoginIndex sort key or item.get("GSI2SK") # EmailDomainIndex sort key diff --git a/backend/src/apis/shared/users/sync.py b/backend/src/apis/shared/users/sync.py index 025f1a3d..3b70bb0f 100644 --- a/backend/src/apis/shared/users/sync.py +++ b/backend/src/apis/shared/users/sync.py @@ -10,6 +10,18 @@ logger = logging.getLogger(__name__) +def _iso(dt: datetime) -> str: + """Serialize a UTC datetime as strict ISO 8601 with a ``Z`` suffix. + + ``datetime.isoformat()`` renders the offset as ``+00:00``; we normalize that + to ``Z`` so the result is valid ISO 8601 that JavaScript's ``Date`` parses. + A previous ``isoformat() + "Z"`` produced ``…+00:00Z`` β€” both an offset and a + Z β€” which is invalid and yields ``Invalid Date`` in strict engines (Safari), + leaving the admin user list / detail dates blank ("Never"). + """ + return dt.isoformat().replace("+00:00", "Z") + + class UserSyncService: """ Syncs user data from JWT claims to DynamoDB. @@ -62,7 +74,7 @@ async def sync_from_jwt(self, jwt_claims: dict) -> Tuple[Optional[UserProfile], if "@" in email: email_domain = email.split("@")[1] - now = datetime.now(timezone.utc).isoformat() + "Z" + now = _iso(datetime.now(timezone.utc)) # Build profile from JWT claims profile = UserProfile( diff --git a/backend/src/lambdas/scheduled_runs_dispatcher/dispatcher.py b/backend/src/lambdas/scheduled_runs_dispatcher/dispatcher.py new file mode 100644 index 00000000..b1e98b96 --- /dev/null +++ b/backend/src/lambdas/scheduled_runs_dispatcher/dispatcher.py @@ -0,0 +1,204 @@ +"""Scheduled-runs dispatcher β€” the single initiator of scheduled agent runs. + +Fired by one EventBridge rate rule (every 5 minutes). Sweeps the sparse +DueScheduleIndex (`apis.shared.scheduled_prompts.service.list_due_schedules`) +and, for each due schedule, applies the runaway guards from +docs/specs/scheduled-runs-phase-b-brief.md / scheduled-agent-runs.md Β§7 in +order, mirroring the KB-sync dispatcher +(`apis/app_api/kb_sync/dispatcher.py`) almost line for line: + +1. Kill switch β€” SCHEDULED_RUNS_ENABLED must be "true" or the tick + no-ops (the EventBridge rule itself is also gated + by `config.scheduledRuns.enabled` in CDK β€” belt and + braces, same as KB-sync). +2. Runaway guard β€” `runs_today` (reset on UTC date rollover) compared + against the schedule's own `max_runs_per_day`. A + schedule that would exceed its ceiling is paused + instead of dispatched. +3. Re-arm BEFORE work β€” `next_run_at` advances via a conditional write + (`rearm_schedule`) before the worker is invoked, so + a double-fired tick is idempotent: the second + dispatcher loses the conditional write and skips + the schedule. A crashed worker costs one missed + run, never a hot loop. + +Governance note (brief Β§1): delivery is role/auth-based β€” the dispatcher's +only job is "who's due, how many times today, don't double-fire". No +content classification happens here or in the worker. +""" + +import asyncio +import json +import logging +import os +from datetime import date, datetime, timedelta, timezone +from typing import Any, Dict + +from apis.shared.scheduled_prompts.service import ( + list_due_schedules, + rearm_schedule, + set_schedule_state, +) +from apis.shared.scheduled_prompts.models import ScheduledPrompt + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +METRIC_NAMESPACE = "ScheduledRuns" + +# Cadence -> a normalized re-arm delta. The dispatcher recomputes the exact +# next_run_at via the same cadence math the schedule was created with +# (compute_next_run_at); this constant is only a defensive fallback bound so +# an unexpected cadence value can never wedge a schedule into a tight loop. +_FALLBACK_REARM_DELTA = timedelta(hours=1) + + +def _env_int(name: str, default: int) -> int: + return int(os.environ.get(name, default)) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _today() -> str: + return date.today().isoformat() + + +def _invoke_worker(payload: Dict[str, Any]) -> None: + """Async-invoke the scheduled-runs worker Lambda (fire-and-forget).""" + import boto3 + + function_name = os.environ["SCHEDULED_RUNS_WORKER_FUNCTION_NAME"] + boto3.client("lambda").invoke( + FunctionName=function_name, + InvocationType="Event", + Payload=json.dumps(payload).encode("utf-8"), + ) + + +def _emit_metrics(counts: Dict[str, int]) -> None: + """Best-effort CloudWatch metrics; never fails the tick.""" + import boto3 + + try: + metric_data = [ + {"MetricName": name, "Value": value, "Unit": "Count"} for name, value in counts.items() + ] + boto3.client("cloudwatch").put_metric_data(Namespace=METRIC_NAMESPACE, MetricData=metric_data) + except Exception as e: + logger.warning(f"Failed to emit ScheduledRuns metrics: {e}") + + +def _next_run_at(schedule: ScheduledPrompt, now: datetime) -> str: + """Recompute the next due timestamp using the schedule's own cadence. + + Reuses the exact cadence math the schedule was created with + (`compute_next_run_at`) so a daily/weekday/weekly schedule advances + correctly β€” no cron strings, no engine-side interval table. + """ + from apis.shared.scheduled_prompts.service import compute_next_run_at, interval_to_minutes + + try: + return compute_next_run_at( + schedule.cadence, + schedule.hour_local, + schedule.timezone, + weekday=schedule.weekday, + from_time=now, + interval_minutes=interval_to_minutes(schedule.interval_value, schedule.interval_unit), + ) + except Exception as e: + # Defensive fallback β€” should never trigger for a valid schedule, + # but a wedged schedule is worse than a slightly-off re-arm. + logger.error(f"Schedule {schedule.schedule_id}: cadence recompute failed ({e}); using fallback delta") + return (now + _FALLBACK_REARM_DELTA).isoformat().replace("+00:00", "Z") + + +def _runs_today(schedule: ScheduledPrompt, today: str) -> int: + """Runaway-guard counter, reset across a UTC date rollover. + + Mirrors `record_run_result`'s own rollover logic: `runs_today` only + counts against `runs_today_date`; a stale date means today's count is + effectively zero (the counter has not been touched yet today). + """ + if schedule.runs_today_date == today: + return schedule.runs_today + return 0 + + +async def _dispatch_schedule(schedule: ScheduledPrompt, now: datetime, counts: Dict[str, int]) -> None: + today = _today() + + # Guard 2 β€” runaway: would firing this schedule exceed its own daily + # ceiling? Check BEFORE re-arming/invoking, using the rollover-aware + # counter (a run this tick would be the (runs_today + 1)th today). + runs_today = _runs_today(schedule, today) + if runs_today >= schedule.max_runs_per_day: + await set_schedule_state( + schedule.user_id, + schedule.schedule_id, + "paused_error", + state_reason="max_runs_per_day_exceeded", + ) + logger.warning( + f"Schedule {schedule.schedule_id}: runaway guard tripped " + f"({runs_today}/{schedule.max_runs_per_day} runs today); pausing" + ) + counts["PausedRunaway"] += 1 + return + + # Guard 3 β€” re-arm before work; losing the conditional write means + # another dispatcher tick already claimed this schedule. + new_next = _next_run_at(schedule, now) + won = await rearm_schedule( + schedule.user_id, schedule.schedule_id, schedule.next_run_at, new_next + ) + if not won: + counts["RearmLost"] += 1 + return + + _invoke_worker( + { + "scheduleId": schedule.schedule_id, + "userId": schedule.user_id, + } + ) + counts["Dispatched"] += 1 + + +async def dispatch_once() -> Dict[str, int]: + """One dispatcher tick. Returns the metric counts (also emitted).""" + counts: Dict[str, int] = { + "SchedulesDue": 0, + "Dispatched": 0, + "PausedRunaway": 0, + "RearmLost": 0, + } + + if os.environ.get("SCHEDULED_RUNS_ENABLED", "false").lower() != "true": + logger.info("SCHEDULED_RUNS_ENABLED is not true; dispatcher tick is a no-op") + return counts + + now = _now() + limit = _env_int("SCHEDULED_RUNS_DISPATCH_LIMIT", 20) + now_iso = now.isoformat().replace("+00:00", "Z") + due = await list_due_schedules(now=now_iso, limit=limit) + counts["SchedulesDue"] = len(due) + logger.info(f"Dispatcher tick: {len(due)} due schedules (limit {limit})") + + for schedule in due: + try: + await _dispatch_schedule(schedule, now, counts) + except Exception as e: + # One broken schedule must not starve the rest of the sweep. + logger.error(f"Failed to dispatch schedule {schedule.schedule_id}: {e}", exc_info=True) + + _emit_metrics(counts) + return counts + + +def lambda_handler(event, context): + """EventBridge entry point.""" + counts = asyncio.run(dispatch_once()) + return {"statusCode": 200, "body": counts} diff --git a/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt b/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt new file mode 100644 index 00000000..0e6532b1 --- /dev/null +++ b/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt @@ -0,0 +1,13 @@ +# scheduled-runs Lambda image dependencies (backend/Dockerfile.scheduled-runs). +# Exact pins per repo policy; versions match backend/uv.lock. +boto3==1.43.9 +pydantic==2.12.5 +httpx==0.28.1 +bedrock-agentcore==1.9.1 +# Worker-only transitive deps (both handlers share ONE image, and the +# Dockerfile installs THIS file): the worker imports apis.shared.harness -> +# apis.shared.sessions_bff, whose cookie.py needs cryptography (AESGCM) and +# cache.py needs cachetools (TTLCache). The dispatcher itself does not import +# these, but the shared image must satisfy the worker's import chain. +cryptography==48.0.1 +cachetools==6.2.4 diff --git a/backend/src/lambdas/scheduled_runs_worker/requirements.txt b/backend/src/lambdas/scheduled_runs_worker/requirements.txt new file mode 100644 index 00000000..bcd4399b --- /dev/null +++ b/backend/src/lambdas/scheduled_runs_worker/requirements.txt @@ -0,0 +1,12 @@ +# scheduled-runs Lambda image dependencies (backend/Dockerfile.scheduled-runs). +# Exact pins per repo policy; versions match backend/uv.lock. +boto3==1.43.9 +pydantic==2.12.5 +httpx==0.28.1 +bedrock-agentcore==1.9.1 +# Worker transitive deps: apis.shared.harness -> apis.shared.sessions_bff, +# whose cookie.py needs cryptography (AESGCM) and cache.py needs cachetools +# (TTLCache). Kept in sync with the dispatcher requirements.txt, which is the +# file Dockerfile.scheduled-runs actually installs for the shared image. +cryptography==48.0.1 +cachetools==6.2.4 diff --git a/backend/src/lambdas/scheduled_runs_worker/worker.py b/backend/src/lambdas/scheduled_runs_worker/worker.py new file mode 100644 index 00000000..c6237293 --- /dev/null +++ b/backend/src/lambdas/scheduled_runs_worker/worker.py @@ -0,0 +1,175 @@ +"""Scheduled-runs worker β€” executes a single schedule's headless run. + +Given `{"scheduleId", "userId"}` from the dispatcher (async-invoked, +`InvocationType=Event`), resolves the schedule record, calls +`run_agent_headless(trigger="schedule")` with its snapshotted config, and +records the outcome via `apis.shared.scheduled_prompts.service`. + +Governance (docs/specs/scheduled-runs-phase-b-brief.md Β§1): a scheduled run +executes as the owning user with that user's own RBAC and delivers back to +that same user's session list. Delivery already happens inside +`run_agent_headless` (the runtime persists the session/messages during the +turn) β€” this worker's only job after the call returns is bookkeeping: +record the outcome, and pause the schedule on the terminal failure modes +below (mirrors the KB-sync worker's pause/breaker outcomes, `apis/app_api/ +kb_sync/worker.py`). + +Failure handling (brief Β§2, "Worker" bullet): + - HeadlessAuthError (no/expired headless grant) -> paused_error, + state_reason="reauth_required". Not a failure streak β€” the user must + log in again and re-enable; retry-spamming a dead grant is pointless. + - RunResult.status == "oauth_required" (a connector tool needs (re-) + consent; a headless run cannot pop a consent window) -> paused_error, + state_reason="oauth_required". The consent URL is recorded in + `last_error` for a future B3 surface to render. + - Any other error/timeout status (and any unexpected exception) -> + record_run_result(status="error"), which increments the persistent + ``consecutive_failures`` counter and returns the new streak; after + SCHEDULED_RUNS_MAX_FAILURES consecutive failures, paused_error, + state_reason="repeated_failures" (same breaker shape as KB-sync's + consecutive_failures >= 5). + - completed -> record_run_result(status="completed", session_id=...), + which resets the streak to 0. +""" + +import asyncio +import logging +import os +from typing import Any, Dict, Optional + +from apis.shared.harness import ( + CognitoRefreshBearerAuth, + HeadlessAuthError, + RunResult, + run_agent_headless, +) +from apis.shared.scheduled_prompts.models import ScheduledPrompt +from apis.shared.scheduled_prompts.service import ( + get_scheduled_prompt, + record_run_result, + set_schedule_state, +) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +def _env_int(name: str, default: int) -> int: + return int(os.environ.get(name, default)) + + +async def _record_failure_and_maybe_pause( + schedule: ScheduledPrompt, + *, + error: Optional[str], + session_id: Optional[str], + max_failures: int, + result_label: str, +) -> Dict[str, Any]: + """Record an errored run and trip the breaker on a real failure streak. + + ``record_run_result`` maintains the persistent ``consecutive_failures`` + counter (reset on a completed run) and returns the new streak, so the + breaker fires correctly at any ``max_failures`` β€” a last-status proxy + could only ever see one run back and so never reach a threshold > 2. + """ + streak = await record_run_result( + schedule.user_id, + schedule.schedule_id, + status="error", + session_id=session_id, + error=error, + ) + if streak >= max_failures: + await set_schedule_state( + schedule.user_id, + schedule.schedule_id, + "paused_error", + state_reason="repeated_failures", + ) + logger.warning( + f"Schedule {schedule.schedule_id}: {streak} consecutive failures " + f"(max {max_failures}); pausing" + ) + return {"scheduleId": schedule.schedule_id, "result": "paused_error", "reason": "repeated_failures"} + return {"scheduleId": schedule.schedule_id, "result": result_label} + + +async def _pause_reauth(schedule: ScheduledPrompt, reason: str, detail: Optional[str] = None) -> Dict[str, Any]: + logger.warning( + f"Schedule {schedule.schedule_id}: pausing ({reason}) for user {schedule.user_id}" + + (f" β€” {detail}" if detail else "") + ) + await record_run_result(schedule.user_id, schedule.schedule_id, status="error", error=detail or reason) + await set_schedule_state( + schedule.user_id, + schedule.schedule_id, + "paused_error", + state_reason=reason, + ) + return {"scheduleId": schedule.schedule_id, "result": "paused_error", "reason": reason} + + +async def run_schedule(payload: Dict[str, Any]) -> Dict[str, Any]: + schedule_id = payload["scheduleId"] + user_id = payload["userId"] + + schedule = await get_scheduled_prompt(user_id, schedule_id) + if schedule is None: + logger.info(f"Scheduled-runs worker: schedule {schedule_id} no longer exists; dropping run") + return {"scheduleId": schedule_id, "result": "dropped"} + + try: + result: RunResult = await run_agent_headless( + user_id=user_id, + prompt=schedule.prompt_text, + auth=CognitoRefreshBearerAuth(), + title=schedule.label, + rag_assistant_id=schedule.assistant_id, + enabled_tools=schedule.enabled_tools, + trigger="schedule", + ) + except HeadlessAuthError as e: + # No active headless grant, or Cognito refused the refresh + # exchange β€” the user must log in and re-enable. Do not + # retry-spam a dead credential. + return await _pause_reauth(schedule, "reauth_required", str(e)) + except Exception as e: + # Last-resort catch: always record the run so the schedule never + # gets stuck silently un-run, and the failure-streak breaker sees it. + logger.error(f"Scheduled-runs worker: unexpected failure on schedule {schedule_id}: {e}", exc_info=True) + return await _record_failure_and_maybe_pause( + schedule, + error=str(e), + session_id=None, + max_failures=_env_int("SCHEDULED_RUNS_MAX_FAILURES", 3), + result_label="error", + ) + + if result.status == "oauth_required": + detail = None + if result.oauth_required: + first = result.oauth_required[0] + detail = f"provider={first.provider_id} authorization_url={first.authorization_url}" + return await _pause_reauth(schedule, "oauth_required", detail) + + if result.status == "completed": + await record_run_result( + user_id, schedule_id, status="completed", session_id=result.session_id + ) + logger.info(f"Schedule {schedule_id}: run {result.run_id} completed (session {result.session_id})") + return {"scheduleId": schedule_id, "result": "completed", "sessionId": result.session_id} + + # error / timeout β€” record and apply the consecutive-failure breaker. + return await _record_failure_and_maybe_pause( + schedule, + error=result.error, + session_id=result.session_id, + max_failures=_env_int("SCHEDULED_RUNS_MAX_FAILURES", 3), + result_label=result.status, + ) + + +def lambda_handler(event, context): + """Async-invoke entry point (InvocationType=Event from the dispatcher).""" + return asyncio.run(run_schedule(event)) diff --git a/backend/tests/agents/builtin_tools/memory_spaces/__init__.py b/backend/tests/agents/builtin_tools/memory_spaces/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/agents/builtin_tools/memory_spaces/test_memory_tools.py b/backend/tests/agents/builtin_tools/memory_spaces/test_memory_tools.py new file mode 100644 index 00000000..55745c57 --- /dev/null +++ b/backend/tests/agents/builtin_tools/memory_spaces/test_memory_tools.py @@ -0,0 +1,100 @@ +"""Agent Designer Phase 3 β€” memory_* tool factories. + +Each tool is closed over the bound space id + invoker identity; MemorySpaceService is +patched. Verifies success payloads and that a revoked grant (permission error) surfaces +as an error tool-result rather than raising. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from agents.builtin_tools.memory_spaces import ( + make_memory_list_tool, + make_memory_read_tool, + make_memory_write_tool, +) +from apis.shared.memory.service import ( + MemorySpaceNotFoundError, + MemorySpacePermissionError, +) + +MODULE = "agents.builtin_tools.memory_spaces.tools" + + +async def _call(tool, *args, **kwargs): + fn = getattr(tool, "__wrapped__", None) or tool + return await fn(*args, **kwargs) + + +def _patch_service(monkeypatch) -> MagicMock: + svc = MagicMock() + monkeypatch.setattr(f"{MODULE}.MemorySpaceService", lambda: svc) + return svc + + +class TestMemoryList: + @pytest.mark.asyncio + async def test_lists_manifest_summary(self, monkeypatch): + svc = _patch_service(monkeypatch) + svc.list_entries.return_value = [ + SimpleNamespace(slug="jane", entry_type="entity", description="a person", updated="2026-07-07"), + ] + tool = make_memory_list_tool("spc_1", "Brain", "u1", "u1@x.edu") + result = await _call(tool) + assert result["status"] == "success" + assert result["content"][0]["json"]["entries"][0]["slug"] == "jane" + # scoped to the bound space + invoker + assert svc.list_entries.call_args.args[:3] == ("spc_1", "u1", "u1@x.edu") + + @pytest.mark.asyncio + async def test_revoked_grant_is_error_result(self, monkeypatch): + svc = _patch_service(monkeypatch) + svc.list_entries.side_effect = MemorySpacePermissionError("nope") + tool = make_memory_list_tool("spc_1", "Brain", "u1", "u1@x.edu") + result = await _call(tool) + assert result["status"] == "error" + assert "no longer have access" in result["content"][0]["text"] + + +class TestMemoryRead: + @pytest.mark.asyncio + async def test_reads_body(self, monkeypatch): + svc = _patch_service(monkeypatch) + svc.read_entry.return_value = "Jane is the CFO." + tool = make_memory_read_tool("spc_1", "Brain", "u1", "u1@x.edu") + result = await _call(tool, slug="jane") + assert result["status"] == "success" + assert result["content"][0]["text"] == "Jane is the CFO." + + @pytest.mark.asyncio + async def test_missing_entry_is_error_result(self, monkeypatch): + svc = _patch_service(monkeypatch) + svc.read_entry.side_effect = MemorySpaceNotFoundError("gone") + tool = make_memory_read_tool("spc_1", "Brain", "u1", "u1@x.edu") + result = await _call(tool, slug="ghost") + assert result["status"] == "error" and "No memory entry 'ghost'" in result["content"][0]["text"] + + +class TestMemoryWrite: + @pytest.mark.asyncio + async def test_writes_and_confirms(self, monkeypatch): + svc = _patch_service(monkeypatch) + svc.write_entry.return_value = SimpleNamespace(slug="jane", entry_type="entity") + tool = make_memory_write_tool("spc_1", "Brain", "u1", "u1@x.edu") + result = await _call(tool, slug="jane", body="Jane is the CFO.", entry_type="entity", description="person") + assert result["status"] == "success" + assert 'Saved memory entry "jane"' in result["content"][0]["text"] + # write goes to the bound space as the invoker, with the given fields + kwargs = svc.write_entry.call_args + assert kwargs.args[0] == "spc_1" and kwargs.args[1] == "u1" + assert kwargs.kwargs["entry_type"] == "entity" + + @pytest.mark.asyncio + async def test_write_permission_error_is_error_result(self, monkeypatch): + svc = _patch_service(monkeypatch) + svc.write_entry.side_effect = MemorySpacePermissionError("read-only") + tool = make_memory_write_tool("spc_1", "Brain", "u1", "u1@x.edu") + result = await _call(tool, slug="jane", body="x") + assert result["status"] == "error" and "don't have write access" in result["content"][0]["text"] diff --git a/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py b/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py index dd6aaf47..ff712cd0 100644 --- a/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py +++ b/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py @@ -114,13 +114,13 @@ async def test_disconnected_lookup_bypasses_token_cache(self): assert kwargs["force_authentication"] is True @pytest.mark.asyncio - async def test_force_authentication_sends_prompt_consent_for_google(self): - """Google only re-issues a refresh token on subsequent grants if - the user is shown the consent screen β€” so the explicit re-consent - path (force_authentication=True) must propagate prompt=consent - through to AgentCore Identity. Without it, a Disconnect/Reconnect - cycle leaves the vault with an access token but no refresh token, - putting the user back in the hourly-reconsent loop.""" + async def test_force_authentication_forwards_configured_custom_parameters(self): + """On the explicit re-consent path (force_authentication=True) the + hook forwards the connector's configured customParameters verbatim. + For a Google connector that's `access_type=offline` + `prompt=consent` + β€” Google only re-issues a refresh token when the consent screen is + shown, so without prompt=consent a Disconnect/Reconnect cycle leaves + the vault with an access token but no refresh token.""" identity = MagicMock() identity.get_token_for_user = AsyncMock( return_value=TokenResult(authorization_url="https://accounts/consent") @@ -130,7 +130,10 @@ async def test_force_authentication_sends_prompt_consent_for_google(self): user_id="alice", provider_lookup=lambda _tool: "google", scopes_lookup=lambda _: ["openid"], - provider_type_lookup=lambda _: "google", + custom_parameters_lookup=lambda _: { + "access_type": "offline", + "prompt": "consent", + }, disconnected_lookup=lambda _pid: True, ) event = _make_event(provider_id="google") @@ -150,10 +153,14 @@ async def test_force_authentication_sends_prompt_consent_for_google(self): } @pytest.mark.asyncio - async def test_silent_refresh_does_not_send_prompt_consent(self): - """The refresh path (force_authentication=False) must not send - prompt=consent β€” that would force the consent screen on every - silent refresh, which is the exact UX we're trying to avoid.""" + async def test_silent_refresh_forwards_same_custom_parameters(self): + """The silent-refresh path (force_authentication=False) forwards the + SAME configured customParameters as the consent path. AgentCore + factors the map into the vault key, so they must match or a usable + vaulted token is treated as a fresh request. (prompt=consent is inert + on a refresh-token exchange β€” that hits the token endpoint, not the + interactive authorization redirect β€” so sending it uniformly is + safe.)""" identity = MagicMock() identity.get_token_for_user = AsyncMock( return_value=TokenResult(access_token="refreshed-token") @@ -163,7 +170,10 @@ async def test_silent_refresh_does_not_send_prompt_consent(self): user_id="alice", provider_lookup=lambda _tool: "google", scopes_lookup=lambda _: ["openid"], - provider_type_lookup=lambda _: "google", + custom_parameters_lookup=lambda _: { + "access_type": "offline", + "prompt": "consent", + }, ) event = _make_event(provider_id="google") @@ -175,8 +185,10 @@ async def test_silent_refresh_does_not_send_prompt_consent(self): kwargs = identity.get_token_for_user.call_args.kwargs assert kwargs["force_authentication"] is False - assert kwargs["custom_parameters"] == {"access_type": "offline"} - assert "prompt" not in kwargs["custom_parameters"] + assert kwargs["custom_parameters"] == { + "access_type": "offline", + "prompt": "consent", + } @pytest.mark.asyncio async def test_uses_cached_token_without_calling_identity(self): @@ -785,11 +797,12 @@ async def test_scopes_lookup_is_cached_across_calls(self): assert scopes_lookup.call_count == 1 @pytest.mark.asyncio - async def test_provider_type_lookup_forwards_custom_parameters(self): - """When the provider is Google, the hook forwards - `custom_parameters={"access_type": "offline"}` to AgentCore Identity - so Google issues a refresh token (vault entry would otherwise expire - after ~1 hour with no refresh path).""" + async def test_custom_parameters_lookup_forwards_configured_params(self): + """For a Google connector configured with `access_type=offline`, the + hook forwards it to AgentCore Identity so Google issues a refresh + token (the vault entry would otherwise expire after ~1 hour with no + refresh path). The value comes from the connector config, not a + hardcoded vendor baseline.""" identity = MagicMock() identity.get_token_for_user = AsyncMock( return_value=TokenResult(access_token="t") @@ -799,7 +812,7 @@ async def test_provider_type_lookup_forwards_custom_parameters(self): user_id="alice", provider_lookup=lambda _tool: "google", scopes_lookup=lambda _: ["openid"], - provider_type_lookup=lambda _: "google", + custom_parameters_lookup=lambda _: {"access_type": "offline"}, ) event = _make_event(provider_id="google") @@ -815,10 +828,10 @@ async def test_provider_type_lookup_forwards_custom_parameters(self): } @pytest.mark.asyncio - async def test_admin_custom_parameters_merge_with_baseline(self): - """Hook merges admin-supplied extras (e.g. Google `hd=` for Workspace - domain restriction) with the vendor baseline before forwarding to - AgentCore. Baseline still wins on conflict.""" + async def test_custom_parameters_forwarded_verbatim(self): + """Hook forwards the connector's configured extras verbatim β€” no + hardcoded baseline is injected or allowed to override. An admin who + sets `access_type=online` gets exactly that (their choice).""" identity = MagicMock() identity.get_token_for_user = AsyncMock( return_value=TokenResult(access_token="t") @@ -828,10 +841,9 @@ async def test_admin_custom_parameters_merge_with_baseline(self): user_id="alice", provider_lookup=lambda _tool: "google", scopes_lookup=lambda _: ["openid"], - provider_type_lookup=lambda _: "google", custom_parameters_lookup=lambda _: { "hd": "mycompany.com", - "access_type": "online", # admin attempts override; ignored + "access_type": "online", }, ) @@ -844,15 +856,15 @@ async def test_admin_custom_parameters_merge_with_baseline(self): identity.get_token_for_user.assert_called_once() assert identity.get_token_for_user.call_args.kwargs["custom_parameters"] == { - "access_type": "offline", # baseline wins + "access_type": "online", "hd": "mycompany.com", } @pytest.mark.asyncio - async def test_no_provider_type_lookup_omits_custom_parameters(self): - """When the lookup is omitted (legacy callers / non-Google vendors), - no `custom_parameters` is sent β€” AgentCore handles vendor defaults - and we don't accidentally inject Google-specific keys elsewhere.""" + async def test_no_custom_parameters_lookup_omits_custom_parameters(self): + """When the lookup is omitted (legacy callers / connectors with no + configured extras), no `custom_parameters` is sent β€” AgentCore handles + vendor defaults and we don't inject anything.""" identity = MagicMock() identity.get_token_for_user = AsyncMock( return_value=TokenResult(access_token="t") @@ -862,7 +874,7 @@ async def test_no_provider_type_lookup_omits_custom_parameters(self): user_id="alice", provider_lookup=lambda _tool: "github", scopes_lookup=lambda _: ["read:user"], - # no provider_type_lookup + # no custom_parameters_lookup ) event = _make_event(provider_id="github") diff --git a/backend/tests/agents/main_agent/streaming/test_interrupted_turn_persistence.py b/backend/tests/agents/main_agent/streaming/test_interrupted_turn_persistence.py new file mode 100644 index 00000000..01cc974d --- /dev/null +++ b/backend/tests/agents/main_agent/streaming/test_interrupted_turn_persistence.py @@ -0,0 +1,336 @@ +"""Regression: a turn interrupted mid-stream (client Stop / refresh / dropped +socket) persists the in-flight partial assistant text + an interrupted marker +instead of leaving an orphan user turn. + +A client teardown surfaces inside the coordinator generator as +``asyncio.CancelledError`` (cancellation delivered at an inner ``await``) or +``GeneratorExit`` (thrown at a ``yield`` via ``aclose()``). Both subclass +``BaseException``, so they slip past ``process_agent_stream``'s ``except +Exception`` and the coordinator's own ``except Exception`` β€” without a +dedicated arm the partial is lost and the turn stays a dangling user message. + +Key invariants under test: +- The partial persisted is ONLY the in-flight message's text. Completed + mid-turn messages were already committed by the ``append_message`` hook + (``TurnBasedSessionManager`` persists per-message; ``flush`` is a no-op), + so re-persisting their text would duplicate history. +- Assistant-only persistence β€” the user turn was already committed at turn + start by Strands' MessageAddedEvent hook. +- The empty-partial placeholder exists solely to repair userβ†’user role + alternation, so it is gated on the history tail being a user message. +- The marker's fallback reason is ``connection_lost``; the client's + ``user_stopped`` signal wins via precedence in ``set_interrupted_turn`` + (covered in tests/shared/test_sessions_metadata.py). +""" + +import asyncio +from typing import Any, AsyncIterator, Dict, List +from unittest.mock import patch + +import pytest + +from agents.main_agent.streaming.stream_coordinator import StreamCoordinator + + +class _InterruptingAgent: + """Agent whose stream yields raw Strands events then raises the given + teardown exception, mimicking a client disconnect mid-generation.""" + + def __init__(self, events: List[Dict[str, Any]] = None, exc: BaseException = None) -> None: + self.messages = [{"role": "user", "content": [{"text": "hi"}]}] + self._events = events or [] + self._exc = exc if exc is not None else asyncio.CancelledError() + + def stream_async(self, prompt: Any) -> AsyncIterator[Dict[str, Any]]: + async def _gen() -> AsyncIterator[Dict[str, Any]]: + for event in self._events: + yield event + raise self._exc + + return _gen() + + +class _NoopSessionManager: + async def update_after_turn(self, input_tokens: int, current_messages=None): + return None + + +class _RecordingPersistSessionManager: + """Flat ``create_message`` shape matching the current SDK.""" + + def __init__(self) -> None: + self.calls: List[Dict[str, Any]] = [] + + def create_message(self, session_id: str, agent_id: str, session_message: Any) -> None: + self.calls.append({"session_id": session_id, "agent_id": agent_id, "message": session_message}) + + +def _extract_text(session_message: Any) -> str: + inner = getattr(session_message, "message", None) + content = inner.get("content") if isinstance(inner, dict) else getattr(inner, "content", None) + if isinstance(content, list): + return "".join(block.get("text", "") for block in content if isinstance(block, dict)) + return "" + + +def _raw_message_start() -> Dict[str, Any]: + return {"event": {"messageStart": {"role": "assistant"}}} + + +def _raw_text_delta(text: str) -> Dict[str, Any]: + return {"event": {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": text}}}} + + +def _raw_message_stop() -> Dict[str, Any]: + return {"event": {"messageStop": {"stopReason": "end_turn"}}} + + +async def _drive_until_teardown(agent: Any, expected_exc: type) -> None: + coordinator = StreamCoordinator() + with pytest.raises(expected_exc): + async for _sse in coordinator.stream_response( + agent=agent, + prompt="please write a long essay", + session_manager=_NoopSessionManager(), + session_id="sess-interrupt", + user_id="user-1", + main_agent_wrapper=None, + ): + pass + + +@pytest.mark.asyncio +async def test_cancellation_invokes_interruption_persistence(): + """The CancelledError arm fires and re-raises so cancellation still unwinds.""" + called: Dict[str, Any] = {} + + async def _fake_persist(self, *, agent, session_id, user_id, partial_text, **kwargs): + called.update(session_id=session_id, user_id=user_id, partial_text=partial_text) + + with patch.object(StreamCoordinator, "_persist_interruption", _fake_persist): + await _drive_until_teardown(_InterruptingAgent(), asyncio.CancelledError) + + assert called.get("session_id") == "sess-interrupt" + assert called.get("user_id") == "user-1" + + +@pytest.mark.asyncio +async def test_generator_exit_also_invokes_interruption_persistence(): + """Starlette teardown can surface as GeneratorExit (aclose at a yield) + instead of CancelledError β€” the arm must catch both.""" + called: Dict[str, Any] = {} + + async def _fake_persist(self, *, agent, session_id, user_id, partial_text, **kwargs): + called.update(session_id=session_id, user_id=user_id, partial_text=partial_text) + + with patch.object(StreamCoordinator, "_persist_interruption", _fake_persist): + await _drive_until_teardown( + _InterruptingAgent(exc=GeneratorExit()), GeneratorExit + ) + + assert called.get("session_id") == "sess-interrupt" + + +@pytest.mark.asyncio +async def test_partial_covers_only_in_flight_message(): + """Text from a COMPLETED mid-turn message (already persisted by the + append_message hook) must not re-enter the partial β€” only deltas of the + message still streaming at teardown count.""" + called: Dict[str, Any] = {} + + async def _fake_persist(self, *, agent, session_id, user_id, partial_text, **kwargs): + called.update(partial_text=partial_text) + + events = [ + _raw_message_start(), + _raw_text_delta("Completed first message."), + _raw_message_stop(), + _raw_message_start(), + _raw_text_delta("In-flight par"), + ] + with patch.object(StreamCoordinator, "_persist_interruption", _fake_persist): + await _drive_until_teardown( + _InterruptingAgent(events=events), asyncio.CancelledError + ) + + assert called.get("partial_text") == "In-flight par" + + +@pytest.mark.asyncio +async def test_persist_interruption_writes_partial_assistant_only(): + """Non-empty partial β†’ exactly one assistant create_message with that text, + and the connection_lost fallback marker.""" + persist_sm = _RecordingPersistSessionManager() + marker_calls: List[Dict[str, Any]] = [] + + async def _fake_set_interrupted(session_id, user_id, reason="unknown", source="cancellation"): + marker_calls.append({"session_id": session_id, "user_id": user_id, "reason": reason, "source": source}) + + coordinator = StreamCoordinator() + with patch( + "agents.main_agent.session.session_factory.SessionFactory.create_session_manager", + return_value=persist_sm, + ), patch("apis.shared.sessions.metadata.set_interrupted_turn", _fake_set_interrupted): + await coordinator._persist_interruption( + agent=_InterruptingAgent(), + session_id="sess-interrupt", + user_id="user-1", + partial_text="Here is the start of my answ", + ) + + assert len(persist_sm.calls) == 1 + call = persist_sm.calls[0] + inner = getattr(call["message"], "message", None) + role = inner.get("role") if isinstance(inner, dict) else getattr(inner, "role", None) + assert role == "assistant" + assert _extract_text(call["message"]) == "Here is the start of my answ" + + assert marker_calls == [ + {"session_id": "sess-interrupt", "user_id": "user-1", "reason": "connection_lost", "source": "cancellation"} + ] + + +@pytest.mark.asyncio +async def test_empty_partial_uses_placeholder_when_user_turn_dangles(): + """Interruption before any token, history tail = user β†’ persist a minimal + placeholder assistant turn so user/assistant role alternation stays valid.""" + persist_sm = _RecordingPersistSessionManager() + + async def _fake_set_interrupted(session_id, user_id, reason="unknown", source="cancellation"): + return None + + agent = _InterruptingAgent() # messages tail is the user turn + coordinator = StreamCoordinator() + with patch( + "agents.main_agent.session.session_factory.SessionFactory.create_session_manager", + return_value=persist_sm, + ), patch("apis.shared.sessions.metadata.set_interrupted_turn", _fake_set_interrupted): + await coordinator._persist_interruption( + agent=agent, + session_id="sess-interrupt", + user_id="user-1", + partial_text=" ", + ) + + assert len(persist_sm.calls) == 1 + text = _extract_text(persist_sm.calls[0]["message"]) + assert "interrupted" in text.lower() + + +@pytest.mark.asyncio +async def test_empty_partial_skips_synthetic_write_when_tail_is_assistant(): + """No in-flight text and history tail = assistant (continuation/resume + teardown) β†’ alternation needs no repair, so no synthetic message; the + marker is still set.""" + persist_sm = _RecordingPersistSessionManager() + marker_calls: List[Dict[str, Any]] = [] + + async def _fake_set_interrupted(session_id, user_id, reason="unknown", source="cancellation"): + marker_calls.append({"reason": reason}) + + agent = _InterruptingAgent() + agent.messages = [ + {"role": "user", "content": [{"text": "hi"}]}, + {"role": "assistant", "content": [{"text": "truncated partial"}]}, + ] + coordinator = StreamCoordinator() + with patch( + "agents.main_agent.session.session_factory.SessionFactory.create_session_manager", + return_value=persist_sm, + ), patch("apis.shared.sessions.metadata.set_interrupted_turn", _fake_set_interrupted): + await coordinator._persist_interruption( + agent=agent, + session_id="sess-interrupt", + user_id="user-1", + partial_text="", + ) + + assert persist_sm.calls == [] + assert marker_calls == [{"reason": "connection_lost"}] + + +@pytest.mark.asyncio +async def test_interruption_persists_partial_turn_metadata(): + """A Stop with a partial persists per-message metadata (which also bumps + the session cost aggregates) keyed to the interrupted message's index, so + the token/cost badges + session cost badge hydrate on reload. When the cut + generation never delivered Bedrock's terminal usage event, the input side + falls back to the context-attribution projection.""" + persist_sm = _RecordingPersistSessionManager() + stored: Dict[str, Any] = {} + + async def _fake_set_interrupted(session_id, user_id, reason="unknown", source="cancellation"): + return None + + async def _fake_store_metadata(self, *, session_id, user_id, message_id, accumulated_metadata, **kwargs): + stored.update( + message_id=message_id, + usage=accumulated_metadata.get("usage"), + agent=kwargs.get("agent"), + ) + + wrapper = object() # stands in for the MainAgent wrapper (model_config, etc.) + + coordinator = StreamCoordinator() + with patch( + "agents.main_agent.session.session_factory.SessionFactory.create_session_manager", + return_value=persist_sm, + ), patch( + "apis.shared.sessions.metadata.set_interrupted_turn", _fake_set_interrupted + ), patch.object( + StreamCoordinator, "_store_message_metadata", _fake_store_metadata + ), patch( + "agents.main_agent.session.hooks.context_attribution.get_context_breakdown", + return_value={"total": 1234, "partitions": []}, + ): + await coordinator._persist_interruption( + agent=_InterruptingAgent(), + session_id="sess-interrupt", + user_id="user-1", + partial_text="Here is the start of my answ", + main_agent_wrapper=wrapper, + accumulated_metadata={"usage": {}, "metrics": {}}, + initial_message_count=4, + current_assistant_message_index=0, + stream_start_time=100.0, + first_token_time=100.2, + ) + + # Interrupted message sits at initial + 2*idx + 1 = 4 + 0 + 1 = 5 β€” the same + # odd-position index the messages endpoint re-derives as `idx` on reload. + assert stored.get("message_id") == 5 + # Terminal usage never arrived β†’ projected input from context attribution. + assert stored.get("usage") == {"inputTokens": 1234, "outputTokens": 0, "totalTokens": 1234} + assert stored.get("agent") is wrapper + + +@pytest.mark.asyncio +async def test_interruption_metadata_skipped_without_wrapper(): + """No model wrapper (can't identify/price the model) β†’ skip metadata + persistence entirely; the partial + marker still land.""" + persist_sm = _RecordingPersistSessionManager() + store_calls: List[Any] = [] + + async def _fake_set_interrupted(session_id, user_id, reason="unknown", source="cancellation"): + return None + + async def _fake_store_metadata(self, **kwargs): + store_calls.append(kwargs) + + coordinator = StreamCoordinator() + with patch( + "agents.main_agent.session.session_factory.SessionFactory.create_session_manager", + return_value=persist_sm, + ), patch( + "apis.shared.sessions.metadata.set_interrupted_turn", _fake_set_interrupted + ), patch.object(StreamCoordinator, "_store_message_metadata", _fake_store_metadata): + await coordinator._persist_interruption( + agent=_InterruptingAgent(), + session_id="sess-interrupt", + user_id="user-1", + partial_text="partial", + main_agent_wrapper=None, + ) + + assert store_calls == [] + assert len(persist_sm.calls) == 1 # partial still persisted diff --git a/backend/tests/apis/app_api/agent_designer/test_bindable_catalog.py b/backend/tests/apis/app_api/agent_designer/test_bindable_catalog.py new file mode 100644 index 00000000..29c8ace9 --- /dev/null +++ b/backend/tests/apis/app_api/agent_designer/test_bindable_catalog.py @@ -0,0 +1,134 @@ +"""Agent Designer Phase 2 β€” the bindable-primitives catalog (D4). + +Unit tests for ``list_bindable``: each primitive's list/access service is mocked so +these stay fast. Asserts the uniform ``BindableItem`` projection per kind, that ``ref`` +carries the correct identifier (Bedrock model_id for models β€” not the internal UUID), +the feature-flag gating for skills/memory_space, the welded-KB empty result, and the +best-effort degradation on a sub-service failure. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from apis.app_api.agent_designer.services import bindable_catalog as bc +from apis.shared.auth.models import User + +MODULE = "apis.app_api.agent_designer.services.bindable_catalog" + + +def _user() -> User: + return User(email="alice@x.edu", user_id="u1", name="Alice", roles=[]) + + +def _model(**kw): + base = dict( + model_id="us.anthropic.claude", model_name="Claude", provider="bedrock", + provider_name="Bedrock", is_default=True, max_input_tokens=200000, + max_output_tokens=8192, supports_caching=True, input_modalities=["text"], + output_modalities=["text"], supported_params=None, + ) + base.update(kw) + return SimpleNamespace(**base) + + +def _model_svc(models): + svc = MagicMock() + svc.filter_accessible_models = AsyncMock(return_value=models) + return svc + + +# --------------------------------------------------------------------------- model +class TestModels: + @pytest.mark.asyncio + async def test_model_ref_is_bedrock_model_id(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.list_all_managed_models", AsyncMock(return_value=[_model()])) + items = await bc.list_bindable("model", _user(), model_access_service=_model_svc([_model()])) + assert len(items) == 1 + it = items[0] + assert it.kind == "model" + assert it.ref == "us.anthropic.claude" # NOT the internal UUID id + assert it.label == "Claude" + assert it.meta["provider"] == "bedrock" + assert it.meta["isDefault"] is True + + @pytest.mark.asyncio + async def test_model_service_failure_degrades_to_empty(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.list_all_managed_models", AsyncMock(side_effect=RuntimeError("boom"))) + items = await bc.list_bindable("model", _user(), model_access_service=_model_svc([])) + assert items == [] + + +# --------------------------------------------------------------------------- tool +class TestTools: + @pytest.mark.asyncio + async def test_tool_projection_with_server_tools(self): + tool = SimpleNamespace( + tool_id="wikipedia", display_name="Wikipedia", description="Search Wikipedia", + category="research", protocol="mcp", requires_oauth_provider=None, + server_tools=[SimpleNamespace(name="search", description="d", needs_approval=False, enabled=True)], + ) + svc = MagicMock() + svc.get_user_accessible_tools = AsyncMock(return_value=[tool]) + items = await bc.list_bindable("tool", _user(), tool_service=svc) + assert items[0].ref == "wikipedia" + assert items[0].meta["serverTools"][0]["name"] == "search" + + +# --------------------------------------------------------------------------- skill +class TestSkills: + @pytest.mark.asyncio + async def test_skills_empty_when_flag_off(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.skills_enabled", lambda: False) + items = await bc.list_bindable("skill", _user()) + assert items == [] + + @pytest.mark.asyncio + async def test_skills_hydrated_when_flag_on(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.skills_enabled", lambda: True) + monkeypatch.setattr(f"{MODULE}.resolve_accessible_skill_ids", AsyncMock(return_value=["pdf"])) + skill = SimpleNamespace(skill_id="pdf", display_name="PDF", description="PDF tools", + bound_tool_ids=["t1"], compose=[]) + repo = MagicMock() + repo.batch_get_skills = AsyncMock(return_value=[skill]) + monkeypatch.setattr(f"{MODULE}.get_skill_catalog_repository", lambda: repo) + items = await bc.list_bindable("skill", _user()) + assert items[0].ref == "pdf" + assert items[0].meta["boundToolIds"] == ["t1"] + + +# --------------------------------------------------------------------------- kb +class TestKnowledgeBase: + @pytest.mark.asyncio + async def test_kb_always_empty(self): + # Welded to the agent, synthesized on read, never author-settable. + assert await bc.list_bindable("knowledge_base", _user()) == [] + + +# --------------------------------------------------------------------------- memory +class TestMemorySpaces: + @pytest.mark.asyncio + async def test_empty_when_flag_off(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.memory_spaces_enabled", lambda: False) + items = await bc.list_bindable("memory_space", _user(), memory_service=MagicMock()) + assert items == [] + + @pytest.mark.asyncio + async def test_projection_when_flag_on(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.memory_spaces_enabled", lambda: True) + space = SimpleNamespace(space_id="spc_1", name="Oliver", template="chief-of-staff", owner_id="u1") + svc = MagicMock() + svc.list_spaces_for_user = MagicMock(return_value=[(space, "owner")]) + items = await bc.list_bindable("memory_space", _user(), memory_service=svc) + assert items[0].ref == "spc_1" + assert items[0].label == "Oliver" + assert items[0].meta["role"] == "owner" + + +# --------------------------------------------------------------------------- misc +class TestUnknownKind: + @pytest.mark.asyncio + async def test_unknown_kind_raises(self): + with pytest.raises(ValueError): + await bc.list_bindable("nonsense", _user()) diff --git a/backend/tests/apis/app_api/agent_designer/test_binding_validation.py b/backend/tests/apis/app_api/agent_designer/test_binding_validation.py new file mode 100644 index 00000000..fc244302 --- /dev/null +++ b/backend/tests/apis/app_api/agent_designer/test_binding_validation.py @@ -0,0 +1,284 @@ +"""Agent Designer Phase 1 β€” design-time binding/model validation (D4/D5). + +Composes existing per-primitive access checks; the primitive services are mocked so +these stay fast unit tests. Asserts the inert guarantee for tool/skill (no RBAC/catalog +call is made), the memory_space grant matrix, and the implicit-KB rejection. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from apis.app_api.agent_designer.services.binding_validation import ( + BindingValidationError, + validate_agent_write, +) +from apis.shared.assistants.models import AgentBinding, AgentModelConfig +from apis.shared.auth.models import User + +MODULE = "apis.app_api.agent_designer.services.binding_validation" + + +def _user() -> User: + return User(email="alice@x.edu", user_id="u1", name="Alice", roles=[]) + + +def _model_svc(allowed: bool) -> MagicMock: + # Mirror the catalog: design-time validation filters the single model via + # ``filter_accessible_models`` (accessible β†’ the model is returned, else []). + svc = MagicMock() + svc.filter_accessible_models = AsyncMock(side_effect=lambda user, models: list(models) if allowed else []) + return svc + + +def _mem_svc(space, role) -> MagicMock: + svc = MagicMock() + svc.resolve_permission = MagicMock(return_value=(space, role)) + return svc + + +def _tool_svc(*accessible_ids: str) -> MagicMock: + # Mirror the palette: get_user_accessible_tools returns objects carrying .tool_id. + svc = MagicMock() + svc.get_user_accessible_tools = AsyncMock( + return_value=[SimpleNamespace(tool_id=t) for t in accessible_ids] + ) + return svc + + +# --------------------------------------------------------------------------- model +class TestModelValidation: + @pytest.mark.asyncio + async def test_accessible_model_passes(self, monkeypatch): + # The model is resolved by its Bedrock ``model_id`` from the full catalog, not + # by the internal-UUID PK β€” so a valid Bedrock id round-trips through the write. + monkeypatch.setattr( + f"{MODULE}.list_all_managed_models", + AsyncMock(return_value=[SimpleNamespace(model_id="m1")]), + ) + await validate_agent_write( + _user(), + model_settings=AgentModelConfig(model_id="m1"), + model_access_service=_model_svc(True), + ) + + @pytest.mark.asyncio + async def test_unknown_model_400(self, monkeypatch): + monkeypatch.setattr( + f"{MODULE}.list_all_managed_models", + AsyncMock(return_value=[SimpleNamespace(model_id="m1")]), + ) + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), model_settings=AgentModelConfig(model_id="ghost"), model_access_service=_model_svc(True) + ) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_forbidden_model_403(self, monkeypatch): + monkeypatch.setattr( + f"{MODULE}.list_all_managed_models", + AsyncMock(return_value=[SimpleNamespace(model_id="m1")]), + ) + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), model_settings=AgentModelConfig(model_id="m1"), model_access_service=_model_svc(False) + ) + assert ei.value.status_code == 403 + + @pytest.mark.asyncio + async def test_membership_grant_without_allowed_app_roles_passes(self, monkeypatch): + """Regression: a model granted via the user's AppRole ``permissions.models`` but + with an empty ``allowed_app_roles`` is listed by the catalog and must save. + + ``filter_accessible_models`` grants it (membership); the old ``can_access_model`` + path would have rejected it (its membership check is gated on a non-empty + ``allowed_app_roles``), so the picker showed it but the write 403'd. + """ + monkeypatch.setattr( + f"{MODULE}.list_all_managed_models", + AsyncMock(return_value=[SimpleNamespace(model_id="m1", allowed_app_roles=[])]), + ) + svc = MagicMock() + # Catalog-style filter: this model is in the accessible subset. + svc.filter_accessible_models = AsyncMock(side_effect=lambda user, models: list(models)) + await validate_agent_write( + _user(), model_settings=AgentModelConfig(model_id="m1"), model_access_service=svc + ) + svc.filter_accessible_models.assert_awaited_once() + + +# --------------------------------------------------------------------------- inert +class TestInertKinds: + @pytest.mark.asyncio + async def test_skill_stored_without_rbac(self): + # The inert guarantee (skill only now β€” tool is governed): no memory service is + # consulted, and a skill binding is stored verbatim. + mem = _mem_svc(space=None, role=None) + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="skill", ref="skill_1")], + memory_service=mem, + ) + mem.resolve_permission.assert_not_called() + + @pytest.mark.asyncio + async def test_inert_kind_requires_ref(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write(_user(), bindings=[AgentBinding(kind="skill", ref=" ")]) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_unknown_kind_rejected(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write(_user(), bindings=[AgentBinding(kind="bogus", ref="x")]) + assert ei.value.status_code == 400 + + +# --------------------------------------------------------------------------- tool +class TestToolValidation: + @pytest.mark.asyncio + async def test_accessible_tool_passes(self): + # A tool in the author's palette (get_user_accessible_tools) is writable. + svc = _tool_svc("gateway_x", "web_search") + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="tool", ref="gateway_x", config={"enabledTools": []})], + tool_service=svc, + ) + svc.get_user_accessible_tools.assert_awaited_once() + + @pytest.mark.asyncio + async def test_inaccessible_tool_403(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="tool", ref="secret_tool")], + tool_service=_tool_svc("web_search"), + ) + assert ei.value.status_code == 403 + + @pytest.mark.asyncio + async def test_empty_ref_400(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), bindings=[AgentBinding(kind="tool", ref=" ")], tool_service=_tool_svc("web_search") + ) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_accessible_tools_fetched_once_for_many_bindings(self): + # The palette is resolved a single time, then each binding is checked against it. + svc = _tool_svc("a", "b", "c") + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="tool", ref="a"), AgentBinding(kind="tool", ref="b")], + tool_service=svc, + ) + svc.get_user_accessible_tools.assert_awaited_once() + + @pytest.mark.asyncio + async def test_no_tool_binding_skips_tool_fetch(self): + # No tool binding β‡’ the tool service is never consulted (lazy palette resolution). + svc = _tool_svc("a") + await validate_agent_write( + _user(), bindings=[AgentBinding(kind="skill", ref="skill_1")], tool_service=svc + ) + svc.get_user_accessible_tools.assert_not_awaited() + + +# --------------------------------------------------------------------------- KB +class TestKnowledgeBase: + @pytest.mark.asyncio + async def test_explicit_kb_binding_rejected(self): + # Phase 1: KB is managed implicitly (synthesized on read), not author-settable. + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write(_user(), bindings=[AgentBinding(kind="knowledge_base", ref="ast_1")]) + assert ei.value.status_code == 400 + + +# --------------------------------------------------------------------------- memory_space +class TestMemorySpace: + @pytest.fixture(autouse=True) + def _flag_on(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.memory_spaces_enabled", lambda: True) + + @pytest.mark.asyncio + async def test_flag_off_400(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.memory_spaces_enabled", lambda: False) + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "read"})], + memory_service=_mem_svc(space=object(), role="viewer"), + ) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_readwrite_requires_editor(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "readwrite"})], + memory_service=_mem_svc(space=object(), role="viewer"), + ) + assert ei.value.status_code == 403 + + @pytest.mark.asyncio + async def test_readwrite_editor_ok(self): + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "readwrite"})], + memory_service=_mem_svc(space=object(), role="editor"), + ) + + @pytest.mark.asyncio + async def test_read_viewer_ok(self): + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "read"})], + memory_service=_mem_svc(space=object(), role="viewer"), + ) + + @pytest.mark.asyncio + async def test_no_grant_403(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "read"})], + memory_service=_mem_svc(space=object(), role=None), + ) + assert ei.value.status_code == 403 + + @pytest.mark.asyncio + async def test_missing_space_400(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="ghost", config={"access": "read"})], + memory_service=_mem_svc(space=None, role=None), + ) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_bad_access_value_400(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "admin"})], + memory_service=_mem_svc(space=object(), role="owner"), + ) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_bad_alwaysload_400(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[ + AgentBinding(kind="memory_space", ref="spc_1", config={"access": "read", "alwaysLoad": "nope"}) + ], + memory_service=_mem_svc(space=object(), role="viewer"), + ) + assert ei.value.status_code == 400 diff --git a/backend/tests/apis/app_api/test_connectors_routes.py b/backend/tests/apis/app_api/test_connectors_routes.py index b0fe5efb..4b4609ed 100644 --- a/backend/tests/apis/app_api/test_connectors_routes.py +++ b/backend/tests/apis/app_api/test_connectors_routes.py @@ -405,55 +405,57 @@ def test_initiate_consent_does_not_force_when_not_disconnected(self, app_with_de def test_status_matches_initiate_consent_custom_parameters(self, app_with_deps): # AgentCore factors customParameters into whether get_resource_oauth2_token - # short-circuits to a vaulted token. Connector consent always runs through - # initiate_consent, which sends Google `prompt=consent`; a status read that - # omits it is treated as a fresh request and reports consent-required even - # when a usable token is vaulted. So /status MUST send the same - # customParameters initiate_consent uses. + # short-circuits to a vaulted token. Both /initiate-consent and /status + # forward the connector's configured customParameters verbatim, so the + # maps match and a usable vaulted token isn't mistaken for a fresh + # request that reports consent-required. + configured = {"access_type": "offline", "prompt": "consent"} app, identity, _ = app_with_deps( "alice", - provider=_make_provider(), # default is OAuthProviderType.GOOGLE + provider=_make_provider(custom_parameters=configured), identity_result=TokenResult(access_token="vault-token"), ) TestClient(app).get("/connectors/google/status") identity.get_token_for_user.assert_called_once() - assert identity.get_token_for_user.call_args.kwargs["custom_parameters"] == { - "access_type": "offline", - "prompt": "consent", - } + assert ( + identity.get_token_for_user.call_args.kwargs["custom_parameters"] + == configured + ) - def test_initiate_consent_forwards_google_access_type_offline_and_prompt_consent( + def test_initiate_consent_forwards_configured_custom_parameters( self, app_with_deps ): - # initiate_consent is a "walk me through consent" path, so for Google - # we always send `prompt=consent` (in addition to `access_type=offline`). - # Without it, Google sees a previously-consented user, skips the consent - # screen, and re-issues an access_token without a refresh_token β€” + # initiate_consent forwards the connector's configured customParameters + # verbatim. For a Google connector configured with access_type=offline + # + prompt=consent, that's exactly what reaches AgentCore β€” without + # prompt=consent Google skips the consent screen for a previously- + # consented user and re-issues an access token with no refresh token, # putting the user back in the hourly-reconsent loop. + configured = {"access_type": "offline", "prompt": "consent"} app, identity, _ = app_with_deps( "alice", - provider=_make_provider(), + provider=_make_provider(custom_parameters=configured), identity_result=TokenResult(access_token="vault-token"), ) TestClient(app).post("/connectors/google/initiate-consent") identity.get_token_for_user.assert_called_once() - assert identity.get_token_for_user.call_args.kwargs["custom_parameters"] == { - "access_type": "offline", - "prompt": "consent", - } + assert ( + identity.get_token_for_user.call_args.kwargs["custom_parameters"] + == configured + ) - def test_admin_custom_parameters_merge_with_google_baseline(self, app_with_deps): - # Admin set Workspace domain restriction. The route must merge - # admin extras with the hardcoded baseline before forwarding to - # AgentCore β€” and the baseline still wins on key conflict. + def test_custom_parameters_forwarded_verbatim(self, app_with_deps): + # No hardcoded baseline is injected or allowed to override β€” the + # connector's configured params reach AgentCore exactly as the admin + # set them (including access_type=online, if that's their choice). app, identity, _ = app_with_deps( "alice", provider=_make_provider( custom_parameters={ "hd": "mycompany.com", - "access_type": "online", # admin tries to override; ignored + "access_type": "online", }, ), identity_result=TokenResult(access_token="vault-token"), @@ -462,8 +464,7 @@ def test_admin_custom_parameters_merge_with_google_baseline(self, app_with_deps) kwargs = identity.get_token_for_user.call_args.kwargs assert kwargs["custom_parameters"] == { - "access_type": "offline", # baseline wins - "prompt": "consent", # matches the consent flow's customParameters + "access_type": "online", "hd": "mycompany.com", } diff --git a/backend/tests/apis/app_api/test_memory_spaces_routes.py b/backend/tests/apis/app_api/test_memory_spaces_routes.py new file mode 100644 index 00000000..5010d122 --- /dev/null +++ b/backend/tests/apis/app_api/test_memory_spaces_routes.py @@ -0,0 +1,476 @@ +"""Route tests for the Memory Spaces user surface (`/memory/spaces/*`, A2). + +Pins the flag gate (404 while off), CRUD happy paths, and identity-based +access (403 for a non-member, 404 for a missing space). Backed by a REAL +`MemorySpaceService` on moto (DynamoDB + S3), so the routes are exercised +end-to-end through the shared service. +""" + +from __future__ import annotations + +import io +import json +import zipfile + +import boto3 +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from moto import mock_aws + +from apis.shared.auth.dependencies import get_current_user_from_session +from apis.shared.auth.models import User +from apis.shared.memory.repository import MemorySpaceRepository +from apis.shared.memory.service import MemorySpaceService +from apis.shared.memory.store import MemorySpaceStore + +from apis.app_api.memory_spaces import routes as mem_routes + +REGION = "us-east-1" +BUCKET = "test-memory-spaces" +TABLE = "test-memory-spaces" + +OWNER = User(user_id="user-owner", email="owner@example.edu", name="O", roles=["default"]) +STRANGER = User( + user_id="user-stranger", email="stranger@example.edu", name="S", roles=["default"] +) + + +def _make_table(): + ddb = boto3.client("dynamodb", region_name=REGION) + ddb.create_table( + TableName=TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + {"AttributeName": "GSI1PK", "AttributeType": "S"}, + {"AttributeName": "GSI1SK", "AttributeType": "S"}, + {"AttributeName": "GSI2PK", "AttributeType": "S"}, + {"AttributeName": "GSI2SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + GlobalSecondaryIndexes=[ + { + "IndexName": "OwnerIndex", + "KeySchema": [ + {"AttributeName": "GSI1PK", "KeyType": "HASH"}, + {"AttributeName": "GSI1SK", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }, + { + "IndexName": "MemberIndex", + "KeySchema": [ + {"AttributeName": "GSI2PK", "KeyType": "HASH"}, + {"AttributeName": "GSI2SK", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }, + ], + ) + return MemorySpaceRepository(table_name=TABLE) + + +@pytest.fixture() +def env(monkeypatch): + monkeypatch.setenv("AWS_DEFAULT_REGION", REGION) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") + monkeypatch.setenv("MEMORY_SPACES_ENABLED", "true") + with mock_aws(): + yield + + +@pytest.fixture() +def service(env): + repo = _make_table() + s3 = boto3.client("s3", region_name=REGION) + s3.create_bucket(Bucket=BUCKET) + return MemorySpaceService( + repository=repo, store=MemorySpaceStore(bucket_name=BUCKET, s3_client=s3) + ) + + +def _client(service, monkeypatch, user: User = OWNER, authed: bool = True): + monkeypatch.setattr(mem_routes, "_service", service) + app = FastAPI() + app.include_router(mem_routes.router) + if authed: + app.dependency_overrides[get_current_user_from_session] = lambda: user + return TestClient(app, raise_server_exceptions=False) + + +# --------------------------------------------------------------------------- + + +class TestFlagGate: + def test_404_when_flag_off(self, service, monkeypatch): + monkeypatch.setenv("MEMORY_SPACES_ENABLED", "false") + client = _client(service, monkeypatch) + assert client.get("/memory/spaces").status_code == 404 + + +class TestSpaceCrud: + def test_create_list_get(self, service, monkeypatch): + client = _client(service, monkeypatch) + + resp = client.post( + "/memory/spaces", json={"name": "My Brain", "template": "chief-of-staff"} + ) + assert resp.status_code == 201 + body = resp.json() + assert body["name"] == "My Brain" + assert body["role"] == "owner" + space_id = body["spaceId"] + + listed = client.get("/memory/spaces").json() + assert {s["spaceId"] for s in listed["spaces"]} == {space_id} + assert any(t["templateId"] == "chief-of-staff" for t in listed["templates"]) + + detail = client.get(f"/memory/spaces/{space_id}").json() + assert detail["role"] == "owner" + assert "Strategic priorities" in detail["index"] + assert detail["entries"] == [] + + def test_create_rejects_unknown_template(self, service, monkeypatch): + client = _client(service, monkeypatch) + resp = client.post("/memory/spaces", json={"name": "X", "template": "nope"}) + assert resp.status_code == 400 + + def test_create_rejects_blank_name(self, service, monkeypatch): + client = _client(service, monkeypatch) + resp = client.post("/memory/spaces", json={"name": " "}) + assert resp.status_code in (400, 422) + + def test_get_missing_space_404(self, service, monkeypatch): + client = _client(service, monkeypatch) + assert client.get("/memory/spaces/spc_missing").status_code == 404 + + def test_owner_deletes(self, service, monkeypatch): + client = _client(service, monkeypatch) + sid = client.post("/memory/spaces", json={"name": "X"}).json()["spaceId"] + assert client.delete(f"/memory/spaces/{sid}").status_code == 204 + assert client.get(f"/memory/spaces/{sid}").status_code == 404 + + +class TestIndexAndEntries: + def _make_space(self, service, monkeypatch): + client = _client(service, monkeypatch) + sid = client.post("/memory/spaces", json={"name": "X"}).json()["spaceId"] + return client, sid + + def test_index_read_update(self, service, monkeypatch): + client, sid = self._make_space(service, monkeypatch) + r = client.put(f"/memory/spaces/{sid}/index", json={"content": "# New\n"}) + assert r.status_code == 200 + assert client.get(f"/memory/spaces/{sid}/index").json()["content"] == "# New\n" + + def test_entry_upsert_read_list_delete(self, service, monkeypatch): + client, sid = self._make_space(service, monkeypatch) + + up = client.put( + f"/memory/spaces/{sid}/entries/jane-doe", + json={ + "body": "# Jane\nVP Research", + "type": "entity", + "description": "VP Research", + "indexed": {"status": "active"}, + }, + ) + assert up.status_code == 200 + assert up.json()["slug"] == "jane-doe" + assert up.json()["type"] == "entity" + + got = client.get(f"/memory/spaces/{sid}/entries/jane-doe").json() + assert "VP Research" in got["content"] + + listed = client.get(f"/memory/spaces/{sid}/entries").json()["entries"] + assert [e["slug"] for e in listed] == ["jane-doe"] + + typed = client.get(f"/memory/spaces/{sid}/entries?type=fact").json()["entries"] + assert typed == [] + + assert client.delete(f"/memory/spaces/{sid}/entries/jane-doe").status_code == 204 + assert client.get(f"/memory/spaces/{sid}/entries/jane-doe").status_code == 404 + + +class TestAccessControl: + def test_stranger_cannot_read_space(self, service, monkeypatch): + owner_client = _client(service, monkeypatch, user=OWNER) + sid = owner_client.post("/memory/spaces", json={"name": "Private"}).json()[ + "spaceId" + ] + stranger_client = _client(service, monkeypatch, user=STRANGER) + assert stranger_client.get(f"/memory/spaces/{sid}").status_code == 403 + + def test_stranger_cannot_write_entry(self, service, monkeypatch): + owner_client = _client(service, monkeypatch, user=OWNER) + sid = owner_client.post("/memory/spaces", json={"name": "P"}).json()["spaceId"] + stranger_client = _client(service, monkeypatch, user=STRANGER) + r = stranger_client.put( + f"/memory/spaces/{sid}/entries/x", json={"body": "hi"} + ) + assert r.status_code == 403 + + def test_stranger_space_not_in_their_list(self, service, monkeypatch): + owner_client = _client(service, monkeypatch, user=OWNER) + owner_client.post("/memory/spaces", json={"name": "P"}) + stranger_client = _client(service, monkeypatch, user=STRANGER) + assert stranger_client.get("/memory/spaces").json()["spaces"] == [] + + def test_stranger_cannot_export_space(self, service, monkeypatch): + owner_client = _client(service, monkeypatch, user=OWNER) + sid = owner_client.post("/memory/spaces", json={"name": "P"}).json()["spaceId"] + stranger_client = _client(service, monkeypatch, user=STRANGER) + assert stranger_client.get(f"/memory/spaces/{sid}/export").status_code == 403 + + def test_member_leaves_via_delete(self, service, monkeypatch): + owner_client = _client(service, monkeypatch, user=OWNER) + sid = owner_client.post("/memory/spaces", json={"name": "Shared"}).json()[ + "spaceId" + ] + # share directly through the service (sharing endpoints land in A4) + service.share(sid, OWNER.user_id, OWNER.email, STRANGER.email, "viewer") + member_client = _client(service, monkeypatch, user=STRANGER) + listed = member_client.get("/memory/spaces").json()["spaces"] + assert {s["spaceId"] for s in listed} == {sid} + # the list reports the member's actual grant, not a placeholder + assert listed[0]["role"] == "viewer" + # member DELETE = leave, not destroy + assert member_client.delete(f"/memory/spaces/{sid}").status_code == 204 + assert member_client.get(f"/memory/spaces/{sid}").status_code == 403 + # the space still exists for the owner + assert owner_client.get(f"/memory/spaces/{sid}").status_code == 200 + + +class TestSharing: + def _make_space(self, service, monkeypatch): + owner = _client(service, monkeypatch, user=OWNER) + sid = owner.post("/memory/spaces", json={"name": "Shared"}).json()["spaceId"] + return owner, sid + + def test_owner_shares_lists_updates_revokes(self, service, monkeypatch): + owner, sid = self._make_space(service, monkeypatch) + + added = owner.post( + f"/memory/spaces/{sid}/shares", + json={"email": STRANGER.email, "permission": "viewer"}, + ) + assert added.status_code == 201 + assert added.json()["email"] == STRANGER.email + assert added.json()["permission"] == "viewer" + + listed = owner.get(f"/memory/spaces/{sid}/shares").json()["members"] + assert [(m["email"], m["permission"]) for m in listed] == [ + (STRANGER.email, "viewer") + ] + created_at = listed[0]["createdAt"] + + upgraded = owner.patch( + f"/memory/spaces/{sid}/shares/{STRANGER.email}", + json={"permission": "editor"}, + ) + assert upgraded.status_code == 200 + assert upgraded.json()["permission"] == "editor" + # PATCH preserves the original grant timestamp. + assert upgraded.json()["createdAt"] == created_at + + assert ( + owner.delete(f"/memory/spaces/{sid}/shares/{STRANGER.email}").status_code + == 204 + ) + assert owner.get(f"/memory/spaces/{sid}/shares").json()["members"] == [] + + def test_shared_member_gains_access(self, service, monkeypatch): + owner, sid = self._make_space(service, monkeypatch) + owner.post( + f"/memory/spaces/{sid}/shares", + json={"email": STRANGER.email, "permission": "editor"}, + ) + member = _client(service, monkeypatch, user=STRANGER) + # editor can now read and write entries + assert member.get(f"/memory/spaces/{sid}").status_code == 200 + assert ( + member.put( + f"/memory/spaces/{sid}/entries/note", json={"body": "hi"} + ).status_code + == 200 + ) + + def test_non_owner_cannot_share(self, service, monkeypatch): + owner, sid = self._make_space(service, monkeypatch) + owner.post( + f"/memory/spaces/{sid}/shares", + json={"email": STRANGER.email, "permission": "editor"}, + ) + # an editor is not an owner β€” cannot manage grants + member = _client(service, monkeypatch, user=STRANGER) + r = member.post( + f"/memory/spaces/{sid}/shares", + json={"email": "third@example.edu", "permission": "viewer"}, + ) + assert r.status_code == 403 + + def test_viewer_cannot_list_members(self, service, monkeypatch): + owner, sid = self._make_space(service, monkeypatch) + owner.post( + f"/memory/spaces/{sid}/shares", + json={"email": STRANGER.email, "permission": "viewer"}, + ) + member = _client(service, monkeypatch, user=STRANGER) + # listing members requires editor+ + assert member.get(f"/memory/spaces/{sid}/shares").status_code == 403 + + def test_patch_unknown_member_404(self, service, monkeypatch): + owner, sid = self._make_space(service, monkeypatch) + r = owner.patch( + f"/memory/spaces/{sid}/shares/nobody@example.edu", + json={"permission": "editor"}, + ) + assert r.status_code == 404 + + def test_share_rejects_owner_role(self, service, monkeypatch): + owner, sid = self._make_space(service, monkeypatch) + r = owner.post( + f"/memory/spaces/{sid}/shares", + json={"email": STRANGER.email, "permission": "owner"}, + ) + # "owner" is not a grantable ShareRole β†’ 422 at the request model + assert r.status_code == 422 + + +class TestConsolidate: + def test_owner_consolidate_returns_report(self, service, monkeypatch): + client = _client(service, monkeypatch, user=OWNER) + sid = client.post("/memory/spaces", json={"name": "X"}).json()["spaceId"] + client.put(f"/memory/spaces/{sid}/entries/a", json={"body": "one"}) + # leak an orphan object to prove GC runs through the route + service.store.put(space_id=sid, content=b"leaked", content_type="text/markdown") + + resp = client.post(f"/memory/spaces/{sid}/consolidate", json={}) + assert resp.status_code == 200 + body = resp.json() + assert body["spaceId"] == sid + assert body["entryCount"] == 1 + assert body["orphansDeleted"] == 1 + assert body["overCap"] is False + assert body["duplicateGroups"] == [] + + def test_consolidate_no_body(self, service, monkeypatch): + client = _client(service, monkeypatch, user=OWNER) + sid = client.post("/memory/spaces", json={"name": "X"}).json()["spaceId"] + assert client.post(f"/memory/spaces/{sid}/consolidate").status_code == 200 + + def test_viewer_cannot_consolidate(self, service, monkeypatch): + owner = _client(service, monkeypatch, user=OWNER) + sid = owner.post("/memory/spaces", json={"name": "X"}).json()["spaceId"] + service.share(sid, OWNER.user_id, OWNER.email, STRANGER.email, "viewer") + member = _client(service, monkeypatch, user=STRANGER) + assert member.post(f"/memory/spaces/{sid}/consolidate", json={}).status_code == 403 + + def test_consolidate_404_when_flag_off(self, service, monkeypatch): + client = _client(service, monkeypatch, user=OWNER) + sid = client.post("/memory/spaces", json={"name": "X"}).json()["spaceId"] + monkeypatch.setenv("MEMORY_SPACES_ENABLED", "false") + assert client.post(f"/memory/spaces/{sid}/consolidate", json={}).status_code == 404 + + +class TestExport: + def _seed(self, service, monkeypatch): + client = _client(service, monkeypatch, user=OWNER) + sid = client.post( + "/memory/spaces", json={"name": "My Brain", "template": "chief-of-staff"} + ).json()["spaceId"] + client.put( + f"/memory/spaces/{sid}/entries/jane-doe", + json={ + "body": "---\ntype: entity\n---\n# Jane\nVP Research", + "type": "entity", + "description": "VP Research", + }, + ) + client.put( + f"/memory/spaces/{sid}/entries/q3-goal", + json={"body": "# Q3\nShip memory spaces", "type": "fact"}, + ) + return client, sid + + def _open_zip(self, resp) -> zipfile.ZipFile: + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/zip" + assert 'filename="My-Brain.zip"' in resp.headers["content-disposition"] + return zipfile.ZipFile(io.BytesIO(resp.content)) + + def test_owner_export_layout_and_contents(self, service, monkeypatch): + client, sid = self._seed(service, monkeypatch) + zf = self._open_zip(client.get(f"/memory/spaces/{sid}/export")) + + names = set(zf.namelist()) + assert "My-Brain/MEMORY.md" in names + assert "My-Brain/entries/entity/jane-doe.md" in names + assert "My-Brain/entries/fact/q3-goal.md" in names + assert "My-Brain/metadata.json" in names + + # Entry bytes are verbatim, frontmatter intact. + assert b"VP Research" in zf.read("My-Brain/entries/entity/jane-doe.md") + assert b"type: entity" in zf.read("My-Brain/entries/entity/jane-doe.md") + # The index text is the seeded template's MEMORY.md. + assert b"Strategic priorities" in zf.read("My-Brain/MEMORY.md") + + meta = json.loads(zf.read("My-Brain/metadata.json")) + assert meta["spaceId"] == sid + assert meta["name"] == "My Brain" + assert meta["template"] == "chief-of-staff" + assert meta["entryCount"] == 2 + assert meta["owner"]["email"] == OWNER.email + assert meta["exportedAt"] + + def test_owner_metadata_lists_members(self, service, monkeypatch): + client, sid = self._seed(service, monkeypatch) + service.share(sid, OWNER.user_id, OWNER.email, STRANGER.email, "viewer") + zf = self._open_zip(client.get(f"/memory/spaces/{sid}/export")) + meta = json.loads(zf.read("My-Brain/metadata.json")) + assert meta["members"] == [ + {"email": STRANGER.email, "permission": "viewer", "createdAt": meta["members"][0]["createdAt"]} + ] + + def test_viewer_can_export_without_member_list(self, service, monkeypatch): + owner_client, sid = self._seed(service, monkeypatch) + service.share(sid, OWNER.user_id, OWNER.email, STRANGER.email, "viewer") + viewer_client = _client(service, monkeypatch, user=STRANGER) + resp = viewer_client.get(f"/memory/spaces/{sid}/export") + assert resp.status_code == 200 + zf = zipfile.ZipFile(io.BytesIO(resp.content)) + meta = json.loads(zf.read("My-Brain/metadata.json")) + # A viewer exports the corpus but not the grant list (list_members gate). + assert meta["members"] == [] + assert meta["entryCount"] == 2 + + def test_export_missing_space_404(self, service, monkeypatch): + client = _client(service, monkeypatch, user=OWNER) + assert client.get("/memory/spaces/spc_missing/export").status_code == 404 + + def test_export_404_when_flag_off(self, service, monkeypatch): + client, sid = self._seed(service, monkeypatch) + monkeypatch.setenv("MEMORY_SPACES_ENABLED", "false") + assert client.get(f"/memory/spaces/{sid}/export").status_code == 404 + + def test_export_sanitizes_hostile_slug(self, service, monkeypatch): + client = _client(service, monkeypatch, user=OWNER) + sid = client.post("/memory/spaces", json={"name": "X"}).json()["spaceId"] + # Seed a traversal slug directly through the service (the route path + # converter would never carry one) β€” the export must not let it escape + # its archive folder (zip-slip). + service.write_entry( + sid, OWNER.user_id, OWNER.email, "../../evil", "nope", entry_type="fact" + ) + resp = client.get(f"/memory/spaces/{sid}/export") + assert resp.status_code == 200 + zf = zipfile.ZipFile(io.BytesIO(resp.content)) + assert all(not n.startswith("..") and "/../" not in n for n in zf.namelist()) + assert "X/entries/fact/evil.md" in zf.namelist() diff --git a/backend/tests/apis/app_api/test_runs_routes.py b/backend/tests/apis/app_api/test_runs_routes.py new file mode 100644 index 00000000..0bba6bc4 --- /dev/null +++ b/backend/tests/apis/app_api/test_runs_routes.py @@ -0,0 +1,449 @@ +"""Route tests for the headless "Run now" surface (`/runs/*`). + +Pins the three-layer gate (cookie auth β†’ SCHEDULED_RUNS_ENABLED kill switch +β†’ `scheduled-runs` RBAC capability), the create-on-enable grant flow, and +the RunResult β†’ camelCase response mapping. +""" + +from __future__ import annotations + +import time +from typing import Optional + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from apis.shared.auth.dependencies import get_current_user_from_session +from apis.shared.auth.models import User +from apis.shared.harness.auth import HeadlessAuthError +from apis.shared.harness.grants import HeadlessGrant +from apis.shared.harness.models import RunResult, ToolTraceEntry +from apis.shared.tools.scoped_ids import base_tool_id + +from apis.app_api.runs import routes as runs_routes + +NOW = int(time.time()) + + +class FakeRoleService: + """Grants a fixed tool set; mirrors filter_requested_tools' contract.""" + + def __init__(self, tools: Optional[list[str]] = None): + self.tools = tools if tools is not None else ["class_search", "web_search"] + + async def filter_requested_tools(self, user, requested): + allowed = set(self.tools) + if "*" in allowed: + return list(requested) + return [t for t in requested if t in allowed or base_tool_id(t) in allowed] + + +def _user() -> User: + return User( + user_id="user-1", + email="user@example.com", + name="User", + roles=["default"], + raw_token="tok", + ) + + +def _grant(user_id: str = "user-1") -> HeadlessGrant: + return HeadlessGrant( + grant_id="hlg-abc", + user_id=user_id, + username="user1", + cognito_refresh_token="rt-stored", + status="active", + created_at=NOW - 100, + updated_at=NOW - 100, + token_issued_at=NOW - 100, + ttl=NOW + 1000, + last_used_at=NOW - 50, + ) + + +class FakeGrantService: + def __init__(self, grant: Optional[HeadlessGrant] = None): + self.grant = grant + self.enable_calls: list[dict] = [] + self.revoke_calls: list[str] = [] + + async def get_active_grant(self, user_id: str): + return self.grant + + async def enable(self, *, user_id, username, refresh_token, token_issued_at=None): + self.enable_calls.append( + { + "user_id": user_id, + "username": username, + "refresh_token": refresh_token, + "token_issued_at": token_issued_at, + } + ) + self.grant = _grant(user_id) + return self.grant + + async def revoke(self, user_id: str) -> bool: + self.revoke_calls.append(user_id) + revoked = self.grant is not None + self.grant = None + return revoked + + +class FakeSessionRecord: + """Just the SessionRecord fields the route reads.""" + + username = "user1" + cognito_refresh_token = "rt-live" + created_at = NOW - 3600 + + +def _completed_result() -> RunResult: + return RunResult( + run_id="run-1", + session_id="headless-1", + user_id="user-1", + status="completed", + final_message="pong", + stop_reason="end_turn", + title="T", + tool_trace=[ + ToolTraceEntry( + tool_use_id="t1", + name="search_classes", + input={"subject": "COMM"}, + result_preview="ok", + ) + ], + usage={"usage": {"totalTokens": 6}}, + started_at="2026-07-05T00:00:00Z", + finished_at="2026-07-05T00:00:10Z", + ) + + +def _make_client( + monkeypatch: pytest.MonkeyPatch, + *, + authed: bool = True, + capability: bool = True, + flag: Optional[str] = None, + grants: Optional[FakeGrantService] = None, + with_session_record: bool = False, + run_result: Optional[RunResult] = None, + run_error: Optional[Exception] = None, + role_tools: Optional[list[str]] = None, +) -> tuple[TestClient, FakeGrantService, list[dict]]: + monkeypatch.delenv("SKIP_AUTH", raising=False) + if flag is None: + monkeypatch.delenv("SCHEDULED_RUNS_ENABLED", raising=False) + else: + monkeypatch.setenv("SCHEDULED_RUNS_ENABLED", flag) + + async def fake_capability(user, capability_id): + assert capability_id == "scheduled-runs" + return capability + + monkeypatch.setattr(runs_routes, "user_has_capability", fake_capability) + + grants = grants or FakeGrantService() + monkeypatch.setattr(runs_routes, "get_headless_grant_service", lambda: grants) + monkeypatch.setattr(runs_routes, "get_app_role_service", lambda: FakeRoleService(role_tools)) + + run_calls: list[dict] = [] + + async def fake_run(**kwargs): + run_calls.append(kwargs) + if run_error is not None: + raise run_error + return run_result or _completed_result() + + monkeypatch.setattr(runs_routes, "run_agent_headless", fake_run) + + app = FastAPI() + + if with_session_record: + @app.middleware("http") + async def attach_session(request: Request, call_next): + request.state.bff_session = FakeSessionRecord() + return await call_next(request) + + app.include_router(runs_routes.router) + if authed: + app.dependency_overrides[get_current_user_from_session] = _user + client = TestClient(app, raise_server_exceptions=False) + return client, grants, run_calls + + +# --------------------------------------------------------------------------- +# scheduled_runs_enabled() helper β€” default ON with a kill switch +# --------------------------------------------------------------------------- + + +class TestScheduledRunsFlag: + @pytest.mark.parametrize( + "value, expected", + [ + (None, True), # unset β†’ default on + ("", True), # empty workflow var β†’ default on + ("true", True), + ("false", False), + ("FALSE", False), + ("0", True), # only the literal "false" disables + ], + ) + def test_parses_env_value(self, monkeypatch, value, expected): + from apis.shared.feature_flags import scheduled_runs_enabled + + if value is None: + monkeypatch.delenv("SCHEDULED_RUNS_ENABLED", raising=False) + else: + monkeypatch.setenv("SCHEDULED_RUNS_ENABLED", value) + assert scheduled_runs_enabled() is expected + + +# --------------------------------------------------------------------------- +# Gating +# --------------------------------------------------------------------------- + + +class TestGating: + def test_unauthenticated_request_is_401(self, monkeypatch): + client, _, _ = _make_client(monkeypatch, authed=False) + assert client.post("/runs/now", json={"prompt": "hi"}).status_code == 401 + + def test_kill_switch_off_hides_the_surface_as_404(self, monkeypatch): + client, _, _ = _make_client(monkeypatch, flag="false") + assert client.post("/runs/now", json={"prompt": "hi"}).status_code == 404 + assert client.get("/runs/grant").status_code == 404 + assert client.delete("/runs/grant").status_code == 404 + + def test_flag_defaults_on_when_unset(self, monkeypatch): + client, _, _ = _make_client(monkeypatch, with_session_record=True) + assert client.post("/runs/now", json={"prompt": "hi"}).status_code == 200 + + def test_empty_flag_value_stays_on(self, monkeypatch): + # `${{ vars.* }}` renders "" when unset β€” must resolve to the default. + client, _, _ = _make_client(monkeypatch, flag="", with_session_record=True) + assert client.post("/runs/now", json={"prompt": "hi"}).status_code == 200 + + def test_missing_capability_is_403(self, monkeypatch): + client, _, _ = _make_client(monkeypatch, capability=False) + response = client.post("/runs/now", json={"prompt": "hi"}) + assert response.status_code == 403 + assert client.get("/runs/grant").status_code == 403 + + +# --------------------------------------------------------------------------- +# POST /runs/now +# --------------------------------------------------------------------------- + + +class TestRunNow: + def test_happy_path_maps_run_result_to_camel_case(self, monkeypatch): + client, grants, run_calls = _make_client( + monkeypatch, with_session_record=True + ) + + response = client.post( + "/runs/now", + json={ + "prompt": "ping", + "title": "My Briefing", + "enabledTools": ["class_search"], + "agentType": "chat", + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["runId"] == "run-1" + assert body["sessionId"] == "headless-1" + assert body["status"] == "completed" + assert body["finalMessage"] == "pong" + assert body["stopReason"] == "end_turn" + assert body["toolTrace"] == [ + { + "toolUseId": "t1", + "name": "search_classes", + "input": {"subject": "COMM"}, + "resultPreview": "ok", + "isError": False, + } + ] + assert body["usage"]["usage"]["totalTokens"] == 6 + + (call,) = run_calls + assert call["user_id"] == "user-1" + assert call["prompt"] == "ping" + assert call["title"] == "My Briefing" + assert call["enabled_tools"] == ["class_search"] + assert call["agent_type"] == "chat" + assert call["trigger"] == "run_now" + + def test_enabled_tools_intersected_with_rbac_before_harness(self, monkeypatch): + """A crafted body cannot enable a tool outside the caller's grant. + + FakeRoleService grants {class_search, web_search}; the ungranted + ``gmail_search`` must be stripped before the harness (and thus the + RBAC-blind tool filter) ever sees it. + """ + client, _, run_calls = _make_client(monkeypatch, with_session_record=True) + + response = client.post( + "/runs/now", + json={"prompt": "ping", "enabledTools": ["class_search", "gmail_search"]}, + ) + + assert response.status_code == 200 + (call,) = run_calls + assert call["enabled_tools"] == ["class_search"] + + def test_none_enabled_tools_passes_through_as_defaults(self, monkeypatch): + """Omitting enabled_tools stays None β†’ harness resolves user defaults.""" + client, _, run_calls = _make_client(monkeypatch, with_session_record=True) + + assert client.post("/runs/now", json={"prompt": "ping"}).status_code == 200 + + (call,) = run_calls + assert call["enabled_tools"] is None + + def test_create_on_enable_pins_the_live_session_token(self, monkeypatch): + client, grants, _ = _make_client(monkeypatch, with_session_record=True) + + client.post("/runs/now", json={"prompt": "ping"}) + + (enable,) = grants.enable_calls + assert enable["user_id"] == "user-1" + assert enable["username"] == "user1" + assert enable["refresh_token"] == "rt-live" + # The session's login instant anchors the 30-day recency window. + assert enable["token_issued_at"] == NOW - 3600 + + def test_existing_grant_works_without_a_session_record(self, monkeypatch): + grants = FakeGrantService(grant=_grant()) + client, grants, _ = _make_client(monkeypatch, grants=grants) + + assert client.post("/runs/now", json={"prompt": "ping"}).status_code == 200 + assert grants.enable_calls == [] # no session to re-pin from + + def test_no_grant_and_no_session_is_409(self, monkeypatch): + client, _, run_calls = _make_client(monkeypatch) + + response = client.post("/runs/now", json={"prompt": "ping"}) + + assert response.status_code == 409 + assert run_calls == [] # never reached the harness + + def test_mint_failure_is_409_not_401(self, monkeypatch): + # 401 would bounce the SPA through the login redirect; the *session* + # is fine β€” only the headless credential is dead. + client, _, _ = _make_client( + monkeypatch, + with_session_record=True, + run_error=HeadlessAuthError("cognito refused"), + ) + + assert client.post("/runs/now", json={"prompt": "ping"}).status_code == 409 + + def test_empty_prompt_is_422(self, monkeypatch): + client, _, _ = _make_client(monkeypatch, with_session_record=True) + assert client.post("/runs/now", json={"prompt": ""}).status_code == 422 + + +# --------------------------------------------------------------------------- +# Grant lifecycle routes +# --------------------------------------------------------------------------- + + +class TestEnableGrant: + """POST /runs/grant β€” shares `_resolve_grant` with /runs/now, so this + pins the same create-on-enable behavior via a distinct entrypoint that + doesn't require running a prompt first.""" + + def test_creates_grant_from_live_session(self, monkeypatch): + client, grants, _ = _make_client(monkeypatch, with_session_record=True) + + response = client.post("/runs/grant") + + assert response.status_code == 200 + body = response.json() + assert body["enabled"] is True + assert body["grantId"] == "hlg-abc" + assert "rt-stored" not in str(body) + + (enable,) = grants.enable_calls + assert enable["user_id"] == "user-1" + assert enable["username"] == "user1" + assert enable["refresh_token"] == "rt-live" + assert enable["token_issued_at"] == NOW - 3600 + + def test_refreshes_an_existing_grant_from_a_new_session(self, monkeypatch): + grants = FakeGrantService(grant=_grant()) + client, grants, _ = _make_client( + monkeypatch, grants=grants, with_session_record=True + ) + + response = client.post("/runs/grant") + + assert response.status_code == 200 + assert len(grants.enable_calls) == 1 # re-pinned, not skipped + + def test_existing_grant_without_a_session_record_is_reused(self, monkeypatch): + grants = FakeGrantService(grant=_grant()) + client, grants, _ = _make_client(monkeypatch, grants=grants) + + response = client.post("/runs/grant") + + assert response.status_code == 200 + assert response.json()["grantId"] == "hlg-abc" + assert grants.enable_calls == [] + + def test_no_grant_and_no_session_is_409(self, monkeypatch): + client, _, _ = _make_client(monkeypatch) + + assert client.post("/runs/grant").status_code == 409 + + def test_kill_switch_off_hides_the_surface_as_404(self, monkeypatch): + client, _, _ = _make_client(monkeypatch, flag="false") + assert client.post("/runs/grant").status_code == 404 + + def test_missing_capability_is_403(self, monkeypatch): + client, _, _ = _make_client(monkeypatch, capability=False) + assert client.post("/runs/grant").status_code == 403 + + def test_unauthenticated_request_is_401(self, monkeypatch): + client, _, _ = _make_client(monkeypatch, authed=False) + assert client.post("/runs/grant").status_code == 401 + + +class TestGrantRoutes: + def test_grant_status_when_enabled(self, monkeypatch): + client, _, _ = _make_client(monkeypatch, grants=FakeGrantService(_grant())) + + body = client.get("/runs/grant").json() + + assert body["enabled"] is True + assert body["grantId"] == "hlg-abc" + assert body["expiresAt"] == NOW + 1000 + # The stored credential itself must never surface. + assert "rt-stored" not in str(body) + + def test_grant_status_when_absent(self, monkeypatch): + client, _, _ = _make_client(monkeypatch) + assert client.get("/runs/grant").json() == { + "enabled": False, + "grantId": None, + "createdAt": None, + "updatedAt": None, + "expiresAt": None, + "lastUsedAt": None, + } + + def test_revoke_grant(self, monkeypatch): + client, grants, _ = _make_client(monkeypatch, grants=FakeGrantService(_grant())) + + assert client.delete("/runs/grant").json() == {"revoked": True} + assert grants.revoke_calls == ["user-1"] + assert client.delete("/runs/grant").json() == {"revoked": False} diff --git a/backend/tests/apis/app_api/web_sources/test_crawl_repository.py b/backend/tests/apis/app_api/web_sources/test_crawl_repository.py index 5ae0a6d7..d9dc205c 100644 --- a/backend/tests/apis/app_api/web_sources/test_crawl_repository.py +++ b/backend/tests/apis/app_api/web_sources/test_crawl_repository.py @@ -362,3 +362,49 @@ async def test_cascade_leaves_running_crawl_alone(ddb) -> None: await _cascade_delete_orphaned_crawl_jobs(ASSISTANT_ID) assert await crawl_repository.get_crawl_job(ASSISTANT_ID, crawl.crawl_id) is not None + + +@pytest.mark.asyncio +async def test_restore_crawl_ttl_reapplies_expiry_to_sync_covered_job(ddb) -> None: + """Deleting a crawl's sync policy puts the job back on 30-day auto-expiry.""" + job = await crawl_repository.create_crawl_job( + assistant_id=ASSISTANT_ID, + root_url="https://example.com/", + settings=CrawlSettings(), + started_by_user_id=USER_ID, + ) + # Sync path: finalized with the ttl attribute removed + await crawl_repository.finalize_crawl( + assistant_id=ASSISTANT_ID, crawl_id=job.crawl_id, status="complete", set_ttl=False + ) + item = ddb.get_item(Key={"PK": f"AST#{ASSISTANT_ID}", "SK": f"CRAWL#{job.crawl_id}"})["Item"] + assert "ttl" not in item + + before = int(time.time()) + await crawl_repository.restore_crawl_ttl(assistant_id=ASSISTANT_ID, crawl_id=job.crawl_id) + + item = ddb.get_item(Key={"PK": f"AST#{ASSISTANT_ID}", "SK": f"CRAWL#{job.crawl_id}"})["Item"] + thirty_days = 30 * 86400 + assert before + thirty_days - 5 <= int(item["ttl"]) <= int(time.time()) + thirty_days + 5 + + +@pytest.mark.asyncio +async def test_restore_crawl_ttl_leaves_running_job_alone(ddb) -> None: + """finalize_crawl owns the runningβ†’terminal transition; restore must not race it.""" + job = await crawl_repository.create_crawl_job( + assistant_id=ASSISTANT_ID, + root_url="https://example.com/", + settings=CrawlSettings(), + started_by_user_id=USER_ID, + ) + + await crawl_repository.restore_crawl_ttl(assistant_id=ASSISTANT_ID, crawl_id=job.crawl_id) + + item = ddb.get_item(Key={"PK": f"AST#{ASSISTANT_ID}", "SK": f"CRAWL#{job.crawl_id}"})["Item"] + assert "ttl" not in item + assert item["status"] == "running" + + +@pytest.mark.asyncio +async def test_restore_crawl_ttl_missing_job_is_noop(ddb) -> None: + await crawl_repository.restore_crawl_ttl(assistant_id=ASSISTANT_ID, crawl_id="crawl-gone") diff --git a/backend/tests/apis/app_api/web_sources/test_crawler.py b/backend/tests/apis/app_api/web_sources/test_crawler.py index af72eb51..3501c45d 100644 --- a/backend/tests/apis/app_api/web_sources/test_crawler.py +++ b/backend/tests/apis/app_api/web_sources/test_crawler.py @@ -131,10 +131,12 @@ async def fake_increment( rec.failed_delta += failed_delta async def fake_finalize( - *, assistant_id: str, crawl_id: str, status: str, error: str | None = None + *, assistant_id: str, crawl_id: str, status: str, error: str | None = None, + set_ttl: bool = True, ): rec.finalized_status = status rec.finalized_error = error + rec.finalized_set_ttl = set_ttl # Crawler module reaches via the import names it owns. Patch each one # on the crawler module so the BFS reroutes uniformly. @@ -404,7 +406,7 @@ async def test_finalize_runs_on_exception(monkeypatch: pytest.MonkeyPatch): """Even if the inner loop crashes, the CrawlJob must not stay 'running'.""" finalized: List[str] = [] - async def fake_finalize(*, assistant_id, crawl_id, status, error=None): + async def fake_finalize(*, assistant_id, crawl_id, status, error=None, set_ttl=True): finalized.append(status) async def fake_increment(**kwargs): @@ -462,3 +464,189 @@ async def test_visited_dedupes_repeated_links(recorder: _Recorder): ) # Only one extra doc despite three duplicate 's. assert len(recorder.created_docs) == 1 + + +# ── KB-sync refresh mode ──────────────────────────────────────────────────── + + +def _build_refresh_handler(pages: Dict[str, str], etags: Dict[str, str] | None = None): + """Like _build_handler, but ETag-aware: a request whose If-None-Match + matches the page's etag gets a 304 with no body.""" + etags = etags or {} + + def _handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if url.endswith("/robots.txt") and url not in pages: + return httpx.Response(404, text="") + if url not in pages: + return httpx.Response(404, text="not found") + etag = etags.get(url) + if etag and request.headers.get("If-None-Match") == etag: + return httpx.Response(304) + headers = {"content-type": "text/html; charset=utf-8"} + if etag: + headers["etag"] = etag + return httpx.Response(200, text=pages[url], headers=headers) + + return _handler + + +class _ResultLog: + """Captures RefreshState.on_result emissions with staging order info.""" + + def __init__(self, rec: _Recorder): + self.rec = rec + self.events: List[Tuple[str, str, int]] = [] # (outcome, url, s3_puts_at_emit) + + async def __call__(self, url, document_id, outcome, etag, content_hash): + self.events.append((outcome, url, len(self.rec.s3_puts))) + + +def _refresh_state(rec: _Recorder, docs: Dict[str, crawler.RefreshDoc]): + log = _ResultLog(rec) + state = crawler.RefreshState(docs=docs, on_result=log) + return state, log + + +ROOT = "https://example.com/" + + +async def _run_refresh(rec: _Recorder, pages, state, *, etags=None, settings=None): + await run_crawl( + assistant_id="ast-1", + crawl_id="CRAWL-1", + user_id="user-1", + root_url=ROOT, + settings=settings or CrawlSettings(max_depth=1, max_pages=10), + root_document_id="DOC-root", + http_client_factory=_client_factory(_build_refresh_handler(pages, etags)), + refresh=state, + finalize_with_ttl=False, + ) + + +@pytest.mark.asyncio +async def test_refresh_304_skips_stage_and_counts_unchanged(recorder: _Recorder): + pages = {ROOT: "hi"} + etags = {ROOT: '"v1"'} + state, log = _refresh_state( + recorder, {ROOT: crawler.RefreshDoc(document_id="DOC-root", source_etag='"v1"')} + ) + + await _run_refresh(recorder, pages, state, etags=etags) + + assert state.unchanged == 1 and state.changed == 0 and state.created == 0 + assert recorder.s3_puts == [] + assert recorder.created_docs == [] + assert ROOT in state.seen_urls + assert recorder.finalized_set_ttl is False + + +@pytest.mark.asyncio +async def test_refresh_identical_hash_skips_stage(recorder: _Recorder): + html = "T

same content

" + markdown, _ = crawler._extract_markdown(html, ROOT) + stored_hash = crawler._content_sha256(markdown) + state, log = _refresh_state( + recorder, + {ROOT: crawler.RefreshDoc(document_id="DOC-root", source_etag='"old"', content_hash=stored_hash)}, + ) + + # Server serves a NEW etag (so the 304 gate misses) but identical content. + await _run_refresh(recorder, {ROOT: html}, state, etags={ROOT: '"new"'}) + + assert state.unchanged == 1 + assert recorder.s3_puts == [] + # etag advanced via on_result so next run's conditional GET can 304 + assert log.events == [("unchanged", ROOT, 0)] + + +@pytest.mark.asyncio +async def test_refresh_changed_page_restages_and_emits_before_put(recorder: _Recorder): + state, log = _refresh_state( + recorder, + {ROOT: crawler.RefreshDoc(document_id="DOC-root", source_etag='"old"', content_hash="different", chunk_count=5)}, + ) + + await _run_refresh( + recorder, {ROOT: "

brand new words

"}, state, etags={ROOT: '"new"'} + ) + + assert state.changed == 1 + assert len(recorder.s3_puts) == 1 + # "changed" emitted BEFORE staging so the worker can stash the previous + # chunk count before the S3 event fires ingestion + assert log.events[0][0] == "changed" and log.events[0][2] == 0 + + +@pytest.mark.asyncio +async def test_refresh_reuses_existing_docs_and_creates_new_ones(recorder: _Recorder): + pages = { + ROOT: '
AN', + "https://example.com/a": "a-page", + "https://example.com/new": "new-page", + } + state, log = _refresh_state( + recorder, + { + ROOT: crawler.RefreshDoc(document_id="DOC-root", content_hash="stale"), + "https://example.com/a": crawler.RefreshDoc(document_id="DOC-a", content_hash="stale"), + }, + ) + + await _run_refresh(recorder, pages, state) + + # Known URLs reuse their records; only /new creates a document. + assert [u for _id, u in recorder.created_docs] == ["https://example.com/new"] + assert state.created == 1 and state.changed == 2 + assert state.seen_urls == {ROOT, "https://example.com/a", "https://example.com/new"} + + +@pytest.mark.asyncio +async def test_refresh_fetch_error_leaves_existing_doc_untouched(recorder: _Recorder): + pages = {ROOT: "ok", "https://example.com/broken": "irrelevant"} + state, _ = _refresh_state( + recorder, + { + ROOT: crawler.RefreshDoc(document_id="DOC-root", content_hash="stale"), + "https://example.com/broken": crawler.RefreshDoc(document_id="DOC-broken", content_hash="x"), + }, + ) + pages_with_link = dict(pages) + pages_with_link[ROOT] = 'b' + + await run_crawl( + assistant_id="ast-1", + crawl_id="CRAWL-1", + user_id="user-1", + root_url=ROOT, + settings=CrawlSettings(max_depth=1, max_pages=10), + root_document_id="DOC-root", + http_client_factory=_client_factory( + _build_handler(pages_with_link, status_codes={"https://example.com/broken": 500}) + ), + refresh=state, + finalize_with_ttl=False, + ) + + # The existing doc must NOT be flipped to failed on a transient error… + assert ("DOC-broken", "failed") not in recorder.status_updates + # …and it still counts as seen (an erroring page is not a missing page). + assert "https://example.com/broken" in state.seen_urls + + +@pytest.mark.asyncio +async def test_refresh_robots_disallow_is_not_seen(recorder: _Recorder): + pages = { + "https://example.com/robots.txt": "User-agent: *\nDisallow: /", + ROOT: "hi", + } + state, _ = _refresh_state( + recorder, {ROOT: crawler.RefreshDoc(document_id="DOC-root", content_hash="h")} + ) + + await _run_refresh(recorder, pages, state) + + # Robots said stop indexing: the URL is deliberately NOT seen, so the + # worker's miss counter starts ticking toward removal. + assert state.seen_urls == set() diff --git a/backend/tests/apis/app_api/web_sources/test_routes.py b/backend/tests/apis/app_api/web_sources/test_routes.py index fbb13989..58832101 100644 --- a/backend/tests/apis/app_api/web_sources/test_routes.py +++ b/backend/tests/apis/app_api/web_sources/test_routes.py @@ -193,6 +193,27 @@ def test_returns_active_jobs(self, app: FastAPI): assert len(body["crawls"]) == 1 assert body["crawls"][0]["crawlId"] == "CRAWL-1" + def test_returns_all_jobs_without_active_filter(self, app: FastAPI): + """Default (no ?active=true) is full history β€” the sync-policy UI + lists completed crawls as syncable sources.""" + mock_auth_user(app, _user()) + crawl = _stub_crawl() + with patch( + "apis.app_api.web_sources.routes.get_assistant", + new_callable=AsyncMock, + return_value={"assistantId": ASSISTANT_ID}, + ), patch( + "apis.app_api.web_sources.routes.list_all_crawls", + new_callable=AsyncMock, + return_value=[crawl], + ) as mock_all: + client = TestClient(app) + resp = client.get(f"/assistants/{ASSISTANT_ID}/web-sources/crawls") + assert resp.status_code == 200 + body = resp.json() + assert len(body["crawls"]) == 1 + mock_all.assert_awaited_once_with(ASSISTANT_ID) + class TestGetCrawl: def test_returns_single_crawl(self, app: FastAPI): diff --git a/backend/tests/apis/inference_api/test_agent_binding_resolver.py b/backend/tests/apis/inference_api/test_agent_binding_resolver.py new file mode 100644 index 00000000..bb84e482 --- /dev/null +++ b/backend/tests/apis/inference_api/test_agent_binding_resolver.py @@ -0,0 +1,237 @@ +"""Agent Designer Phase 3 (PR-A) β€” run-time model resolution + D5 block. + +The resolver re-checks the Agent's modelConfig against the INVOKING user, reusing the +harness's AppRoleService.can_access_model gate (mocked here). Verifies: pinned+allowed β†’ +model_override; pinned+denied β†’ block; no modelConfig β†’ empty plan (today's behavior). +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from apis.inference_api.chat.agent_binding_resolver import ( + AgentBindingBlockedError, + resolve_agent_invocation, +) +from apis.shared.assistants.models import AgentBinding, AgentModelConfig, Assistant +from apis.shared.auth.models import User + +MODULE = "apis.inference_api.chat.agent_binding_resolver" + + +def _user() -> User: + return User(email="bob@x.edu", user_id="u-bob", name="Bob", roles=[]) + + +def _assistant(model_settings=None, bindings=None) -> Assistant: + return Assistant( + assistantId="ast-1", + ownerId="u-alice", + ownerName="Alice", + name="Oliver", + description="d", + instructions="i", + vectorIndexId="idx", + visibility="SHARED", + createdAt="t", + updatedAt="t", + status="COMPLETE", + model_settings=model_settings, + bindings=bindings, + ) + + +def _patch_memory(monkeypatch, *, enabled=True, space=None, role=None): + monkeypatch.setattr(f"{MODULE}.memory_spaces_enabled", lambda: enabled) + svc = MagicMock() + svc.resolve_permission = MagicMock(return_value=(space, role)) + monkeypatch.setattr(f"{MODULE}.MemorySpaceService", lambda: svc) + return svc + + +def _mem_binding(access="read", ref="spc_1", always_load=None): + config = {"access": access} + if always_load is not None: + config["alwaysLoad"] = always_load + return AgentBinding(kind="memory_space", ref=ref, config=config) + + +def _patch_access(monkeypatch, allowed: bool) -> MagicMock: + svc = MagicMock() + svc.can_access_model = AsyncMock(return_value=allowed) + monkeypatch.setattr(f"{MODULE}.get_app_role_service", lambda: svc) + return svc + + +def _patch_tool_access(monkeypatch, allowed) -> MagicMock: + """Patch the AppRole gate for tool resolution. ``allowed`` is a bool (uniform answer) + or a set of tool ids the invoker may access.""" + svc = MagicMock() + if isinstance(allowed, bool): + svc.can_access_tool = AsyncMock(return_value=allowed) + else: + svc.can_access_tool = AsyncMock(side_effect=lambda user, tid: tid in allowed) + monkeypatch.setattr(f"{MODULE}.get_app_role_service", lambda: svc) + return svc + + +def _tool_binding(ref: str) -> AgentBinding: + return AgentBinding(kind="tool", ref=ref) + + +class TestModelResolution: + @pytest.mark.asyncio + async def test_no_modelconfig_is_empty_plan(self, monkeypatch): + svc = _patch_access(monkeypatch, True) + plan = await resolve_agent_invocation(_assistant(model_settings=None), _user()) + assert plan.model_override is None + # No modelConfig β‡’ we must not even consult model RBAC (today's behavior). + svc.can_access_model.assert_not_awaited() + + @pytest.mark.asyncio + async def test_allowed_model_sets_override(self, monkeypatch): + _patch_access(monkeypatch, True) + cfg = AgentModelConfig(model_id="us.anthropic.opus", provider="bedrock", params={"temperature": 0.5}) + plan = await resolve_agent_invocation(_assistant(model_settings=cfg), _user()) + assert plan.model_override.model_id == "us.anthropic.opus" + assert plan.model_override.provider == "bedrock" + assert plan.model_override.params == {"temperature": 0.5} + + @pytest.mark.asyncio + async def test_denied_model_blocks_with_message(self, monkeypatch): + svc = _patch_access(monkeypatch, False) + cfg = AgentModelConfig(model_id="us.anthropic.opus") + with pytest.raises(AgentBindingBlockedError) as ei: + await resolve_agent_invocation(_assistant(model_settings=cfg), _user()) + # The block message names the model and is invoker-facing markdown (D5). + assert "us.anthropic.opus" in ei.value.message + # Checked against the INVOKING user, not the author. + assert svc.can_access_model.await_args.args[0].user_id == "u-bob" + + @pytest.mark.asyncio + async def test_legacy_assistant_never_blocks(self, monkeypatch): + # A legacy row (no model_settings) resolves to an empty plan regardless. + _patch_access(monkeypatch, False) + plan = await resolve_agent_invocation(_assistant(model_settings=None), _user()) + assert plan.model_override is None + + +class TestMemoryResolution: + _SPACE = SimpleNamespace(name="Oliver's Brain", space_id="spc_1") + + @pytest.mark.asyncio + async def test_no_binding_is_none(self, monkeypatch): + svc = _patch_memory(monkeypatch) + plan = await resolve_agent_invocation(_assistant(bindings=[]), _user()) + assert plan.memory is None + svc.resolve_permission.assert_not_called() + + @pytest.mark.asyncio + async def test_flag_off_blocks(self, monkeypatch): + _patch_memory(monkeypatch, enabled=False) + with pytest.raises(AgentBindingBlockedError): + await resolve_agent_invocation(_assistant(bindings=[_mem_binding()]), _user()) + + @pytest.mark.asyncio + async def test_missing_space_blocks(self, monkeypatch): + _patch_memory(monkeypatch, space=None, role=None) + with pytest.raises(AgentBindingBlockedError) as ei: + await resolve_agent_invocation(_assistant(bindings=[_mem_binding()]), _user()) + assert "no longer exists" in ei.value.message + + @pytest.mark.asyncio + async def test_read_viewer_resolves(self, monkeypatch): + _patch_memory(monkeypatch, space=self._SPACE, role="viewer") + plan = await resolve_agent_invocation( + _assistant(bindings=[_mem_binding(access="read", always_load=["MEMORY.md"])]), _user() + ) + assert plan.memory.space_id == "spc_1" + assert plan.memory.space_name == "Oliver's Brain" + assert plan.memory.access == "read" and plan.memory.role == "viewer" + assert plan.memory.always_load == ["MEMORY.md"] + + @pytest.mark.asyncio + async def test_readwrite_requires_editor(self, monkeypatch): + _patch_memory(monkeypatch, space=self._SPACE, role="viewer") + with pytest.raises(AgentBindingBlockedError) as ei: + await resolve_agent_invocation( + _assistant(bindings=[_mem_binding(access="readwrite")]), _user() + ) + assert "editor" in ei.value.message + + @pytest.mark.asyncio + async def test_readwrite_editor_resolves(self, monkeypatch): + _patch_memory(monkeypatch, space=self._SPACE, role="editor") + plan = await resolve_agent_invocation( + _assistant(bindings=[_mem_binding(access="readwrite")]), _user() + ) + assert plan.memory.access == "readwrite" and plan.memory.role == "editor" + + @pytest.mark.asyncio + async def test_permission_checked_against_invoker(self, monkeypatch): + svc = _patch_memory(monkeypatch, space=self._SPACE, role="viewer") + await resolve_agent_invocation(_assistant(bindings=[_mem_binding()]), _user()) + # resolve_permission(space_id, user_id, user_email) β€” invoker's identity. + args = svc.resolve_permission.call_args.args + assert args[0] == "spc_1" and args[1] == "u-bob" and args[2] == "bob@x.edu" + + +class TestToolResolution: + @pytest.mark.asyncio + async def test_no_tool_binding_is_none(self, monkeypatch): + # No tool binding β‡’ plan.tools is None (request's enabled_tools stay in force) and + # we never even consult tool RBAC β€” the service is fetched lazily. + svc = _patch_tool_access(monkeypatch, True) + plan = await resolve_agent_invocation(_assistant(bindings=[]), _user()) + assert plan.tools is None + svc.can_access_tool.assert_not_awaited() + + @pytest.mark.asyncio + async def test_accessible_tools_become_override(self, monkeypatch): + _patch_tool_access(monkeypatch, True) + plan = await resolve_agent_invocation( + _assistant(bindings=[_tool_binding("web_search"), _tool_binding("calculator")]), + _user(), + ) + assert plan.tools is not None + assert plan.tools.tool_ids == ["web_search", "calculator"] + + @pytest.mark.asyncio + async def test_empty_tool_ids_distinct_from_none(self, monkeypatch): + # An Agent may bind tools but none of the other kinds β€” the resolved list drives the + # turn (replace). (A deliberately-empty toolset is expressed by binding no tools => + # None; a non-empty binding list always yields a non-None ResolvedTools.) + _patch_tool_access(monkeypatch, True) + plan = await resolve_agent_invocation( + _assistant(bindings=[_tool_binding("web_search")]), _user() + ) + assert plan.tools is not None and plan.tools.tool_ids == ["web_search"] + + @pytest.mark.asyncio + async def test_duplicate_refs_deduped(self, monkeypatch): + _patch_tool_access(monkeypatch, True) + plan = await resolve_agent_invocation( + _assistant(bindings=[_tool_binding("web_search"), _tool_binding("web_search")]), + _user(), + ) + assert plan.tools.tool_ids == ["web_search"] + + @pytest.mark.asyncio + async def test_missing_tool_blocks_with_message(self, monkeypatch): + # Invoker has calculator but not web_search β‡’ block naming the missing tool (D5). + _patch_tool_access(monkeypatch, {"calculator"}) + with pytest.raises(AgentBindingBlockedError) as ei: + await resolve_agent_invocation( + _assistant(bindings=[_tool_binding("web_search"), _tool_binding("calculator")]), + _user(), + ) + assert "web_search" in ei.value.message + + @pytest.mark.asyncio + async def test_tool_access_checked_against_invoker(self, monkeypatch): + svc = _patch_tool_access(monkeypatch, True) + await resolve_agent_invocation(_assistant(bindings=[_tool_binding("web_search")]), _user()) + # can_access_tool(invoker, tool_id) β€” the INVOKING user, not the author. + args = svc.can_access_tool.await_args.args + assert args[0].user_id == "u-bob" and args[1] == "web_search" diff --git a/backend/tests/apis/inference_api/test_build_memory_tools.py b/backend/tests/apis/inference_api/test_build_memory_tools.py new file mode 100644 index 00000000..f2f85186 --- /dev/null +++ b/backend/tests/apis/inference_api/test_build_memory_tools.py @@ -0,0 +1,28 @@ +"""Agent Designer Phase 3 β€” the _build_memory_tools extra_tools seam. + +None binding β†’ no tools; read access β†’ list+read; readwrite β†’ list+read+write. +""" + +from types import SimpleNamespace + +from apis.inference_api.chat.routes import _build_memory_tools + + +def _binding(access): + return SimpleNamespace(space_id="spc_1", space_name="Brain", access=access, role="editor") + + +def test_no_binding_yields_no_tools(): + assert _build_memory_tools(None, "u1", "u1@x.edu") == [] + + +def test_read_access_exposes_list_and_read_only(): + tools = _build_memory_tools(_binding("read"), "u1", "u1@x.edu") + names = [t.tool_name for t in tools] + assert names == ["memory_list", "memory_read"] + + +def test_readwrite_access_adds_write(): + tools = _build_memory_tools(_binding("readwrite"), "u1", "u1@x.edu") + names = [t.tool_name for t in tools] + assert names == ["memory_list", "memory_read", "memory_write"] diff --git a/backend/tests/apis/inference_api/test_interruption_note.py b/backend/tests/apis/inference_api/test_interruption_note.py new file mode 100644 index 00000000..743e0720 --- /dev/null +++ b/backend/tests/apis/inference_api/test_interruption_note.py @@ -0,0 +1,34 @@ +"""The reason-driven interruption note prepended to the turn after an +interrupted one (see `_build_interruption_note` in inference-api chat routes). + +The note is built from the reason POPPED from the session record at turn +start (`clear_interrupted_turn` returns it), because the reason is not +knowable at cancellation time β€” the client's `user_stopped` signal races the +server-side `connection_lost` backstop and precedence only settles in +DynamoDB. The two reasons carry opposite guidance, which is the whole point +of capturing intent. +""" + +from apis.inference_api.chat.routes import _build_interruption_note + + +class TestBuildInterruptionNote: + def test_user_stopped_tells_model_not_to_resume(self): + note = _build_interruption_note("user_stopped") + assert note.startswith("") + assert note.endswith("") + assert "deliberately stopped" in note + assert "do not" in note.lower() + + def test_connection_lost_tells_model_it_may_continue(self): + note = _build_interruption_note("connection_lost") + assert note.startswith("") + assert "did not stop it deliberately" in note + assert "continue" in note.lower() + + def test_unknown_reason_treated_as_technical_drop(self): + # `unknown` carries no user intent, so it must NOT claim the user + # stopped the response. + note = _build_interruption_note("unknown") + assert "deliberately stopped" not in note + assert "did not stop it deliberately" in note diff --git a/backend/tests/apis/shared/oauth/test_agentcore_identity.py b/backend/tests/apis/shared/oauth/test_agentcore_identity.py index c1b7e89d..a1d50c3b 100644 --- a/backend/tests/apis/shared/oauth/test_agentcore_identity.py +++ b/backend/tests/apis/shared/oauth/test_agentcore_identity.py @@ -13,91 +13,45 @@ class TestCustomParametersFor: - """Merge of vendor baseline + admin extras. Baseline is non-negotiable - because admins can't safely turn off documented requirements (e.g. - Google's `access_type=offline` for refresh tokens).""" - - def test_google_baseline_alone(self) -> None: - assert custom_parameters_for("google") == {"access_type": "offline"} - - def test_google_match_is_case_insensitive(self) -> None: - # OAuthProviderType.GOOGLE.value is "google", but defensive against - # callers that pass the upper-case enum name. - assert custom_parameters_for("Google") == {"access_type": "offline"} - - @pytest.mark.parametrize( - "vendor", ["microsoft", "github", "canvas", "custom", "unknown"] - ) - def test_other_vendors_with_no_extras_return_none(self, vendor: str) -> None: - # Per the AgentCore Identity docs, only Google requires baseline - # extras today. Returning None lets callers pass through. - assert custom_parameters_for(vendor) is None + """Pass-through normalizer for a connector's admin-configured + `customParameters`. There is no hardcoded vendor baseline β€” vendor + requirements (e.g. Google's `access_type=offline` for refresh tokens) + are supplied per-connector via the admin "Custom OAuth Parameters" + field, since the delivered vendors don't add them on their own.""" def test_none_returns_none(self) -> None: assert custom_parameters_for(None) is None - def test_empty_string_returns_none(self) -> None: - assert custom_parameters_for("") is None + def test_empty_dict_returns_none(self) -> None: + # Callers pass the result straight to the SDK; None keeps an empty + # `customParameters` map off the wire. + assert custom_parameters_for({}) is None - def test_admin_extras_merged_with_google_baseline(self) -> None: - # Admin can add domain restriction / prompt without losing - # the access_type=offline requirement. - result = custom_parameters_for( - "google", {"hd": "mycompany.com", "prompt": "consent"} - ) - assert result == { + def test_admin_extras_passed_through_verbatim(self) -> None: + # A Google connector configured for refresh tokens: the exact map + # the admin set is forwarded, untouched. + extras = { "access_type": "offline", - "hd": "mycompany.com", "prompt": "consent", + "hd": "mycompany.com", } + assert custom_parameters_for(extras) == extras - def test_admin_cannot_override_baseline_keys(self) -> None: - # Admin-supplied access_type=online is silently superseded by the - # baseline. This is intentional β€” overriding it would silently - # break refresh tokens, the exact bug we hardcoded against. - result = custom_parameters_for("google", {"access_type": "online"}) - assert result == {"access_type": "offline"} - - def test_admin_extras_only_for_non_baseline_vendor(self) -> None: - # Vendors with no baseline still pass through admin extras. - result = custom_parameters_for("github", {"prompt": "consent"}) - assert result == {"prompt": "consent"} - - def test_empty_admin_extras_treated_as_none(self) -> None: - assert custom_parameters_for("microsoft", {}) is None - assert custom_parameters_for("microsoft", None) is None - - def test_force_authentication_adds_prompt_consent_for_google(self) -> None: - # Google only re-issues a refresh token on subsequent grants when - # the consent screen is shown β€” so the explicit re-consent path - # must add prompt=consent on top of the baseline. - result = custom_parameters_for("google", force_authentication=True) - assert result == {"access_type": "offline", "prompt": "consent"} - - def test_force_authentication_does_not_set_prompt_for_other_vendors( - self, - ) -> None: - # Other vendors don't have the same constraint β€” leaving prompt - # unset means we don't accidentally annoy a Microsoft / GitHub - # user with an unnecessary consent screen on every reconnect. - assert custom_parameters_for("microsoft", force_authentication=True) is None - assert custom_parameters_for("github", force_authentication=True) is None - - def test_force_authentication_baseline_still_wins_over_admin(self) -> None: - # Even when force_authentication adds prompt=consent, an admin - # supplying prompt=login can't override it β€” the re-consent path - # needs the consent screen specifically. - result = custom_parameters_for( - "google", {"prompt": "login"}, force_authentication=True - ) - assert result == {"access_type": "offline", "prompt": "consent"} - - def test_default_force_authentication_is_false(self) -> None: - # Silent refresh path must not get prompt=consent β€” otherwise - # every refresh would force the consent screen. - result = custom_parameters_for("google") - assert result == {"access_type": "offline"} - assert "prompt" not in result + def test_no_baseline_injected_for_google_like_params(self) -> None: + # Nothing is added or overridden β€” if an admin sets access_type=online + # that's what gets sent (their choice, their consequence). + assert custom_parameters_for({"access_type": "online"}) == { + "access_type": "online" + } + + def test_returns_a_copy(self) -> None: + # Defensive: mutating the returned map must not mutate the caller's + # stored connector params. + extras = {"access_type": "offline"} + result = custom_parameters_for(extras) + assert result == extras + result["prompt"] = "consent" + assert "prompt" not in extras class TestTokenResult: diff --git a/backend/tests/apis/shared/test_harness_grants.py b/backend/tests/apis/shared/test_harness_grants.py new file mode 100644 index 00000000..ae267ef2 --- /dev/null +++ b/backend/tests/apis/shared/test_harness_grants.py @@ -0,0 +1,319 @@ +"""Tests for the headless-grant record + grant-backed bearer minting. + +The grant record is the durable "act as me" consent for headless runs +(``apis/shared/harness/grants.py``): create-on-enable from an attended +session, per-owner lookup via the sparse ``HeadlessGrantUserIndex`` GSI +(replacing the spike's BFF-table Scan), and total revocation (the stored +refresh token is deleted in the revoke write). +""" + +from __future__ import annotations + +import time + +import pytest +from botocore.exceptions import ClientError + +from apis.shared.harness.auth import CognitoRefreshBearerAuth, HeadlessAuthError +from apis.shared.harness.grants import ( + HEADLESS_GRANT_MAX_AGE_DAYS, + HeadlessGrant, + HeadlessGrantService, +) +from apis.shared.sessions_bff.refresh import CognitoRefreshError, RefreshResult + +NOW = int(time.time()) +MAX_AGE_SECONDS = HEADLESS_GRANT_MAX_AGE_DAYS * 24 * 60 * 60 + + +class FakeTable: + """Duck-typed DynamoDB Table capturing writes; query returns canned pages.""" + + def __init__(self, query_items=None): + self.query_items = list(query_items or []) + self.put_items: list[dict] = [] + self.update_calls: list[dict] = [] + self.query_kwargs: dict = {} + self.update_error: Exception | None = None + + def query(self, **kwargs): + self.query_kwargs = kwargs + return {"Items": list(self.query_items)} + + def put_item(self, Item): + self.put_items.append(Item) + # Newest-first, as the descending GSI query would return it. + self.query_items.insert(0, Item) + + def update_item(self, **kwargs): + if self.update_error is not None: + raise self.update_error + self.update_calls.append(kwargs) + + +def _service(table: FakeTable) -> HeadlessGrantService: + service = HeadlessGrantService(table_name="fake-table") + service._table = table + return service + + +def _grant_item( + *, + grant_id: str = "hlg-abc", + user_id: str = "user-1", + status: str = "active", + created_at: int = NOW - 100, + ttl: int = NOW + 1000, +) -> dict: + return { + "PK": f"HEADLESS-GRANT#{grant_id}", + "SK": "META", + "grant_id": grant_id, + "grant_user_id": user_id, + "username": "user1", + "cognito_refresh_token": "rt-stored", + "status": status, + "created_at": created_at, + "updated_at": created_at, + "token_issued_at": created_at, + "ttl": ttl, + } + + +# --------------------------------------------------------------------------- +# HeadlessGrantService +# --------------------------------------------------------------------------- + + +class TestEnable: + @pytest.mark.asyncio + async def test_creates_grant_with_sparse_gsi_key_and_login_anchored_ttl(self): + table = FakeTable() + service = _service(table) + issued_at = NOW - 3600 # the login that produced the token + + grant = await service.enable( + user_id="user-1", + username="user1", + refresh_token="rt-1", + token_issued_at=issued_at, + ) + + assert grant.grant_id.startswith("hlg-") + item = table.put_items[0] + assert item["PK"] == f"HEADLESS-GRANT#{grant.grant_id}" + assert item["SK"] == "META" + # Sparse GSI partition key β€” only grant items carry it. + assert item["grant_user_id"] == "user-1" + assert item["cognito_refresh_token"] == "rt-1" + assert item["status"] == "active" + # "Must have logged in within N days": TTL anchors to the login. + assert item["ttl"] == issued_at + MAX_AGE_SECONDS + + @pytest.mark.asyncio + async def test_renews_existing_active_grant_in_place(self): + table = FakeTable([_grant_item()]) + service = _service(table) + + grant = await service.enable( + user_id="user-1", username="user1", refresh_token="rt-new" + ) + + assert grant.grant_id == "hlg-abc" # stable id for audit continuity + assert not table.put_items # renew, not a second record + (call,) = table.update_calls + assert call["Key"] == {"PK": "HEADLESS-GRANT#hlg-abc", "SK": "META"} + assert call["ExpressionAttributeValues"][":rt"] == "rt-new" + assert grant.cognito_refresh_token == "rt-new" + + @pytest.mark.asyncio + async def test_max_age_days_is_env_overridable(self, monkeypatch): + monkeypatch.setenv("HEADLESS_GRANT_MAX_AGE_DAYS", "7") + table = FakeTable() + service = _service(table) + + await service.enable( + user_id="user-1", + username="user1", + refresh_token="rt-1", + token_issued_at=NOW, + ) + + assert table.put_items[0]["ttl"] == NOW + 7 * 24 * 60 * 60 + + +class TestGetActiveGrant: + @pytest.mark.asyncio + async def test_returns_newest_active_grant(self): + table = FakeTable([_grant_item()]) + service = _service(table) + + grant = await service.get_active_grant("user-1") + + assert isinstance(grant, HeadlessGrant) + assert grant.grant_id == "hlg-abc" + assert grant.is_active + # The lookup is a GSI query, not a Scan. + assert table.query_kwargs["IndexName"] == "HeadlessGrantUserIndex" + assert table.query_kwargs["ScanIndexForward"] is False + + @pytest.mark.asyncio + async def test_skips_revoked_and_expired_grants(self): + table = FakeTable( + [ + _grant_item(grant_id="hlg-revoked", status="revoked"), + # TTL passed but DynamoDB hasn't swept yet β€” defense in depth. + _grant_item(grant_id="hlg-expired", ttl=NOW - 5), + _grant_item(grant_id="hlg-live", created_at=NOW - 999), + ] + ) + service = _service(table) + + grant = await service.get_active_grant("user-1") + + assert grant is not None and grant.grant_id == "hlg-live" + + @pytest.mark.asyncio + async def test_returns_none_when_user_has_no_grants(self): + service = _service(FakeTable()) + assert await service.get_active_grant("user-1") is None + + +class TestRevoke: + @pytest.mark.asyncio + async def test_revoke_removes_the_stored_credential(self): + table = FakeTable([_grant_item()]) + service = _service(table) + + assert await service.revoke("user-1") is True + (call,) = table.update_calls + assert "REMOVE cognito_refresh_token" in call["UpdateExpression"] + assert call["ExpressionAttributeValues"][":revoked"] == "revoked" + + @pytest.mark.asyncio + async def test_revoke_is_false_when_nothing_active(self): + table = FakeTable([_grant_item(status="revoked")]) + service = _service(table) + + assert await service.revoke("user-1") is False + assert not table.update_calls + + +class TestRecordUse: + @pytest.mark.asyncio + async def test_touch_failure_never_raises(self): + table = FakeTable() + table.update_error = ClientError( + {"Error": {"Code": "ProvisionedThroughputExceededException"}}, + "UpdateItem", + ) + service = _service(table) + + await service.record_use("hlg-abc") # must not raise + + +# --------------------------------------------------------------------------- +# CognitoRefreshBearerAuth (grant-backed mint) +# --------------------------------------------------------------------------- + + +class FakeGrants: + def __init__(self, grant: HeadlessGrant | None): + self.grant = grant + self.persisted: list[tuple[str, str]] = [] + self.used: list[str] = [] + + async def get_active_grant(self, user_id: str): + return self.grant + + async def persist_rotated_refresh_token(self, grant_id: str, refresh_token: str): + self.persisted.append((grant_id, refresh_token)) + + async def record_use(self, grant_id: str): + self.used.append(grant_id) + + +class FakeRefreshClient: + def __init__(self, result: RefreshResult | None = None, error: Exception | None = None): + self.result = result + self.error = error + self.calls: list[dict] = [] + + async def refresh(self, *, username: str, refresh_token: str) -> RefreshResult: + self.calls.append({"username": username, "refresh_token": refresh_token}) + if self.error is not None: + raise self.error + assert self.result is not None + return self.result + + +def _live_grant() -> HeadlessGrant: + return HeadlessGrant( + grant_id="hlg-abc", + user_id="user-1", + username="user1", + cognito_refresh_token="rt-stored", + status="active", + created_at=NOW - 100, + updated_at=NOW - 100, + token_issued_at=NOW - 100, + ttl=NOW + 1000, + ) + + +class TestCognitoRefreshBearerAuth: + @pytest.mark.asyncio + async def test_mints_from_the_grant_and_records_use(self): + grants = FakeGrants(_live_grant()) + refresh = FakeRefreshClient( + RefreshResult( + access_token="at-fresh", + refresh_token="rt-stored", # no rotation on this pool + id_token=None, + access_token_exp=NOW + 3600, + ) + ) + auth = CognitoRefreshBearerAuth(grants=grants, refresh_client=refresh) + + token = await auth.mint_bearer_for_user("user-1") + + assert token == "at-fresh" + assert refresh.calls == [{"username": "user1", "refresh_token": "rt-stored"}] + assert grants.used == ["hlg-abc"] + assert grants.persisted == [] + + @pytest.mark.asyncio + async def test_no_active_grant_raises_headless_auth_error(self): + auth = CognitoRefreshBearerAuth( + grants=FakeGrants(None), refresh_client=FakeRefreshClient() + ) + + with pytest.raises(HeadlessAuthError, match="No active headless grant"): + await auth.mint_bearer_for_user("user-1") + + @pytest.mark.asyncio + async def test_cognito_refusal_raises_headless_auth_error(self): + auth = CognitoRefreshBearerAuth( + grants=FakeGrants(_live_grant()), + refresh_client=FakeRefreshClient(error=CognitoRefreshError("revoked")), + ) + + with pytest.raises(HeadlessAuthError, match="refused the refresh exchange"): + await auth.mint_bearer_for_user("user-1") + + @pytest.mark.asyncio + async def test_rotated_refresh_token_is_persisted_onto_the_grant(self): + grants = FakeGrants(_live_grant()) + refresh = FakeRefreshClient( + RefreshResult( + access_token="at-fresh", + refresh_token="rt-rotated", + id_token=None, + access_token_exp=NOW + 3600, + ) + ) + auth = CognitoRefreshBearerAuth(grants=grants, refresh_client=refresh) + + await auth.mint_bearer_for_user("user-1") + + assert grants.persisted == [("hlg-abc", "rt-rotated")] diff --git a/backend/tests/apis/shared/test_harness_runner.py b/backend/tests/apis/shared/test_harness_runner.py new file mode 100644 index 00000000..8449a88d --- /dev/null +++ b/backend/tests/apis/shared/test_harness_runner.py @@ -0,0 +1,326 @@ +"""Tests for ``run_agent_headless`` β€” governance ordering, outcomes, delivery. + +The runner is exercised end-to-end against an ``httpx.MockTransport`` +standing in for the AgentCore Runtime data plane (via the +``_build_http_client`` seam), with a recording governance floor and spied +delivery functions. The key invariant pinned here is the F6a fail-closed +rule: **no audit record β†’ no run** (no bearer mint, no HTTP). +""" + +from __future__ import annotations + +import httpx +import pytest + +import apis.shared.sessions.metadata as sessions_metadata +from apis.shared.harness import runner as runner_module +from apis.shared.harness.auth import HeadlessAuthError, StaticBearerAuth +from apis.shared.harness.governance import GovernanceFloor +from apis.shared.harness.runner import build_invocations_url, run_agent_headless + + +class RecordingAudit: + """Stands in for RunAuditRecorder; optionally fails the start write.""" + + def __init__(self, *, fail_start: bool = False): + self.fail_start = fail_start + self.starts: list[dict] = [] + self.ends: list = [] + + def record_start(self, **kwargs): + if self.fail_start: + raise RuntimeError("dynamo down") + self.starts.append(kwargs) + + def record_end(self, *, result): + self.ends.append(result) + + +class SpyBearerAuth(StaticBearerAuth): + def __init__(self, token: str = "bearer-1"): + super().__init__(token) + self.minted_for: list[str] = [] + + async def mint_bearer_for_user(self, user_id: str) -> str: + self.minted_for.append(user_id) + return await super().mint_bearer_for_user(user_id) + + +class FailingBearerAuth: + async def mint_bearer_for_user(self, user_id: str) -> str: + raise HeadlessAuthError("no grant") + + +def _sse(*events: tuple[str, str]) -> bytes: + return "".join(f"event: {name}\ndata: {data}\n\n" for name, data in events).encode() + + +_HAPPY_STREAM = _sse( + ("message_start", '{"role": "assistant"}'), + ("content_block_delta", '{"contentBlockIndex": 0, "type": "text", "text": "pong"}'), + ("message_stop", '{"stopReason": "end_turn"}'), + ("metadata", '{"usage": {"inputTokens": 5, "outputTokens": 1, "totalTokens": 6}}'), + ("done", "{}"), +) + + +@pytest.fixture +def delivery_spy(monkeypatch): + """Spy the runner's delivery calls (imported lazily from sessions.metadata).""" + calls = {"ensure": [], "title": [], "unread": []} + + async def fake_ensure(session_id, user_id): + calls["ensure"].append((session_id, user_id)) + return True + + async def fake_title(session_id, user_id, title): + calls["title"].append((session_id, user_id, title)) + + async def fake_unread(session_id, user_id, unread): + calls["unread"].append((session_id, user_id, unread)) + + monkeypatch.setattr(sessions_metadata, "ensure_session_metadata_exists", fake_ensure) + monkeypatch.setattr(sessions_metadata, "update_session_title", fake_title) + monkeypatch.setattr(sessions_metadata, "set_session_unread", fake_unread) + return calls + + +def _mock_http(monkeypatch, handler): + def factory(timeout_seconds: float) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + monkeypatch.setattr(runner_module, "_build_http_client", factory) + + +# --------------------------------------------------------------------------- +# F6a β€” audit fail-closed +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_audit_start_failure_fails_closed_before_any_spend(monkeypatch): + """No audit record β†’ no run: neither the bearer mint nor HTTP happens.""" + auth = SpyBearerAuth() + + def no_http(timeout_seconds): # any HTTP attempt is a test failure + raise AssertionError("HTTP client built despite failed audit") + + monkeypatch.setattr(runner_module, "_build_http_client", no_http) + + with pytest.raises(RuntimeError, match="dynamo down"): + await run_agent_headless( + user_id="user-1", + prompt="hi", + auth=auth, + governance=GovernanceFloor(audit=RecordingAudit(fail_start=True)), + ) + + assert auth.minted_for == [] + + +@pytest.mark.asyncio +async def test_auth_failure_still_writes_the_end_audit_record(monkeypatch, delivery_spy): + audit = RecordingAudit() + + with pytest.raises(HeadlessAuthError): + await run_agent_headless( + user_id="user-1", + prompt="hi", + auth=FailingBearerAuth(), + governance=GovernanceFloor(audit=audit), + ) + + assert len(audit.starts) == 1 + (end,) = audit.ends + assert end.status == "error" + assert "auth" in (end.error or "") + + +# --------------------------------------------------------------------------- +# Outcomes +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_happy_path_completes_and_delivers(monkeypatch, delivery_spy): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["auth"] = request.headers.get("authorization") + seen["url"] = str(request.url) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_HAPPY_STREAM, + ) + + _mock_http(monkeypatch, handler) + audit = RecordingAudit() + + result = await run_agent_headless( + user_id="user-1", + prompt="ping", + auth=SpyBearerAuth("bearer-xyz"), + title="My Briefing", + trigger="run_now", + invocations_base_url="http://localhost:8001", + governance=GovernanceFloor(audit=audit), + ) + + assert result.status == "completed" + assert result.final_message == "pong" + assert result.title == "My Briefing" + assert result.usage["usage"]["totalTokens"] == 6 + assert seen["auth"] == "Bearer bearer-xyz" + assert seen["url"] == "http://localhost:8001/invocations" + + # Audit trail: start before, end after, same run id. + assert audit.starts[0]["run_id"] == result.run_id + assert audit.starts[0]["trigger"] == "run_now" + assert audit.ends[0].status == "completed" + + # Delivery: idempotent session ensure + explicit title override. + assert delivery_spy["ensure"] == [(result.session_id, "user-1")] + assert delivery_spy["title"] == [(result.session_id, "user-1", "My Briefing")] + # "run_now" is a background/fire-and-forget surface β€” the user isn't + # watching the stream, so a completed run flags the session unread (a + # sidebar dot until they open it), same as a scheduled run. + assert delivery_spy["unread"] == [(result.session_id, "user-1", True)] + + +@pytest.mark.asyncio +async def test_scheduled_completion_marks_session_unread(monkeypatch, delivery_spy): + """An unattended (schedule) run flags the session unread on completion.""" + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_HAPPY_STREAM, + ) + + _mock_http(monkeypatch, handler) + + result = await run_agent_headless( + user_id="user-1", + prompt="daily briefing", + auth=SpyBearerAuth("bearer-xyz"), + trigger="schedule", + invocations_base_url="http://localhost:8001", + governance=GovernanceFloor(audit=RecordingAudit()), + ) + + assert result.status == "completed" + assert delivery_spy["unread"] == [(result.session_id, "user-1", True)] + + +@pytest.mark.asyncio +async def test_scheduled_error_does_not_mark_unread(monkeypatch, delivery_spy): + """A failed schedule run leaves no unread dot (nothing worth reading).""" + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(403, json={"message": "OAuth authorization failed"}) + + _mock_http(monkeypatch, handler) + + result = await run_agent_headless( + user_id="user-1", + prompt="daily briefing", + auth=SpyBearerAuth("bearer-xyz"), + trigger="schedule", + invocations_base_url="http://localhost:8001", + governance=GovernanceFloor(audit=RecordingAudit()), + ) + + assert result.status != "completed" + assert delivery_spy["unread"] == [] + + +@pytest.mark.asyncio +async def test_http_error_from_the_gateway_is_an_error_result(monkeypatch, delivery_spy): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(403, json={"message": "OAuth authorization failed"}) + + _mock_http(monkeypatch, handler) + + result = await run_agent_headless( + user_id="user-1", + prompt="ping", + auth=StaticBearerAuth("t"), + invocations_base_url="http://localhost:8001", + governance=GovernanceFloor(audit=RecordingAudit()), + ) + + assert result.status == "error" + assert "HTTP 403" in (result.error or "") + + +@pytest.mark.asyncio +async def test_stream_without_done_event_is_an_error(monkeypatch, delivery_spy): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_sse(("message_start", '{"role": "assistant"}')), + ) + + _mock_http(monkeypatch, handler) + + result = await run_agent_headless( + user_id="user-1", + prompt="ping", + auth=StaticBearerAuth("t"), + invocations_base_url="http://localhost:8001", + governance=GovernanceFloor(audit=RecordingAudit()), + ) + + assert result.status == "error" + assert "without a done event" in (result.error or "") + + +@pytest.mark.asyncio +async def test_timeout_surfaces_as_timeout_status(monkeypatch, delivery_spy): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("too slow") + + _mock_http(monkeypatch, handler) + + result = await run_agent_headless( + user_id="user-1", + prompt="ping", + auth=StaticBearerAuth("t"), + invocations_base_url="http://localhost:8001", + timeout_seconds=1.0, + governance=GovernanceFloor(audit=RecordingAudit()), + ) + + assert result.status == "timeout" + + +# --------------------------------------------------------------------------- +# build_invocations_url β€” the single shared resolver (chat proxy imports it) +# --------------------------------------------------------------------------- + + +def test_build_invocations_url_encodes_the_runtime_arn(): + base = ( + "https://bedrock-agentcore.us-west-2.amazonaws.com/runtimes/" + "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/my-runtime" + ) + url = build_invocations_url(base) + assert url == ( + "https://bedrock-agentcore.us-west-2.amazonaws.com/runtimes/" + "arn%3Aaws%3Abedrock-agentcore%3Aus-west-2%3A123456789012%3Aruntime%2F" + "my-runtime/invocations?qualifier=DEFAULT" + ) + + +def test_build_invocations_url_local_passthrough(): + assert ( + build_invocations_url("http://localhost:8001") + == "http://localhost:8001/invocations" + ) + + +def test_chat_proxy_uses_the_shared_resolver(): + from apis.app_api.chat import proxy_routes + + assert proxy_routes._build_invocations_url is build_invocations_url diff --git a/backend/tests/apis/shared/test_harness_sse.py b/backend/tests/apis/shared/test_harness_sse.py new file mode 100644 index 00000000..88168b92 --- /dev/null +++ b/backend/tests/apis/shared/test_harness_sse.py @@ -0,0 +1,157 @@ +"""Tests for the harness SSE reader/accumulator (headless run spike). + +Event payload shapes below are verbatim captures from a live dev-ai +`/invocations` stream (2026-07-05, spike run `run-8f10d164cff9`), so the +accumulator is pinned to what the runtime actually emits β€” including the +stream-processor's nested `tool_use` passthrough whose `input` is a +partial JSON *string* re-emitted as the model streams arguments, and the +message-shaped `tool_result`. +""" + +import pytest + +from apis.shared.harness.sse import InvocationStreamAccumulator, iter_sse_events + + +async def _aiter(lines): + for line in lines: + yield line + + +@pytest.mark.asyncio +async def test_iter_sse_events_parses_named_and_bare_data_events(): + lines = [ + "event: message_start", + 'data: {"role": "assistant"}', + "", + # Bare data event (event_formatter path) β€” name comes from `type`. + 'data: {"type": "session_title", "title": "T"}', + "", + "event: done", + "data: {}", + "", + ] + events = [pair async for pair in iter_sse_events(_aiter(lines))] + assert events == [ + ("message_start", {"role": "assistant"}), + ("session_title", {"type": "session_title", "title": "T"}), + ("done", {}), + ] + + +@pytest.mark.asyncio +async def test_iter_sse_events_survives_unparseable_payload(): + lines = ["event: event", "data: {not json", "", "event: done", "data: {}", ""] + events = [pair async for pair in iter_sse_events(_aiter(lines))] + assert events[0][0] == "_unparseable" + assert events[-1] == ("done", {}) + + +def _drive_turn_events(): + """A tool-use turn in the shapes the runtime actually emits.""" + return [ + ("message_start", {"role": "assistant"}), + # Streamed partial tool input: same id re-emitted with growing input. + ( + "tool_use", + {"tool_use": {"name": "search_classes", "tool_use_id": "t1", "input": ""}}, + ), + ( + "tool_use", + { + "tool_use": { + "name": "search_classes", + "tool_use_id": "t1", + "input": '{"subject": "CO', + } + }, + ), + ( + "tool_use", + { + "tool_use": { + "name": "search_classes", + "tool_use_id": "t1", + "input": '{"subject": "COMM", "min_credits": 3}', + } + }, + ), + ("message_stop", {"stopReason": "tool_use"}), + ( + "tool_result", + { + "message": { + "role": "user", + "content": [ + { + "toolResult": { + "status": "success", + "toolUseId": "t1", + "content": [{"text": '{"total_results": 87}'}], + } + } + ], + } + }, + ), + ("message_start", {"role": "assistant"}), + ("content_block_delta", {"contentBlockIndex": 0, "type": "text", "text": "Found "}), + ("content_block_delta", {"contentBlockIndex": 0, "type": "text", "text": "87 classes."}), + ("message_stop", {"stopReason": "end_turn"}), + ( + "metadata", + {"usage": {"inputTokens": 5, "outputTokens": 215, "totalTokens": 10763}}, + ), + ("session_title", {"type": "session_title", "title": "COMM Search"}), + ("done", {}), + ] + + +def test_accumulator_folds_streamed_tool_use_into_one_entry(): + acc = InvocationStreamAccumulator() + for name, payload in _drive_turn_events(): + acc.handle(name, payload) + + assert acc.done is True + assert acc.stop_reason == "end_turn" + assert acc.final_message == "Found 87 classes." + assert acc.title == "COMM Search" + + assert len(acc.tool_trace) == 1 + entry = acc.tool_trace[0] + assert entry.tool_use_id == "t1" + assert entry.name == "search_classes" + assert entry.input == {"subject": "COMM", "min_credits": 3} + assert entry.result_preview == '{"total_results": 87}' + assert entry.is_error is False + + usage = acc.finalize_usage() + assert usage["usage"]["totalTokens"] == 10763 + + +def test_accumulator_flat_tool_shapes_and_errors(): + acc = InvocationStreamAccumulator() + acc.handle("tool_use", {"toolUseId": "t2", "name": "calc", "input": {"x": 1}}) + acc.handle("tool_result", {"toolUseId": "t2", "result": "boom", "status": "error"}) + acc.handle("stream_error", {"message": "model exploded"}) + + assert acc.tool_trace[0].input == {"x": 1} + assert acc.tool_trace[0].is_error is True + assert acc.tool_trace[0].result_preview == "boom" + assert acc.error == "model exploded" + assert acc.done is False + + +def test_accumulator_oauth_required_and_final_message_fallback(): + acc = InvocationStreamAccumulator() + acc.handle("message_start", {"role": "assistant"}) + acc.handle( + "content_block_delta", {"contentBlockIndex": 0, "type": "text", "text": "partial"} + ) + acc.handle( + "oauth_required", + {"providerId": "google-drive", "authorizationUrl": "https://consent"}, + ) + # No message_stop β€” unterminated buffer must still surface. + assert acc.final_message == "partial" + assert acc.oauth_required[0].provider_id == "google-drive" diff --git a/backend/tests/architecture/test_import_boundaries.py b/backend/tests/architecture/test_import_boundaries.py index ac42cb0f..1cdd7342 100644 --- a/backend/tests/architecture/test_import_boundaries.py +++ b/backend/tests/architecture/test_import_boundaries.py @@ -171,6 +171,54 @@ def test_no_inference_api_imports(self): ) +class TestScheduledRunsLeanImageIsImportable: + """The scheduled-runs Lambda image must not import agents/ or strands. + + ``backend/Dockerfile.scheduled-runs`` bundles only a handful of + ``apis.shared`` subpackages plus the two Lambda handlers β€” it deliberately + omits the ``agents``/``strands`` packages to stay small. Any import of + those (top level *or* lazy β€” a deferred import still crashes at call time + in the lean image) breaks the dispatcher/worker at runtime. This test is + the guard that would have caught the ``ModuleNotFoundError: No module + named 'agents'`` that shipped in the first cut of the worker. + + Keep ``_BUNDLED_DIRS`` in lockstep with the ``COPY`` lines in + ``backend/Dockerfile.scheduled-runs`` and the ``scheduled-runs`` + ``SOURCE_DIRS`` in ``scripts/build/build-one.sh``. + """ + + # Paths (relative to backend/src) the scheduled-runs image COPYs in. + _BUNDLED_DIRS = ( + Path("apis/shared/harness"), + Path("apis/shared/scheduled_prompts"), + Path("apis/shared/sessions_bff"), + Path("apis/shared/sessions"), + Path("lambdas/scheduled_runs_dispatcher"), + Path("lambdas/scheduled_runs_worker"), + ) + + def test_no_agents_or_strands_imports(self): + violations: List[str] = [] + for rel_dir in self._BUNDLED_DIRS: + source = _BACKEND_SRC / rel_dir + if not source.exists(): + pytest.skip(f"{rel_dir} not found") + violations += _find_violations( + source, + {"agents", "strands"}, + "scheduled-runs lean image β†’ agents/strands (unavailable in image)", + ) + + assert violations == [], ( + "The scheduled-runs Lambda image bundles modules that import " + "agents/ or strands, which are NOT in the image:\n" + + "\n".join(violations) + + "\n\nMove the needed helper into a dependency-free apis.shared " + "leaf (see apis/shared/sessions/preview.py), or add the package to " + "the image requirements if it truly belongs there." + ) + + class TestAppApiDoesNotImportInferenceApi: """app_api must not import from inference_api. diff --git a/backend/tests/lambdas/conftest.py b/backend/tests/lambdas/conftest.py new file mode 100644 index 00000000..0cf359fe --- /dev/null +++ b/backend/tests/lambdas/conftest.py @@ -0,0 +1,127 @@ +"""Lambda-handler test fixtures (moto). + +Mirrors the assistants-table / sessions-metadata-table fixtures from +tests/shared/conftest.py β€” pytest dir-scoped conftests don't share +fixtures across sibling test packages, and the kb-sync and scheduled-runs +Lambda tests need the same table shapes the services use (including the +sparse DueSyncIndex / DueScheduleIndex). +""" + +import boto3 +import pytest +from moto import mock_aws + +AWS_REGION = "us-east-1" + + +@pytest.fixture() +def aws(monkeypatch): + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_SECURITY_TOKEN", "testing") + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") + with mock_aws(): + yield + + +def _gsi(index_name, hash_key, range_key): + return { + "IndexName": index_name, + "KeySchema": [ + {"AttributeName": hash_key, "KeyType": "HASH"}, + {"AttributeName": range_key, "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + } + + +@pytest.fixture() +def assistants_table(aws, monkeypatch): + ddb = boto3.client("dynamodb", region_name=AWS_REGION) + name = "test-assistants" + monkeypatch.setenv("DYNAMODB_ASSISTANTS_TABLE_NAME", name) + + ddb.create_table( + TableName=name, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + {"AttributeName": "GSI_PK", "AttributeType": "S"}, + {"AttributeName": "GSI_SK", "AttributeType": "S"}, + {"AttributeName": "GSI4_PK", "AttributeType": "S"}, + {"AttributeName": "GSI4_SK", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[ + _gsi("OwnerStatusIndex", "GSI_PK", "GSI_SK"), + _gsi("DueSyncIndex", "GSI4_PK", "GSI4_SK"), + ], + BillingMode="PAY_PER_REQUEST", + ) + return boto3.resource("dynamodb", region_name=AWS_REGION).Table(name) + + +@pytest.fixture() +def sessions_metadata_table(aws, monkeypatch): + """Sessions-metadata table shape used by scheduled_prompts + harness + (schedules, the sparse DueScheduleIndex, and RUN# audit records).""" + ddb = boto3.client("dynamodb", region_name=AWS_REGION) + name = "test-sessions-metadata" + monkeypatch.setenv("DYNAMODB_SESSIONS_METADATA_TABLE_NAME", name) + + ddb.create_table( + TableName=name, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + {"AttributeName": "GSI1PK", "AttributeType": "S"}, + {"AttributeName": "GSI1SK", "AttributeType": "S"}, + {"AttributeName": "GSI_PK", "AttributeType": "S"}, + {"AttributeName": "GSI_SK", "AttributeType": "S"}, + {"AttributeName": "GSI3_PK", "AttributeType": "S"}, + {"AttributeName": "GSI3_SK", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[ + _gsi("UserTimestampIndex", "GSI1PK", "GSI1SK"), + _gsi("SessionLookupIndex", "GSI_PK", "GSI_SK"), + _gsi("DueScheduleIndex", "GSI3_PK", "GSI3_SK"), + ], + BillingMode="PAY_PER_REQUEST", + ) + return boto3.resource("dynamodb", region_name=AWS_REGION).Table(name) + + +@pytest.fixture() +def bff_sessions_table(aws, monkeypatch): + """BFF sessions table shape used by HeadlessGrantService (sparse + HeadlessGrantUserIndex GSI).""" + ddb = boto3.client("dynamodb", region_name=AWS_REGION) + name = "test-bff-sessions" + monkeypatch.setenv("BFF_SESSIONS_TABLE_NAME", name) + + ddb.create_table( + TableName=name, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + {"AttributeName": "grant_user_id", "AttributeType": "S"}, + {"AttributeName": "created_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[ + _gsi("HeadlessGrantUserIndex", "grant_user_id", "created_at"), + ], + BillingMode="PAY_PER_REQUEST", + ) + return boto3.resource("dynamodb", region_name=AWS_REGION).Table(name) diff --git a/backend/tests/lambdas/test_kb_sync_dispatcher.py b/backend/tests/lambdas/test_kb_sync_dispatcher.py new file mode 100644 index 00000000..280af231 --- /dev/null +++ b/backend/tests/lambdas/test_kb_sync_dispatcher.py @@ -0,0 +1,325 @@ +"""KB sync dispatcher tests (moto DynamoDB, stubbed worker invoke). + +Sources are created through the REAL assistants/documents services so the +dispatcher's raw adjacency-list key reads are cross-checked against the +actual storage schema β€” if a key pattern drifts, these tests break loudly. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from apis.app_api.documents.services.document_service import create_document +from apis.app_api.kb_sync import dispatcher +from apis.shared.assistants.service import create_assistant +from apis.shared.sync_policies.service import ( + create_sync_policy, + get_sync_policy, + list_sync_policies, + set_policy_state, +) + +pytestmark = pytest.mark.asyncio + +USER_ID = "user-1" +PAST = "2000-01-01T00:00:00+00:00Z" + + +@pytest.fixture(autouse=True) +def kb_sync_env(monkeypatch): + monkeypatch.setenv("KB_SYNC_ENABLED", "true") + monkeypatch.setenv("KB_SYNC_WORKER_FUNCTION_NAME", "test-kb-sync-worker") + + +@pytest.fixture() +def invoked_workers(monkeypatch): + """Capture worker invocations instead of calling Lambda.""" + payloads = [] + monkeypatch.setattr(dispatcher, "_invoke_worker", payloads.append) + return payloads + + +@pytest.fixture(autouse=True) +def no_metrics(monkeypatch): + monkeypatch.setattr(dispatcher, "_emit_metrics", lambda counts: None) + + +async def _make_assistant(): + assistant = await create_assistant( + owner_id=USER_ID, + owner_name="Test User", + name="Synced Assistant", + description="d", + instructions="i", + vector_index_id="assistants-index", + ) + return assistant.assistant_id + + +async def _make_document(assistant_id, document_id="doc-1"): + doc = await create_document( + assistant_id=assistant_id, + filename="report.pdf", + content_type="application/pdf", + size_bytes=10, + s3_key=f"assistants/{assistant_id}/documents/{document_id}/report.pdf", + document_id=document_id, + ) + return doc.document_id + + +async def _make_due_policy(assistant_id, source_ref="doc-1"): + policy = await create_sync_policy( + assistant_id=assistant_id, + source_type="drive_file", + source_ref=source_ref, + interval="daily", + created_by_user_id=USER_ID, + ) + await set_policy_state(assistant_id, policy.policy_id, "active", next_sync_at=PAST) + return await get_sync_policy(assistant_id, policy.policy_id) + + +class TestKillSwitch: + async def test_disabled_tick_is_noop(self, assistants_table, invoked_workers, monkeypatch): + monkeypatch.setenv("KB_SYNC_ENABLED", "false") + assistant_id = await _make_assistant() + await _make_document(assistant_id) + await _make_due_policy(assistant_id) + + counts = await dispatcher.dispatch_once() + + assert counts["PoliciesDue"] == 0 + assert invoked_workers == [] + + +class TestLiveness: + async def test_missing_assistant_deletes_policy(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + await _make_document(assistant_id) + await _make_due_policy(assistant_id) + # Simulate a delete path that missed the cascade + assistants_table.delete_item(Key={"PK": f"AST#{assistant_id}", "SK": "METADATA"}) + + counts = await dispatcher.dispatch_once() + + assert counts["OrphansDeleted"] == 1 + assert invoked_workers == [] + assert await list_sync_policies(assistant_id) == [] + + async def test_missing_source_deletes_policy(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + await _make_due_policy(assistant_id, source_ref="doc-never-created") + + counts = await dispatcher.dispatch_once() + + assert counts["OrphansDeleted"] == 1 + assert await list_sync_policies(assistant_id) == [] + + async def test_deleting_source_deletes_policy(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + doc_id = await _make_document(assistant_id) + await _make_due_policy(assistant_id, source_ref=doc_id) + assistants_table.update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"DOC#{doc_id}"}, + UpdateExpression="SET #s = :deleting", + ExpressionAttributeNames={"#s": "status"}, + ExpressionAttributeValues={":deleting": "deleting"}, + ) + + counts = await dispatcher.dispatch_once() + + assert counts["OrphansDeleted"] == 1 + assert invoked_workers == [] + + +class TestCircuitBreaker: + async def _set_counter(self, table, assistant_id, policy_id, field, value): + table.update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"SYNCPOL#{policy_id}"}, + UpdateExpression=f"SET {field} = :v", + ExpressionAttributeValues={":v": value}, + ) + + async def test_not_found_streak_pauses(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + await _make_document(assistant_id) + policy = await _make_due_policy(assistant_id) + await self._set_counter(assistants_table, assistant_id, policy.policy_id, "consecutiveNotFound", 2) + + counts = await dispatcher.dispatch_once() + + assert counts["PausedBreaker"] == 1 + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.state == "paused_error" + assert "accessible" in updated.state_reason + assert invoked_workers == [] + + async def test_failure_streak_pauses(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + await _make_document(assistant_id) + policy = await _make_due_policy(assistant_id) + await self._set_counter(assistants_table, assistant_id, policy.policy_id, "consecutiveFailures", 5) + + counts = await dispatcher.dispatch_once() + + assert counts["PausedBreaker"] == 1 + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.state == "paused_error" + assert invoked_workers == [] + + async def test_backoff_scales_with_failures(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + await _make_document(assistant_id) + policy = await _make_due_policy(assistant_id) + await self._set_counter(assistants_table, assistant_id, policy.policy_id, "consecutiveFailures", 2) + + await dispatcher.dispatch_once() + + # daily * 2^2 = 4 days out + updated = await get_sync_policy(assistant_id, policy.policy_id) + next_dt = dispatcher._parse_timestamp(updated.next_sync_at) + expected = datetime.now(timezone.utc) + timedelta(days=4) + assert abs((next_dt - expected).total_seconds()) < 300 + + +class TestInactivityPause: + async def test_stale_assistant_pauses_policy(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + await _make_document(assistant_id) + policy = await _make_due_policy(assistant_id) + assistants_table.update_item( + Key={"PK": f"AST#{assistant_id}", "SK": "METADATA"}, + UpdateExpression="SET createdAt = :old, updatedAt = :old", + ExpressionAttributeValues={":old": "2020-01-01T00:00:00+00:00Z"}, + ) + + counts = await dispatcher.dispatch_once() + + assert counts["PausedInactive"] == 1 + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.state == "paused_inactive" + assert invoked_workers == [] + + async def test_recent_last_used_at_keeps_policy_active(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + await _make_document(assistant_id) + await _make_due_policy(assistant_id) + now_ts = datetime.now(timezone.utc).isoformat() + "Z" + assistants_table.update_item( + Key={"PK": f"AST#{assistant_id}", "SK": "METADATA"}, + UpdateExpression="SET createdAt = :old, updatedAt = :old, lastUsedAt = :now", + ExpressionAttributeValues={":old": "2020-01-01T00:00:00+00:00Z", ":now": now_ts}, + ) + + counts = await dispatcher.dispatch_once() + + assert counts["Dispatched"] == 1 + + +class TestDispatch: + async def test_happy_path_dispatches_and_rearms(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + doc_id = await _make_document(assistant_id) + policy = await _make_due_policy(assistant_id, source_ref=doc_id) + + counts = await dispatcher.dispatch_once() + + assert counts["Dispatched"] == 1 + assert invoked_workers == [ + { + "policyId": policy.policy_id, + "assistantId": assistant_id, + "sourceType": "drive_file", + "sourceRef": doc_id, + } + ] + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.next_sync_at > datetime.now(timezone.utc).isoformat() + assert updated.sync_run_started_at is not None + + async def test_dispatched_policy_not_redispatched_same_day(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + doc_id = await _make_document(assistant_id) + await _make_due_policy(assistant_id, source_ref=doc_id) + + await dispatcher.dispatch_once() + counts = await dispatcher.dispatch_once() + + # Re-armed a day out β€” second tick sees nothing due + assert counts["PoliciesDue"] == 0 + assert len(invoked_workers) == 1 + + async def test_fresh_run_stamp_skips_without_rearm(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + doc_id = await _make_document(assistant_id) + policy = await _make_due_policy(assistant_id, source_ref=doc_id) + now_ts = datetime.now(timezone.utc).isoformat() + "Z" + assistants_table.update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"SYNCPOL#{policy.policy_id}"}, + UpdateExpression="SET syncRunStartedAt = :now", + ExpressionAttributeValues={":now": now_ts}, + ) + + counts = await dispatcher.dispatch_once() + + assert counts["InFlightSkipped"] == 1 + assert invoked_workers == [] + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.next_sync_at == PAST # not re-armed; retried next tick + + async def test_stale_run_stamp_is_overwritten_and_dispatched(self, assistants_table, invoked_workers): + assistant_id = await _make_assistant() + doc_id = await _make_document(assistant_id) + policy = await _make_due_policy(assistant_id, source_ref=doc_id) + assistants_table.update_item( + Key={"PK": f"AST#{assistant_id}", "SK": f"SYNCPOL#{policy.policy_id}"}, + UpdateExpression="SET syncRunStartedAt = :old", + ExpressionAttributeValues={":old": "2020-01-01T00:00:00+00:00Z"}, + ) + + counts = await dispatcher.dispatch_once() + + assert counts["Dispatched"] == 1 + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.sync_run_started_at > "2020-01-01" + + async def test_broken_policy_does_not_starve_sweep(self, assistants_table, invoked_workers, monkeypatch): + assistant_id = await _make_assistant() + doc_a = await _make_document(assistant_id, document_id="doc-a") + doc_b = await _make_document(assistant_id, document_id="doc-b") + await _make_due_policy(assistant_id, source_ref=doc_a) + await _make_due_policy(assistant_id, source_ref=doc_b) + + real_get_source = dispatcher._get_source_item + calls = {"n": 0} + + def flaky_get_source(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("boom") + return real_get_source(*args, **kwargs) + + monkeypatch.setattr(dispatcher, "_get_source_item", flaky_get_source) + + counts = await dispatcher.dispatch_once() + + assert counts["Dispatched"] == 1 + + +class TestWorkerStub: + async def test_stub_records_skipped_and_clears_stamp(self, assistants_table): + from apis.app_api.kb_sync import worker + + assistant_id = await _make_assistant() + doc_id = await _make_document(assistant_id) + policy = await _make_due_policy(assistant_id, source_ref=doc_id) + + result = await worker.run_sync( + {"policyId": policy.policy_id, "assistantId": assistant_id, "sourceType": "drive_file", "sourceRef": doc_id} + ) + + assert result["result"] == "skipped" + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.last_result == "skipped" + assert updated.sync_run_started_at is None diff --git a/backend/tests/lambdas/test_kb_sync_worker.py b/backend/tests/lambdas/test_kb_sync_worker.py new file mode 100644 index 00000000..86197b80 --- /dev/null +++ b/backend/tests/lambdas/test_kb_sync_worker.py @@ -0,0 +1,568 @@ +"""KB sync worker tests β€” Drive-file path (moto DynamoDB, stubbed Drive/token). + +Documents are created through the REAL document service (with import +provenance) so the worker's raw record reads/writes are cross-checked +against the actual storage schema. The Drive adapter and token +resolution are stubbed at the worker's seams. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from apis.app_api.documents.models import DocumentProvenance +from apis.app_api.documents.services.document_service import create_document +from apis.app_api.file_sources.models import ( + DownloadedFile, + FileSourceAuthError, + FileSourceError, + FileSourceNotFoundError, +) +from apis.app_api.kb_sync import worker +from apis.shared.assistants.service import create_assistant +from apis.shared.sync_policies.service import create_sync_policy, get_sync_policy + +pytestmark = pytest.mark.asyncio + +USER_ID = "user-1" +FILE_ID = "drive-file-1" +CONNECTOR_ID = "google-workspace" + + +class FakeDriveAdapter: + """Stub with the two methods the worker uses.""" + + def __init__(self, metadata=None, content=b"", metadata_error=None, download_error=None): + self.metadata = metadata or {} + self.content = content + self.metadata_error = metadata_error + self.download_error = download_error + self.download_calls = 0 + + async def get_file_metadata(self, access_token, file_id): + if self.metadata_error: + raise self.metadata_error + return self.metadata + + async def download(self, access_token, file_id): + self.download_calls += 1 + if self.download_error: + raise self.download_error + return DownloadedFile(content=self.content, filename="report.pdf", content_type="application/pdf") + + +@pytest.fixture() +def staged(monkeypatch): + """Capture S3 staging calls.""" + calls = [] + monkeypatch.setattr(worker, "_stage_to_s3", lambda *args: calls.append(args)) + return calls + + +@pytest.fixture() +def token_ok(monkeypatch): + async def fake_resolve(provider, user_id): + return "test-access-token" + + monkeypatch.setattr(worker, "_resolve_access_token", fake_resolve) + + +@pytest.fixture() +def provider_ok(monkeypatch): + provider = SimpleNamespace( + provider_id=CONNECTOR_ID, + provider_type=SimpleNamespace(value="google"), + scopes=["https://www.googleapis.com/auth/drive.readonly"], + custom_parameters=None, + ) + + async def fake_get_provider(self, provider_id): + return provider if provider_id == CONNECTOR_ID else None + + monkeypatch.setattr( + "apis.shared.oauth.provider_repository.OAuthProviderRepository.get_provider", fake_get_provider + ) + return provider + + +def _use_adapter(monkeypatch, adapter): + monkeypatch.setattr(worker.registry, "get", lambda key: adapter if key == "google-drive" else None) + + +async def _setup(assistants_table, *, etag="41", content_hash=None, chunk_count=7): + assistant = await create_assistant( + owner_id=USER_ID, owner_name="U", name="A", description="d", + instructions="i", vector_index_id="assistants-index", + ) + assistant_id = assistant.assistant_id + doc = await create_document( + assistant_id=assistant_id, + filename="report.pdf", + content_type="application/pdf", + size_bytes=10, + s3_key=f"assistants/{assistant_id}/documents/doc-1/report.pdf", + document_id="doc-1", + provenance=DocumentProvenance( + source_connector_id=CONNECTOR_ID, + source_adapter_key="google-drive", + source_file_id=FILE_ID, + imported_by_user_id=USER_ID, + source_etag=etag, + ), + ) + extra = {":cc": chunk_count} + expression = "SET chunkCount = :cc" + if content_hash: + expression += ", contentHash = :ch" + extra[":ch"] = content_hash + assistants_table.update_item( + Key={"PK": f"AST#{assistant_id}", "SK": "DOC#doc-1"}, + UpdateExpression=expression, + ExpressionAttributeValues=extra, + ) + policy = await create_sync_policy( + assistant_id=assistant_id, source_type="drive_file", source_ref="doc-1", + interval="daily", created_by_user_id=USER_ID, + ) + return assistant_id, doc, policy + + +def _payload(assistant_id, policy): + return { + "policyId": policy.policy_id, + "assistantId": assistant_id, + "sourceType": "drive_file", + "sourceRef": "doc-1", + } + + +class TestChangeDetection: + async def test_unchanged_etag_skips_download(self, assistants_table, staged, token_ok, provider_ok, monkeypatch): + adapter = FakeDriveAdapter(metadata={"version": "41", "trashed": False}) + _use_adapter(monkeypatch, adapter) + assistant_id, _, policy = await _setup(assistants_table, etag="41") + + result = await worker.run_sync(_payload(assistant_id, policy)) + + assert result["result"] == "unchanged" + assert adapter.download_calls == 0 + assert staged == [] + + async def test_same_bytes_advances_etag_without_staging( + self, assistants_table, staged, token_ok, provider_ok, monkeypatch + ): + content = b"same bytes" + adapter = FakeDriveAdapter(metadata={"version": "42", "trashed": False}, content=content) + _use_adapter(monkeypatch, adapter) + assistant_id, _, policy = await _setup( + assistants_table, etag="41", content_hash=worker._sha256(content) + ) + + result = await worker.run_sync(_payload(assistant_id, policy)) + + assert result["result"] == "unchanged" + assert adapter.download_calls == 1 + assert staged == [] + doc = assistants_table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": "DOC#doc-1"})["Item"] + assert doc["sourceEtag"] == "42" # gate 1 passes next run + + async def test_changed_bytes_staged_with_stash(self, assistants_table, staged, token_ok, provider_ok, monkeypatch): + adapter = FakeDriveAdapter(metadata={"version": "42", "trashed": False}, content=b"new bytes") + _use_adapter(monkeypatch, adapter) + assistant_id, doc, policy = await _setup(assistants_table, etag="41", chunk_count=7) + + result = await worker.run_sync(_payload(assistant_id, policy)) + + assert result["result"] == "changed" + assert len(staged) == 1 + s3_key, content, content_type = staged[0] + assert s3_key == doc.s3_key + assert content == b"new bytes" + item = assistants_table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": "DOC#doc-1"})["Item"] + assert item["sourceEtag"] == "42" + assert item["contentHash"] == worker._sha256(b"new bytes") + assert item["previousChunkCount"] == 7 + assert "lastSyncedAt" in item + updated_policy = await get_sync_policy(assistant_id, policy.policy_id) + assert updated_policy.last_result == "changed" + assert updated_policy.consecutive_failures == 0 + + async def test_trashed_is_grace_skip(self, assistants_table, staged, token_ok, provider_ok, monkeypatch): + adapter = FakeDriveAdapter(metadata={"version": "42", "trashed": True}) + _use_adapter(monkeypatch, adapter) + assistant_id, _, policy = await _setup(assistants_table) + + result = await worker.run_sync(_payload(assistant_id, policy)) + + assert result["result"] == "skipped" + assert adapter.download_calls == 0 + + +class TestFailureModes: + async def test_not_found_strikes_counter(self, assistants_table, staged, token_ok, provider_ok, monkeypatch): + adapter = FakeDriveAdapter(metadata_error=FileSourceNotFoundError("gone or unshared")) + _use_adapter(monkeypatch, adapter) + assistant_id, _, policy = await _setup(assistants_table) + + result = await worker.run_sync(_payload(assistant_id, policy)) + + assert result["result"] == "failed" + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.consecutive_not_found == 1 + assert updated.consecutive_failures == 1 + assert updated.state == "active" # dispatcher pauses at the 2-strike breaker + + async def test_auth_error_pauses_reauth(self, assistants_table, staged, token_ok, provider_ok, monkeypatch): + adapter = FakeDriveAdapter(metadata_error=FileSourceAuthError("401 invalid token")) + _use_adapter(monkeypatch, adapter) + assistant_id, _, policy = await _setup(assistants_table) + + result = await worker.run_sync(_payload(assistant_id, policy)) + + assert result["result"] == "paused_reauth" + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.state == "paused_reauth" + assert "Reconnect" in updated.state_reason + assert updated.consecutive_failures == 0 # reauth is not a failure streak + assert updated.sync_run_started_at is None + # Marker lets consent completion find this policy without a scan + marker = assistants_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SYNCREAUTH#{policy.policy_id}"} + ).get("Item") + assert marker is not None + assert marker["providerId"] == CONNECTOR_ID + assert marker["assistantId"] == assistant_id + + async def test_requires_consent_pauses_reauth(self, assistants_table, staged, provider_ok, monkeypatch): + async def no_token(provider, user_id): + return None + + monkeypatch.setattr(worker, "_resolve_access_token", no_token) + adapter = FakeDriveAdapter(metadata={"version": "42"}) + _use_adapter(monkeypatch, adapter) + assistant_id, _, policy = await _setup(assistants_table) + + result = await worker.run_sync(_payload(assistant_id, policy)) + + assert result["result"] == "paused_reauth" + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.state == "paused_reauth" + marker = assistants_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SYNCREAUTH#{policy.policy_id}"} + ).get("Item") + assert marker is not None + assert marker["providerId"] == CONNECTOR_ID + + async def test_missing_provider_pauses_error(self, assistants_table, staged, token_ok, monkeypatch): + async def no_provider(self, provider_id): + return None + + monkeypatch.setattr( + "apis.shared.oauth.provider_repository.OAuthProviderRepository.get_provider", no_provider + ) + _use_adapter(monkeypatch, FakeDriveAdapter()) + assistant_id, _, policy = await _setup(assistants_table) + + result = await worker.run_sync(_payload(assistant_id, policy)) + + assert result["result"] == "skipped" + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.state == "paused_error" + assert "connector" in updated.state_reason + + async def test_drive_error_records_failed(self, assistants_table, staged, token_ok, provider_ok, monkeypatch): + adapter = FakeDriveAdapter(metadata_error=FileSourceError("500 backend error")) + _use_adapter(monkeypatch, adapter) + assistant_id, _, policy = await _setup(assistants_table) + + result = await worker.run_sync(_payload(assistant_id, policy)) + + assert result["result"] == "failed" + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.consecutive_failures == 1 + assert updated.consecutive_not_found == 0 + + async def test_unexpected_exception_still_records_run( + self, assistants_table, staged, token_ok, provider_ok, monkeypatch + ): + class ExplodingAdapter(FakeDriveAdapter): + async def get_file_metadata(self, access_token, file_id): + raise RuntimeError("boom") + + _use_adapter(monkeypatch, ExplodingAdapter()) + assistant_id, _, policy = await _setup(assistants_table) + + result = await worker.run_sync(_payload(assistant_id, policy)) + + assert result["result"] == "failed" + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.sync_run_started_at is None # stamp always clears + + async def test_missing_policy_drops_run(self, assistants_table): + result = await worker.run_sync( + {"policyId": "syn-missing", "assistantId": "ast-x", "sourceType": "drive_file", "sourceRef": "doc-1"} + ) + assert result["result"] == "dropped" + + async def test_web_crawl_still_skips(self, assistants_table): + assistant = await create_assistant( + owner_id=USER_ID, owner_name="U", name="A", description="d", + instructions="i", vector_index_id="assistants-index", + ) + policy = await create_sync_policy( + assistant_id=assistant.assistant_id, source_type="web_crawl", source_ref="crawl-1", + interval="daily", created_by_user_id=USER_ID, + ) + + result = await worker.run_sync( + {"policyId": policy.policy_id, "assistantId": assistant.assistant_id, + "sourceType": "web_crawl", "sourceRef": "crawl-1"} + ) + + assert result["result"] == "skipped" + + +class TestShrinkageCleanup: + async def test_pop_previous_chunk_count_is_atomic(self, assistants_table, monkeypatch): + from apis.app_api.documents.ingestion.status import DocumentStatusManager + + assistant = await create_assistant( + owner_id=USER_ID, owner_name="U", name="A", description="d", + instructions="i", vector_index_id="assistants-index", + ) + assistant_id = assistant.assistant_id + await create_document( + assistant_id=assistant_id, filename="f.pdf", content_type="application/pdf", + size_bytes=1, s3_key="k", document_id="doc-1", + ) + assistants_table.update_item( + Key={"PK": f"AST#{assistant_id}", "SK": "DOC#doc-1"}, + UpdateExpression="SET previousChunkCount = :p", + ExpressionAttributeValues={":p": 9}, + ) + + manager = DocumentStatusManager(table_name=assistants_table.table_name) + first = await manager.pop_previous_chunk_count(assistant_id=assistant_id, document_id="doc-1") + second = await manager.pop_previous_chunk_count(assistant_id=assistant_id, document_id="doc-1") + + assert first == 9 + assert second is None + item = assistants_table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": "DOC#doc-1"})["Item"] + assert "previousChunkCount" not in item + + async def test_delete_vector_tail_sends_exact_range(self, monkeypatch): + from apis.shared.embeddings import bedrock_embeddings + + deleted_batches = [] + + class FakeClient: + def delete_vectors(self, vectorBucketName, indexName, keys): + deleted_batches.append(keys) + + with patch.object(bedrock_embeddings, "_get_vector_store_bucket", return_value="vb"), patch.object( + bedrock_embeddings, "_get_vector_store_index", return_value="vi" + ), patch.object(bedrock_embeddings.boto3, "client", return_value=FakeClient()): + count = await bedrock_embeddings.delete_vector_tail("doc-1", 3, 7) + + assert count == 4 + assert deleted_batches == [["doc-1#3", "doc-1#4", "doc-1#5", "doc-1#6"]] + + async def test_delete_vector_tail_empty_range_noop(self, monkeypatch): + from apis.shared.embeddings import bedrock_embeddings + + with patch.object(bedrock_embeddings.boto3, "client") as client: + count = await bedrock_embeddings.delete_vector_tail("doc-1", 5, 5) + + assert count == 0 + client.assert_not_called() + + +class TestWebCrawlSync: + """Worker web re-crawl orchestration (crawler stubbed at the seam; + crawl job + documents live in moto through the real repositories).""" + + ROOT = "https://example.com/" + + async def _setup_crawl(self, assistants_table, *, page_urls=()): + from apis.app_api.web_sources.crawl_repository import create_crawl_job, finalize_crawl + from apis.app_api.web_sources.models import CrawlSettings + + assistant = await create_assistant( + owner_id=USER_ID, owner_name="U", name="A", description="d", + instructions="i", vector_index_id="assistants-index", + ) + assistant_id = assistant.assistant_id + job = await create_crawl_job( + assistant_id=assistant_id, root_url=self.ROOT, + settings=CrawlSettings(max_depth=1, max_pages=10), + started_by_user_id=USER_ID, + ) + await finalize_crawl(assistant_id=assistant_id, crawl_id=job.crawl_id, status="complete") + + for i, url in enumerate((self.ROOT, *page_urls)): + doc_id = f"doc-web-{i}" + await create_document( + assistant_id=assistant_id, filename=f"p{i}.md", content_type="text/markdown", + size_bytes=1, s3_key=f"assistants/{assistant_id}/documents/{doc_id}/p{i}.md", + document_id=doc_id, + provenance=DocumentProvenance( + source_connector_id="web", source_adapter_key="http", + source_file_id=url, imported_by_user_id=USER_ID, + ), + ) + + policy = await create_sync_policy( + assistant_id=assistant_id, source_type="web_crawl", source_ref=job.crawl_id, + interval="daily", created_by_user_id=USER_ID, + ) + return assistant_id, job, policy + + def _payload(self, assistant_id, policy, job): + return { + "policyId": policy.policy_id, + "assistantId": assistant_id, + "sourceType": "web_crawl", + "sourceRef": job.crawl_id, + } + + @pytest.fixture() + def fake_crawl(self, monkeypatch): + """Replace run_crawl with a script: which URLs are seen, which emit + which outcome.""" + script = {"seen": [], "emit": []} # emit: (url, outcome, etag, hash) + captured = {} + + async def fake_run_crawl(**kwargs): + captured.update(kwargs) + refresh = kwargs["refresh"] + for url in script["seen"]: + refresh.seen_urls.add(url) + for url, outcome, etag, content_hash in script["emit"]: + doc = refresh.docs.get(url) + doc_id = doc.document_id if doc else "doc-new" + await refresh._emit(url, doc_id, outcome, etag, content_hash) + + from apis.app_api.web_sources import crawler + monkeypatch.setattr(crawler, "run_crawl", fake_run_crawl) + script["captured"] = captured + return script + + async def test_changed_page_stashes_and_records_changed(self, assistants_table, fake_crawl): + assistant_id, job, policy = await self._setup_crawl(assistants_table) + assistants_table.update_item( + Key={"PK": f"AST#{assistant_id}", "SK": "DOC#doc-web-0"}, + UpdateExpression="SET chunkCount = :c", + ExpressionAttributeValues={":c": 4}, + ) + fake_crawl["seen"] = [self.ROOT] + fake_crawl["emit"] = [(self.ROOT, "changed", '"e2"', "hash2")] + + result = await worker.run_sync(self._payload(assistant_id, policy, job)) + + assert result["result"] == "changed" + item = assistants_table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": "DOC#doc-web-0"})["Item"] + assert item["previousChunkCount"] == 4 + assert item["sourceEtag"] == '"e2"' + assert item["contentHash"] == "hash2" + # crawler invoked in refresh mode without TTL finalization + assert fake_crawl["captured"]["finalize_with_ttl"] is False + assert fake_crawl["captured"]["settings"].max_pages == 10 + + async def test_recrawl_reset_removes_job_ttl(self, assistants_table, fake_crawl): + assistant_id, job, policy = await self._setup_crawl(assistants_table) + # finalize_crawl(set_ttl=True) above put a ttl on the terminal job + before = assistants_table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": f"CRAWL#{job.crawl_id}"})["Item"] + assert "ttl" in before + fake_crawl["seen"] = [self.ROOT] + + await worker.run_sync(self._payload(assistant_id, policy, job)) + + after = assistants_table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": f"CRAWL#{job.crawl_id}"})["Item"] + assert "ttl" not in after + assert after["discoveredCount"] == 0 and after["fetchedCount"] == 0 + + async def test_all_unchanged_records_unchanged(self, assistants_table, fake_crawl): + assistant_id, job, policy = await self._setup_crawl(assistants_table) + fake_crawl["seen"] = [self.ROOT] + fake_crawl["emit"] = [(self.ROOT, "unchanged", '"e1"', None)] + + result = await worker.run_sync(self._payload(assistant_id, policy, job)) + + assert result["result"] == "unchanged" + + async def test_miss_counts_then_deletes_on_second_run(self, assistants_table, fake_crawl, monkeypatch): + from unittest.mock import AsyncMock + + cleanup = AsyncMock() + monkeypatch.setattr( + "apis.app_api.documents.services.cleanup_service.cleanup_document_resources", cleanup + ) + gone_url = "https://example.com/gone" + assistant_id, job, policy = await self._setup_crawl(assistants_table, page_urls=(gone_url,)) + # Root seen, /gone absent + fake_crawl["seen"] = [self.ROOT] + + await worker.run_sync(self._payload(assistant_id, policy, job)) + item = assistants_table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": "DOC#doc-web-1"})["Item"] + assert item["consecutiveMisses"] == 1 + assert item["status"] != "deleting" + cleanup.assert_not_awaited() + + # Policy re-armed a day out by the first run's rearm... but the fake + # crawl doesn't touch the policy; run directly again. + result = await worker.run_sync(self._payload(assistant_id, policy, job)) + + item = assistants_table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": "DOC#doc-web-1"})["Item"] + assert item["status"] == "deleting" + cleanup.assert_awaited_once() + assert result["result"] == "changed" # a deletion is a change + + async def test_seen_again_resets_miss_counter(self, assistants_table, fake_crawl): + flaky_url = "https://example.com/flaky" + assistant_id, job, policy = await self._setup_crawl(assistants_table, page_urls=(flaky_url,)) + fake_crawl["seen"] = [self.ROOT] + await worker.run_sync(self._payload(assistant_id, policy, job)) + + fake_crawl["seen"] = [self.ROOT, flaky_url] + await worker.run_sync(self._payload(assistant_id, policy, job)) + + item = assistants_table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": "DOC#doc-web-1"})["Item"] + assert item["consecutiveMisses"] == 0 + assert item["status"] != "deleting" + + async def test_missing_job_pauses_policy(self, assistants_table, fake_crawl): + assistant_id, job, policy = await self._setup_crawl(assistants_table) + assistants_table.delete_item(Key={"PK": f"AST#{assistant_id}", "SK": f"CRAWL#{job.crawl_id}"}) + + result = await worker.run_sync(self._payload(assistant_id, policy, job)) + + assert result["result"] == "skipped" + updated = await get_sync_policy(assistant_id, policy.policy_id) + assert updated.state == "paused_error" + + async def test_missing_root_doc_is_recreated(self, assistants_table, fake_crawl): + from apis.app_api.web_sources.crawl_repository import create_crawl_job + from apis.app_api.web_sources.models import CrawlSettings + + assistant = await create_assistant( + owner_id=USER_ID, owner_name="U", name="A", description="d", + instructions="i", vector_index_id="assistants-index", + ) + assistant_id = assistant.assistant_id + job = await create_crawl_job( + assistant_id=assistant_id, root_url=self.ROOT, + settings=CrawlSettings(), started_by_user_id=USER_ID, + ) + policy = await create_sync_policy( + assistant_id=assistant_id, source_type="web_crawl", source_ref=job.crawl_id, + interval="daily", created_by_user_id=USER_ID, + ) + fake_crawl["seen"] = [self.ROOT] + + await worker.run_sync(self._payload(assistant_id, policy, job)) + + root_doc_id = fake_crawl["captured"]["root_document_id"] + item = assistants_table.get_item(Key={"PK": f"AST#{assistant_id}", "SK": f"DOC#{root_doc_id}"})["Item"] + assert item["sourceFileId"] == self.ROOT + assert item["sourceConnectorId"] == "web" diff --git a/backend/tests/lambdas/test_scheduled_runs_dispatcher.py b/backend/tests/lambdas/test_scheduled_runs_dispatcher.py new file mode 100644 index 00000000..3703ab96 --- /dev/null +++ b/backend/tests/lambdas/test_scheduled_runs_dispatcher.py @@ -0,0 +1,209 @@ +"""Scheduled-runs dispatcher tests (moto DynamoDB, stubbed worker invoke). + +Mirrors backend/tests/lambdas/test_kb_sync_dispatcher.py's structure: due +sweep, runaway guard (incl. UTC date rollover), conditional rearm +win/lose, and invoke fan-out β€” using the REAL scheduled_prompts service so +the dispatcher's assumptions about that data plane are cross-checked +against the actual storage schema. +""" + +from datetime import date, datetime, timedelta, timezone + +import pytest + +from apis.shared.scheduled_prompts.service import ( + get_scheduled_prompt, + create_scheduled_prompt, + set_schedule_state, +) +from lambdas.scheduled_runs_dispatcher import dispatcher + +pytestmark = pytest.mark.asyncio + +USER_ID = "user-1" +PAST = "2000-01-01T00:00:00Z" + + +@pytest.fixture(autouse=True) +def scheduled_runs_env(monkeypatch): + monkeypatch.setenv("SCHEDULED_RUNS_ENABLED", "true") + monkeypatch.setenv("SCHEDULED_RUNS_WORKER_FUNCTION_NAME", "test-scheduled-runs-worker") + + +@pytest.fixture() +def invoked_workers(monkeypatch): + """Capture worker invocations instead of calling Lambda.""" + payloads = [] + monkeypatch.setattr(dispatcher, "_invoke_worker", payloads.append) + return payloads + + +@pytest.fixture(autouse=True) +def no_metrics(monkeypatch): + monkeypatch.setattr(dispatcher, "_emit_metrics", lambda counts: None) + + +async def _make_due_schedule(sessions_metadata_table, *, max_runs_per_day=24, label="Morning Briefing"): + schedule = await create_scheduled_prompt( + user_id=USER_ID, + label=label, + prompt_text="Summarize my day", + cadence="daily", + hour_local=9, + timezone_name="America/Boise", + ) + # Force it due immediately (create_scheduled_prompt always computes a + # future next_run_at) and set the runaway-guard ceiling for this test. + sessions_metadata_table.update_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"}, + UpdateExpression="SET nextRunAt = :past, GSI3_SK = :gsisk, maxRunsPerDay = :max", + ExpressionAttributeValues={ + ":past": PAST, + ":gsisk": f"{PAST}#{schedule.schedule_id}", + ":max": max_runs_per_day, + }, + ) + return await get_scheduled_prompt(USER_ID, schedule.schedule_id) + + +class TestKillSwitch: + async def test_disabled_tick_is_noop(self, sessions_metadata_table, invoked_workers, monkeypatch): + monkeypatch.setenv("SCHEDULED_RUNS_ENABLED", "false") + await _make_due_schedule(sessions_metadata_table) + + counts = await dispatcher.dispatch_once() + + assert counts["SchedulesDue"] == 0 + assert invoked_workers == [] + + +class TestRunawayGuard: + async def test_exceeding_ceiling_pauses_instead_of_dispatching(self, sessions_metadata_table, invoked_workers): + schedule = await _make_due_schedule(sessions_metadata_table, max_runs_per_day=3) + sessions_metadata_table.update_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"}, + UpdateExpression="SET runsToday = :n, runsTodayDate = :today", + ExpressionAttributeValues={":n": 3, ":today": date.today().isoformat()}, + ) + + counts = await dispatcher.dispatch_once() + + assert counts["PausedRunaway"] == 1 + assert invoked_workers == [] + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.state == "paused_error" + assert updated.state_reason == "max_runs_per_day_exceeded" + + async def test_under_ceiling_still_dispatches(self, sessions_metadata_table, invoked_workers): + schedule = await _make_due_schedule(sessions_metadata_table, max_runs_per_day=3) + sessions_metadata_table.update_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"}, + UpdateExpression="SET runsToday = :n, runsTodayDate = :today", + ExpressionAttributeValues={":n": 2, ":today": date.today().isoformat()}, + ) + + counts = await dispatcher.dispatch_once() + + assert counts["Dispatched"] == 1 + assert counts["PausedRunaway"] == 0 + + async def test_stale_date_rollover_resets_counter(self, sessions_metadata_table, invoked_workers): + """runsToday from a previous UTC day must not count against today's + ceiling β€” the rollover-aware counter treats it as zero.""" + schedule = await _make_due_schedule(sessions_metadata_table, max_runs_per_day=1) + sessions_metadata_table.update_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"}, + UpdateExpression="SET runsToday = :n, runsTodayDate = :yesterday", + ExpressionAttributeValues={":n": 5, ":yesterday": "2000-01-01"}, + ) + + counts = await dispatcher.dispatch_once() + + assert counts["Dispatched"] == 1 + assert counts["PausedRunaway"] == 0 + + +class TestConditionalRearm: + async def test_happy_path_dispatches_and_rearms(self, sessions_metadata_table, invoked_workers): + schedule = await _make_due_schedule(sessions_metadata_table) + + counts = await dispatcher.dispatch_once() + + assert counts["Dispatched"] == 1 + assert invoked_workers == [{"scheduleId": schedule.schedule_id, "userId": USER_ID}] + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.next_run_at > datetime.now(timezone.utc).isoformat() + + async def test_dispatched_schedule_not_redispatched_same_tick(self, sessions_metadata_table, invoked_workers): + await _make_due_schedule(sessions_metadata_table) + + await dispatcher.dispatch_once() + counts = await dispatcher.dispatch_once() + + # Re-armed a day out (daily cadence) β€” second tick sees nothing due. + assert counts["SchedulesDue"] == 0 + assert len(invoked_workers) == 1 + + async def test_lost_conditional_write_skips_without_double_invoke( + self, sessions_metadata_table, invoked_workers, monkeypatch + ): + """Simulate a double-fired tick: another dispatcher already won the + conditional rearm before this call runs `_dispatch_schedule`.""" + schedule = await _make_due_schedule(sessions_metadata_table) + + real_rearm = dispatcher.rearm_schedule + + async def rearm_and_lose(*args, **kwargs): + # A concurrent dispatcher wins first β€” real rearm succeeds once, + # then this call's own attempt (with the stale expected value) + # would lose. Simplest simulation: force False directly. + return False + + monkeypatch.setattr(dispatcher, "rearm_schedule", rearm_and_lose) + + counts = await dispatcher.dispatch_once() + + assert counts["RearmLost"] == 1 + assert invoked_workers == [] + # next_run_at untouched β€” schedule remains due for the next tick. + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.next_run_at == PAST + assert real_rearm is not None # sanity: we didn't lose the reference + + +class TestBrokenScheduleIsolation: + async def test_broken_schedule_does_not_starve_sweep(self, sessions_metadata_table, invoked_workers, monkeypatch): + schedule_a = await _make_due_schedule(sessions_metadata_table, label="A") + schedule_b = await _make_due_schedule(sessions_metadata_table, label="B") + + real_rearm = dispatcher.rearm_schedule + calls = {"n": 0} + + async def flaky_rearm(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("boom") + return await real_rearm(*args, **kwargs) + + monkeypatch.setattr(dispatcher, "rearm_schedule", flaky_rearm) + + counts = await dispatcher.dispatch_once() + + assert counts["Dispatched"] == 1 + assert counts["SchedulesDue"] == 2 + dispatched_ids = {p["scheduleId"] for p in invoked_workers} + assert dispatched_ids <= {schedule_a.schedule_id, schedule_b.schedule_id} + + +class TestCadenceRearm: + async def test_next_run_at_uses_schedule_cadence(self, sessions_metadata_table, invoked_workers): + schedule = await _make_due_schedule(sessions_metadata_table) + + await dispatcher.dispatch_once() + + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + next_dt = datetime.fromisoformat(updated.next_run_at.rstrip("Z")).replace(tzinfo=timezone.utc) + # Daily at 9am America/Boise from "now" (past due) lands within the + # next ~48h β€” a loose bound that just proves cadence math ran + # (not the fallback delta, which would be ~1h out). + assert timedelta(hours=1) < (next_dt - datetime.now(timezone.utc)) < timedelta(hours=48) diff --git a/backend/tests/lambdas/test_scheduled_runs_worker.py b/backend/tests/lambdas/test_scheduled_runs_worker.py new file mode 100644 index 00000000..4900ef94 --- /dev/null +++ b/backend/tests/lambdas/test_scheduled_runs_worker.py @@ -0,0 +1,237 @@ +"""Scheduled-runs worker tests (moto DynamoDB, stubbed run_agent_headless). + +Mirrors backend/tests/lambdas/test_kb_sync_worker.py's structure: the real +scheduled_prompts service manages schedule state so the worker's raw +bookkeeping is cross-checked against the actual storage schema; only +run_agent_headless (the harness's HTTP/SSE boundary) is stubbed. +""" + +from datetime import datetime, timezone + +import pytest + +from apis.shared.harness.auth import HeadlessAuthError +from apis.shared.harness.models import OAuthConsentRequired, RunResult +from apis.shared.scheduled_prompts.service import create_scheduled_prompt, get_scheduled_prompt +from lambdas.scheduled_runs_worker import worker + +pytestmark = pytest.mark.asyncio + +USER_ID = "user-1" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _result(status: str, **overrides) -> RunResult: + base = dict( + run_id="run-1", + session_id="sess-1", + user_id=USER_ID, + status=status, + started_at=_now_iso(), + finished_at=_now_iso(), + ) + base.update(overrides) + return RunResult(**base) + + +async def _make_schedule(**overrides): + kwargs = dict( + user_id=USER_ID, + label="Morning Briefing", + prompt_text="Summarize my day", + cadence="daily", + hour_local=9, + timezone_name="America/Boise", + ) + kwargs.update(overrides) + return await create_scheduled_prompt(**kwargs) + + +def _payload(schedule): + return {"scheduleId": schedule.schedule_id, "userId": USER_ID} + + +class TestSuccess: + async def test_completed_run_records_session(self, sessions_metadata_table, bff_sessions_table, monkeypatch): + schedule = await _make_schedule() + + async def fake_run(**kwargs): + assert kwargs["user_id"] == USER_ID + assert kwargs["prompt"] == schedule.prompt_text + assert kwargs["trigger"] == "schedule" + return _result("completed", session_id="sess-abc") + + monkeypatch.setattr(worker, "run_agent_headless", fake_run) + + result = await worker.run_schedule(_payload(schedule)) + + assert result["result"] == "completed" + assert result["sessionId"] == "sess-abc" + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.last_run_status == "completed" + assert updated.last_run_session_id == "sess-abc" + assert updated.state == "active" + + +class TestHeadlessAuthFailure: + async def test_auth_error_pauses_reauth_required(self, sessions_metadata_table, bff_sessions_table, monkeypatch): + schedule = await _make_schedule() + + async def fake_run(**kwargs): + raise HeadlessAuthError("no active grant") + + monkeypatch.setattr(worker, "run_agent_headless", fake_run) + + result = await worker.run_schedule(_payload(schedule)) + + assert result["result"] == "paused_error" + assert result["reason"] == "reauth_required" + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.state == "paused_error" + assert updated.state_reason == "reauth_required" + assert updated.last_run_status == "error" + + +class TestOAuthRequired: + async def test_oauth_required_pauses(self, sessions_metadata_table, bff_sessions_table, monkeypatch): + schedule = await _make_schedule() + + async def fake_run(**kwargs): + return _result( + "oauth_required", + oauth_required=[ + OAuthConsentRequired(provider_id="google-drive", authorization_url="https://example.com/consent") + ], + ) + + monkeypatch.setattr(worker, "run_agent_headless", fake_run) + + result = await worker.run_schedule(_payload(schedule)) + + assert result["result"] == "paused_error" + assert result["reason"] == "oauth_required" + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.state == "paused_error" + assert updated.state_reason == "oauth_required" + assert "google-drive" in (updated.last_error or "") + assert "https://example.com/consent" in (updated.last_error or "") + + +class TestGenericFailure: + async def test_single_error_records_without_pausing(self, sessions_metadata_table, bff_sessions_table, monkeypatch): + schedule = await _make_schedule() + + async def fake_run(**kwargs): + return _result("error", error="transport: boom") + + monkeypatch.setattr(worker, "run_agent_headless", fake_run) + + result = await worker.run_schedule(_payload(schedule)) + + assert result["result"] == "error" + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.state == "active" # not paused on an isolated failure + assert updated.last_run_status == "error" + + async def test_repeated_failures_pause(self, sessions_metadata_table, bff_sessions_table, monkeypatch): + schedule = await _make_schedule() + + async def fake_run(**kwargs): + return _result("error", error="transport: boom") + + monkeypatch.setattr(worker, "run_agent_headless", fake_run) + monkeypatch.setenv("SCHEDULED_RUNS_MAX_FAILURES", "2") + + first = await worker.run_schedule(_payload(schedule)) + assert first["result"] == "error" + + second = await worker.run_schedule(_payload(schedule)) + + assert second["result"] == "paused_error" + assert second["reason"] == "repeated_failures" + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.state == "paused_error" + assert updated.state_reason == "repeated_failures" + + async def test_repeated_failures_pause_at_default_threshold( + self, sessions_metadata_table, bff_sessions_table, monkeypatch + ): + """The breaker must trip at the PRODUCTION default (3), not only at 2. + + Regression guard: the original last-status proxy capped the streak at + 2, so with the default SCHEDULED_RUNS_MAX_FAILURES=3 it could never + pause. The persistent counter must reach 3. + """ + schedule = await _make_schedule() + + async def fake_run(**kwargs): + return _result("error", error="transport: boom") + + monkeypatch.setattr(worker, "run_agent_headless", fake_run) + # No SCHEDULED_RUNS_MAX_FAILURES override β€” exercise the default of 3. + + assert (await worker.run_schedule(_payload(schedule)))["result"] == "error" + assert (await worker.run_schedule(_payload(schedule)))["result"] == "error" + third = await worker.run_schedule(_payload(schedule)) + + assert third["result"] == "paused_error" + assert third["reason"] == "repeated_failures" + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.state == "paused_error" + assert updated.consecutive_failures == 3 + + async def test_completed_run_resets_failure_streak( + self, sessions_metadata_table, bff_sessions_table, monkeypatch + ): + """A successful run clears the streak so old failures don't accumulate.""" + schedule = await _make_schedule() + statuses = iter(["error", "completed"]) + + async def fake_run(**kwargs): + return _result(next(statuses), session_id="sess-x") + + monkeypatch.setattr(worker, "run_agent_headless", fake_run) + + await worker.run_schedule(_payload(schedule)) # error -> streak 1 + await worker.run_schedule(_payload(schedule)) # completed -> streak 0 + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.consecutive_failures == 0 + assert updated.state == "active" + + async def test_timeout_status_treated_as_failure(self, sessions_metadata_table, bff_sessions_table, monkeypatch): + schedule = await _make_schedule() + + async def fake_run(**kwargs): + return _result("timeout", error="stream exceeded 300s budget") + + monkeypatch.setattr(worker, "run_agent_headless", fake_run) + + result = await worker.run_schedule(_payload(schedule)) + + assert result["result"] == "timeout" + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.last_run_status == "error" + + async def test_unexpected_exception_still_records_run(self, sessions_metadata_table, bff_sessions_table, monkeypatch): + schedule = await _make_schedule() + + async def exploding_run(**kwargs): + raise RuntimeError("kaboom") + + monkeypatch.setattr(worker, "run_agent_headless", exploding_run) + + result = await worker.run_schedule(_payload(schedule)) + + assert result["result"] == "error" + updated = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert updated.last_run_status == "error" + assert "kaboom" in (updated.last_error or "") + + +class TestMissingSchedule: + async def test_missing_schedule_drops_run(self, sessions_metadata_table, bff_sessions_table): + result = await worker.run_schedule({"scheduleId": "sched-missing", "userId": USER_ID}) + assert result["result"] == "dropped" diff --git a/backend/tests/rbac/test_app_role_service.py b/backend/tests/rbac/test_app_role_service.py index 55d4bca7..5f786a2c 100644 --- a/backend/tests/rbac/test_app_role_service.py +++ b/backend/tests/rbac/test_app_role_service.py @@ -329,3 +329,78 @@ async def test_only_enabled_roles_merged(service, mock_app_role_repo, mock_app_r assert "tool_a" in perms.tools assert "tool_secret" not in perms.tools assert "viewer" not in perms.app_roles + + +# --------------------------------------------------------------------------- +# filter_requested_tools β€” narrow-never-grant intersection of a client list +# --------------------------------------------------------------------------- + +def _wire_single_role(mock_app_role_repo, role): + """Point the mock repo at one role mapped from the user's 'Editor' JWT role.""" + mock_app_role_repo.get_roles_for_jwt_role.side_effect = lambda r: ( + [role.role_id] if r == "Editor" else [] + ) + mock_app_role_repo.get_role.side_effect = lambda rid: ( + role if rid == role.role_id else None + ) + + +@pytest.mark.asyncio +async def test_filter_requested_tools_drops_ungranted( + service, mock_app_role_repo, make_app_role, user +): + """A requested tool outside the user's grant is silently dropped.""" + _wire_single_role(mock_app_role_repo, make_app_role(role_id="editor", tools=["tool_a", "tool_b"])) + + result = await service.filter_requested_tools(user, ["tool_a", "tool_secret", "tool_b"]) + + assert result == ["tool_a", "tool_b"] + + +@pytest.mark.asyncio +async def test_filter_requested_tools_preserves_caller_order( + service, mock_app_role_repo, make_app_role, user +): + """The caller's ordering is preserved, not the grant's.""" + _wire_single_role(mock_app_role_repo, make_app_role(role_id="editor", tools=["tool_a", "tool_b", "tool_c"])) + + result = await service.filter_requested_tools(user, ["tool_c", "tool_a"]) + + assert result == ["tool_c", "tool_a"] + + +@pytest.mark.asyncio +async def test_filter_requested_tools_wildcard_passes_everything( + service, mock_app_role_repo, make_app_role, user +): + """A ``*`` grant admits any requested tool (including ones not enumerated).""" + _wire_single_role(mock_app_role_repo, make_app_role(role_id="admin", tools=["*"])) + + requested = ["tool_a", "gateway_anything", "some_admin_tool"] + result = await service.filter_requested_tools(user, requested) + + assert result == requested + + +@pytest.mark.asyncio +async def test_filter_requested_tools_scoped_id_allowed_by_base_grant( + service, mock_app_role_repo, make_app_role, user +): + """A scoped id (base::tool) passes when its base server id is granted.""" + _wire_single_role(mock_app_role_repo, make_app_role(role_id="editor", tools=["gateway_wikipedia"])) + + result = await service.filter_requested_tools(user, ["gateway_wikipedia::search"]) + + assert result == ["gateway_wikipedia::search"] + + +@pytest.mark.asyncio +async def test_filter_requested_tools_empty_grant_drops_all( + service, mock_app_role_repo, make_app_role, user +): + """No grant means an empty result β€” never a passthrough.""" + _wire_single_role(mock_app_role_repo, make_app_role(role_id="editor", tools=[])) + + result = await service.filter_requested_tools(user, ["tool_a", "tool_b"]) + + assert result == [] diff --git a/backend/tests/routes/test_agents.py b/backend/tests/routes/test_agents.py new file mode 100644 index 00000000..f9aa215a --- /dev/null +++ b/backend/tests/routes/test_agents.py @@ -0,0 +1,234 @@ +"""Tests for the /agents alias surface (Agent Designer Phase 1, PR-3). + +Covers the AGENTS_API_ENABLED 404-gate, the Agent projection (agentId + bindings + +modelConfig), and CRUD/shares parity with /assistants. Services are patched in the +agents routes module namespace. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apis.app_api.agent_designer.routes import router +from apis.shared.assistants.models import AgentBinding, AgentModelConfig, Assistant +from tests.routes.conftest import mock_auth_user + +ROUTES_MODULE = "apis.app_api.agent_designer.routes" + + +def _make_assistant(**overrides) -> Assistant: + defaults = dict( + assistantId="ast-001", + ownerId="user-001", + ownerName="Test User", + name="My Agent", + description="A helpful agent", + instructions="You are helpful.", + vectorIndexId="idx-001", + visibility="PRIVATE", + tags=["test"], + starters=["Hi"], + emoji="πŸ€–", + usageCount=0, + createdAt="2024-01-01T00:00:00Z", + updatedAt="2024-01-01T00:00:00Z", + status="COMPLETE", + ) + defaults.update(overrides) + return Assistant.model_validate(defaults) + + +@pytest.fixture +def app(): + _app = FastAPI() + _app.include_router(router) + return _app + + +@pytest.fixture +def _flag_on(monkeypatch): + monkeypatch.setenv("AGENTS_API_ENABLED", "true") + + +# --------------------------------------------------------------------------- gate +class TestFeatureGate: + def test_404_when_flag_off(self, app, make_user, monkeypatch): + monkeypatch.setenv("AGENTS_API_ENABLED", "false") + mock_auth_user(app, make_user()) # auth passes; the gate itself 404s + with patch(f"{ROUTES_MODULE}.list_user_assistants", new_callable=AsyncMock, return_value=([], None)): + resp = TestClient(app).get("/agents") + assert resp.status_code == 404 + + def test_available_when_flag_on(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + with patch(f"{ROUTES_MODULE}.list_user_assistants", new_callable=AsyncMock, return_value=([], None)), patch( + f"{ROUTES_MODULE}.list_shared_with_user", new_callable=AsyncMock, return_value=[] + ): + resp = TestClient(app).get("/agents") + assert resp.status_code == 200 + assert resp.json()["agents"] == [] + + +# --------------------------------------------------------------------------- projection +class TestAgentProjection: + def test_list_projects_agent_id_and_synthesizes_kb_binding(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + with patch( + f"{ROUTES_MODULE}.list_user_assistants", new_callable=AsyncMock, return_value=([_make_assistant()], None) + ), patch(f"{ROUTES_MODULE}.list_shared_with_user", new_callable=AsyncMock, return_value=[]): + resp = TestClient(app).get("/agents") + assert resp.status_code == 200 + agent = resp.json()["agents"][0] + assert agent["agentId"] == "ast-001" + assert "assistantId" not in agent + # Legacy row β†’ compat synthesizes a knowledge_base binding reffing the id. + assert agent["bindings"] == [ + {"kind": "knowledge_base", "ref": "ast-001", "config": {"vectorIndexId": "idx-001"}} + ] + + def test_get_exposes_bindings_and_modelconfig(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + agent = _make_assistant( + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "readwrite"})], + model_settings=AgentModelConfig(model_id="m1", params={"temperature": 0.7}), + ) + with patch(f"{ROUTES_MODULE}.assistant_exists", new_callable=AsyncMock, return_value=True), patch( + f"{ROUTES_MODULE}.get_assistant_with_access_check", new_callable=AsyncMock, return_value=(agent, "owner") + ): + resp = TestClient(app).get("/agents/ast-001") + assert resp.status_code == 200 + body = resp.json() + assert body["modelConfig"] == {"modelId": "m1", "params": {"temperature": 0.7}} + assert body["bindings"][0]["kind"] == "memory_space" + assert body["userPermission"] == "owner" + + def test_get_404_when_missing(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + with patch(f"{ROUTES_MODULE}.assistant_exists", new_callable=AsyncMock, return_value=False): + resp = TestClient(app).get("/agents/ghost") + assert resp.status_code == 404 + + def test_get_403_when_access_denied(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + with patch(f"{ROUTES_MODULE}.assistant_exists", new_callable=AsyncMock, return_value=True), patch( + f"{ROUTES_MODULE}.get_assistant_with_access_check", new_callable=AsyncMock, return_value=(None, None) + ): + resp = TestClient(app).get("/agents/ast-001") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- writes +class TestAgentWrites: + def test_create_validates_then_persists(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + created = _make_assistant() + with patch(f"{ROUTES_MODULE}.validate_agent_write", new_callable=AsyncMock) as v, patch( + f"{ROUTES_MODULE}.create_assistant", new_callable=AsyncMock, return_value=created + ): + resp = TestClient(app).post( + "/agents", json={"name": "My Agent", "description": "d", "instructions": "i"} + ) + assert resp.status_code == 200 + assert resp.json()["agentId"] == "ast-001" + v.assert_awaited_once() + + def test_create_surfaces_validation_403(self, app, make_user, _flag_on): + from apis.app_api.agent_designer.services.binding_validation import BindingValidationError + + mock_auth_user(app, make_user()) + with patch( + f"{ROUTES_MODULE}.validate_agent_write", + new_callable=AsyncMock, + side_effect=BindingValidationError("no access to model 'm1'", status_code=403), + ): + resp = TestClient(app).post( + "/agents", + json={"name": "X", "description": "d", "instructions": "i", "modelConfig": {"modelId": "m1"}}, + ) + assert resp.status_code == 403 + assert "no access" in resp.json()["detail"] + + def test_update_gates_on_permission(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + existing = _make_assistant() + with patch( + f"{ROUTES_MODULE}.resolve_assistant_permission", + new_callable=AsyncMock, + return_value=(existing, "viewer"), + ): + resp = TestClient(app).put("/agents/ast-001", json={"name": "New"}) + assert resp.status_code == 403 + + def test_delete_204(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + with patch(f"{ROUTES_MODULE}.delete_assistant", new_callable=AsyncMock, return_value=True): + resp = TestClient(app).delete("/agents/ast-001") + assert resp.status_code == 204 + + +# --------------------------------------------------------------------------- shares +class TestAgentShares: + def test_get_shares_projects_agent_id(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + existing = _make_assistant() + with patch( + f"{ROUTES_MODULE}.resolve_assistant_permission", + new_callable=AsyncMock, + return_value=(existing, "owner"), + ), patch( + f"{ROUTES_MODULE}.list_assistant_shares", + new_callable=AsyncMock, + return_value=[{"email": "bob@x.edu", "permission": "viewer"}], + ): + resp = TestClient(app).get("/agents/ast-001/shares") + assert resp.status_code == 200 + body = resp.json() + assert body["agentId"] == "ast-001" + assert body["sharedWith"] == [{"email": "bob@x.edu", "permission": "viewer"}] + + def test_share_404_when_not_owned(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + with patch(f"{ROUTES_MODULE}.share_assistant", new_callable=AsyncMock, return_value=False): + resp = TestClient(app).post("/agents/ast-001/shares", json={"emails": ["b@x.edu"], "permission": "viewer"}) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- bindable +class TestBindable: + def test_404_when_flag_off(self, app, make_user, monkeypatch): + monkeypatch.setenv("AGENTS_API_ENABLED", "false") + mock_auth_user(app, make_user()) + resp = TestClient(app).get("/agents/bindable?kind=model") + assert resp.status_code == 404 + + def test_returns_projected_items(self, app, make_user, _flag_on): + from apis.shared.assistants.models import BindableItem + + mock_auth_user(app, make_user()) + items = [BindableItem(kind="model", ref="us.anthropic.claude", label="Claude", description="Bedrock")] + with patch(f"{ROUTES_MODULE}.list_bindable", new_callable=AsyncMock, return_value=items): + resp = TestClient(app).get("/agents/bindable?kind=model") + assert resp.status_code == 200 + body = resp.json() + assert body["kind"] == "model" + assert body["items"][0]["ref"] == "us.anthropic.claude" + + def test_unsupported_kind_400(self, app, make_user, _flag_on): + mock_auth_user(app, make_user()) + resp = TestClient(app).get("/agents/bindable?kind=nonsense") + assert resp.status_code == 400 + + def test_bindable_not_captured_by_agent_id_route(self, app, make_user, _flag_on): + """The literal /bindable path must not be swallowed by /{agent_id}.""" + from apis.shared.assistants.models import BindableItem + + mock_auth_user(app, make_user()) + with patch(f"{ROUTES_MODULE}.list_bindable", new_callable=AsyncMock, return_value=[]) as m, patch( + f"{ROUTES_MODULE}.assistant_exists", new_callable=AsyncMock + ) as exists: + resp = TestClient(app).get("/agents/bindable?kind=tool") + assert resp.status_code == 200 + m.assert_awaited_once() + exists.assert_not_called() # did NOT fall through to get_agent_endpoint diff --git a/backend/tests/routes/test_delete_endpoints.py b/backend/tests/routes/test_delete_endpoints.py index 37df4c25..8337e0c5 100644 --- a/backend/tests/routes/test_delete_endpoints.py +++ b/backend/tests/routes/test_delete_endpoints.py @@ -35,6 +35,23 @@ def _owner_resolve(user_id: str): DOC_SERVICE = "apis.app_api.documents.services.document_service" CLEANUP_SERVICE = "apis.app_api.documents.services.cleanup_service" ASSISTANT_SERVICE = "apis.shared.assistants.service" +SYNC_POLICY_SERVICE = "apis.shared.sync_policies.service" + + +@pytest.fixture(autouse=True) +def sync_policy_cascades(): + """Both delete endpoints cascade into the sync-policy repository; stub it + so these route tests stay DynamoDB-free.""" + with patch( + f"{SYNC_POLICY_SERVICE}.delete_sync_policies_for_source", + new_callable=AsyncMock, + return_value=0, + ) as for_source, patch( + f"{SYNC_POLICY_SERVICE}.delete_sync_policies_for_assistant", + new_callable=AsyncMock, + return_value=0, + ) as for_assistant: + yield SimpleNamespace(for_source=for_source, for_assistant=for_assistant) # --------------------------------------------------------------------------- @@ -108,6 +125,31 @@ def test_delete_returns_204_after_soft_delete(self, app): assert resp.status_code == 204 + def test_delete_cascades_sync_policies_for_document(self, app, sync_policy_cascades): + """A deleted document must not leave a live sync schedule behind.""" + doc = _make_document() + routes_module = "apis.app_api.documents.routes" + + with patch( + f"{routes_module}.resolve_assistant_permission", + new_callable=AsyncMock, + return_value=_owner_resolve(USER_ID), + ), patch( + f"{DOC_SERVICE}.soft_delete_document", + new_callable=AsyncMock, + return_value=doc, + ), patch( + f"{CLEANUP_SERVICE}.cleanup_document_resources", + new_callable=AsyncMock, + ), patch( + "asyncio.ensure_future", + ): + client = TestClient(app) + resp = client.delete(f"/assistants/{ASSISTANT_ID}/documents/doc-001") + + assert resp.status_code == 204 + sync_policy_cascades.for_source.assert_awaited_once_with(ASSISTANT_ID, "doc-001") + def test_delete_returns_404_when_not_found(self, app): """Req 1.5: Returns 404 when soft_delete_document returns None.""" routes_module = "apis.app_api.documents.routes" @@ -233,6 +275,25 @@ def test_delete_hard_deletes_assistant(self, app): owner_id=USER_ID, ) + def test_delete_cascades_sync_policies_for_assistant(self, app, sync_policy_cascades): + """No sync schedule may outlive its assistant.""" + with patch( + f"{self.ROUTES_MODULE}.list_assistant_documents", + new_callable=AsyncMock, + return_value=([], None), + ), patch( + f"{self.ROUTES_MODULE}.delete_assistant", + new_callable=AsyncMock, + return_value=True, + ), patch( + "asyncio.ensure_future", + ): + client = TestClient(app) + resp = client.delete(f"/assistants/{ASSISTANT_ID}") + + assert resp.status_code == 204 + sync_policy_cascades.for_assistant.assert_awaited_once_with(ASSISTANT_ID) + def test_delete_fires_cleanup_in_background(self, app): """Req 8.2: Background cleanup is scheduled via asyncio.ensure_future.""" docs = [_make_document(documentId="doc-001")] diff --git a/backend/tests/routes/test_inference.py b/backend/tests/routes/test_inference.py index d4263e03..6342ec1c 100644 --- a/backend/tests/routes/test_inference.py +++ b/backend/tests/routes/test_inference.py @@ -148,6 +148,93 @@ async def fake_stream(*args, **kwargs): assert "event: message_start" in body or "event: done" in body +# --------------------------------------------------------------------------- +# session_title SSE: concurrent title generation pushed mid-stream +# --------------------------------------------------------------------------- + + +class TestSessionTitleEvent: + """First-turn streams interleave a `session_title` SSE event once the + concurrent title-generation task finishes, so the client can rename the + conversation while the response is still pending.""" + + @staticmethod + def _make_agent(): + """Fake agent whose stream yields real suspension points so the + concurrently scheduled title task gets a chance to run β€” mirrors the + real Bedrock stream, which awaits network I/O between events.""" + import asyncio + + mock_agent = MagicMock() + + async def fake_stream(*args, **kwargs): + yield 'event: message_start\ndata: {"role": "assistant"}\n\n' + await asyncio.sleep(0) + yield 'event: content_block_delta\ndata: {"contentBlockIndex": 0, "type": "text", "text": "Hi"}\n\n' + await asyncio.sleep(0) + yield "event: done\ndata: {}\n\n" + + mock_agent.stream_async = fake_stream + return mock_agent + + def _post_first_turn(self, authed_client, title_result, is_new_session=True): + async def fake_title(**kwargs): + return title_result + + async def fake_ensure(*args, **kwargs): + return is_new_session + + with patch( + "apis.inference_api.chat.routes.get_agent", + return_value=self._make_agent(), + ), patch( + "apis.inference_api.chat.routes.is_quota_enforcement_enabled", + return_value=False, + ), patch( + "apis.inference_api.chat.routes.ensure_session_metadata_exists", + side_effect=fake_ensure, + ), patch( + "apis.inference_api.chat.routes.generate_conversation_title", + side_effect=fake_title, + ): + return authed_client.post( + "/invocations", + json={"session_id": "sess-title-1", "message": "Explain SSE"}, + ) + + def test_first_turn_emits_session_title_event(self, authed_app, authed_client): + """A new session's stream carries the generated title mid-stream.""" + resp = self._post_first_turn(authed_client, "SSE Streaming Explained") + + assert resp.status_code == 200 + body = resp.text + assert "event: session_title" in body + assert '"title": "SSE Streaming Explained"' in body + assert '"sessionId": "sess-title-1"' in body + # At most once per stream. + assert body.count("event: session_title") == 1 + # Interleaved into the stream, not appended after the fact: it must + # appear before the terminal `done` frame the agent emitted last. + assert body.index("event: session_title") < body.rindex("event: done") + + def test_placeholder_title_is_not_emitted(self, authed_app, authed_client): + """Generation failure returns the placeholder β€” nothing is pushed; + the SPA's post-close fallback owns that path.""" + resp = self._post_first_turn(authed_client, "New Conversation") + + assert resp.status_code == 200 + assert "event: session_title" not in resp.text + + def test_existing_session_emits_no_title_event(self, authed_app, authed_client): + """Non-first turns never kick off title generation, so no event.""" + resp = self._post_first_turn( + authed_client, "Should Never Appear", is_new_session=False + ) + + assert resp.status_code == 200 + assert "event: session_title" not in resp.text + + # --------------------------------------------------------------------------- # Requirement 15.3: POST /invocations with invalid payload returns 422 # --------------------------------------------------------------------------- diff --git a/backend/tests/routes/test_schedules_routes.py b/backend/tests/routes/test_schedules_routes.py new file mode 100644 index 00000000..5344c3e9 --- /dev/null +++ b/backend/tests/routes/test_schedules_routes.py @@ -0,0 +1,643 @@ +"""Tests for schedule routes (`/schedules/*` β€” scheduled-runs B1, inert CRUD). + +Endpoints under test: +- POST /schedules -> create (201; 400 cap; 422 validation) +- GET /schedules -> list caller's own schedules (200) +- GET /schedules/{id} -> get (200; 404) +- PATCH /schedules/{id} -> edit fields / pause / resume (200; 404; 422) +- POST /schedules/{id}/pause -> pause (200; 404) +- POST /schedules/{id}/resume -> resume (200; 404) +- DELETE /schedules/{id} -> delete (204; 404) + +Every route shares the three-layer gate (cookie auth -> SCHEDULED_RUNS_ENABLED +kill switch -> `scheduled-runs` RBAC capability), pinned once via `TestGating` +and assumed enabled/authorized everywhere else. The service layer is mocked +(DynamoDB semantics are covered by tests/shared/test_scheduled_prompts.py); +these tests pin the HTTP contract and ownership isolation. +""" + +from dataclasses import dataclass +from typing import List, Optional +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apis.app_api.schedules import routes as schedules_routes +from apis.shared.auth.dependencies import get_current_user_from_session +from apis.shared.auth.models import User +from apis.shared.tools.scoped_ids import base_tool_id +from apis.shared.scheduled_prompts.models import ScheduledPrompt +from apis.shared.scheduled_prompts.service import ScheduledPromptLimitExceeded +from tests.routes.conftest import mock_no_auth + +USER_ID = "user-001" +OTHER_USER_ID = "user-002" +SCHEDULE_ID = "sched-abc123def456" +NOW = "2026-07-03T00:00:00Z" +FUTURE = "2999-01-01T09:00:00Z" + + +def _user(user_id: str = USER_ID) -> User: + return User(user_id=user_id, email=f"{user_id}@example.com", name="Test User", roles=["User"]) + + +def _make_schedule(**overrides) -> ScheduledPrompt: + defaults = dict( + scheduleId=SCHEDULE_ID, + userId=USER_ID, + assistantId=None, + label="Morning Briefing", + promptText="Summarize my day", + cadence="daily", + hourLocal=9, + weekday=None, + timezone="America/Boise", + state="active", + nextRunAt=FUTURE, + runsToday=0, + maxRunsPerDay=24, + enabledTools=["class_search"], + deliverEmail=False, + createdAt=NOW, + updatedAt=NOW, + ) + defaults.update(overrides) + return ScheduledPrompt.model_validate(defaults) + + +@dataclass +class _FakePermissions: + tools: List[str] + + +class FakeRoleService: + def __init__(self, tools: Optional[List[str]] = None): + self.tools = tools if tools is not None else ["class_search", "web_search"] + + async def resolve_user_permissions(self, user): + return _FakePermissions(tools=self.tools) + + async def filter_requested_tools(self, user, requested): + """Mirror AppRoleService.filter_requested_tools' narrow-never-grant.""" + allowed = set(self.tools) + if "*" in allowed: + return list(requested) + return [t for t in requested if t in allowed or base_tool_id(t) in allowed] + + +def _make_client( + monkeypatch: pytest.MonkeyPatch, + *, + authed: bool = True, + capability: bool = True, + flag: Optional[str] = None, + user_id: str = USER_ID, +) -> TestClient: + monkeypatch.delenv("SKIP_AUTH", raising=False) + if flag is None: + monkeypatch.delenv("SCHEDULED_RUNS_ENABLED", raising=False) + else: + monkeypatch.setenv("SCHEDULED_RUNS_ENABLED", flag) + + async def fake_capability(user, capability_id): + assert capability_id == "scheduled-runs" + return capability + + monkeypatch.setattr(schedules_routes, "user_has_capability", fake_capability) + monkeypatch.setattr(schedules_routes, "get_app_role_service", lambda: FakeRoleService()) + + app = FastAPI() + app.include_router(schedules_routes.router) + if authed: + app.dependency_overrides[get_current_user_from_session] = lambda: _user(user_id) + return TestClient(app, raise_server_exceptions=False) + + +# --------------------------------------------------------------------------- +# Gating +# --------------------------------------------------------------------------- + + +class TestGating: + def test_unauthenticated_request_is_401(self, monkeypatch): + client = _make_client(monkeypatch, authed=False) + mock_no_auth # (unused import kept for parity with sync_policies test style) + assert client.get("/schedules").status_code == 401 + + def test_kill_switch_off_hides_the_surface_as_404(self, monkeypatch): + client = _make_client(monkeypatch, flag="false") + assert client.get("/schedules").status_code == 404 + assert client.post("/schedules", json={}).status_code == 404 + assert client.get(f"/schedules/{SCHEDULE_ID}").status_code == 404 + assert client.delete(f"/schedules/{SCHEDULE_ID}").status_code == 404 + + def test_flag_defaults_on_when_unset(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "list_scheduled_prompts", AsyncMock(return_value=[])) + assert client.get("/schedules").status_code == 200 + + def test_empty_flag_value_stays_on(self, monkeypatch): + client = _make_client(monkeypatch, flag="") + monkeypatch.setattr(schedules_routes, "list_scheduled_prompts", AsyncMock(return_value=[])) + assert client.get("/schedules").status_code == 200 + + def test_missing_capability_is_403(self, monkeypatch): + client = _make_client(monkeypatch, capability=False) + assert client.get("/schedules").status_code == 403 + assert client.post("/schedules", json={}).status_code == 403 + + +# --------------------------------------------------------------------------- +# POST /schedules β€” create +# --------------------------------------------------------------------------- + + +class TestCreateSchedule: + def test_happy_path(self, monkeypatch): + client = _make_client(monkeypatch) + created = _make_schedule() + create_mock = AsyncMock(return_value=created) + monkeypatch.setattr(schedules_routes, "create_scheduled_prompt", create_mock) + + response = client.post( + "/schedules", + json={ + "label": "Morning Briefing", + "promptText": "Summarize my day", + "cadence": "daily", + "hourLocal": 9, + "timezone": "America/Boise", + }, + ) + + assert response.status_code == 201 + body = response.json() + assert body["scheduleId"] == SCHEDULE_ID + assert body["cadence"] == "daily" + assert body["state"] == "active" + + (call,) = create_mock.call_args_list + assert call.kwargs["user_id"] == USER_ID + assert call.kwargs["label"] == "Morning Briefing" + + def test_none_enabled_tools_resolves_rbac_snapshot_at_creation(self, monkeypatch): + client = _make_client(monkeypatch) + create_mock = AsyncMock(return_value=_make_schedule()) + monkeypatch.setattr(schedules_routes, "create_scheduled_prompt", create_mock) + + client.post( + "/schedules", + json={ + "label": "Briefing", + "promptText": "Go", + "cadence": "daily", + "hourLocal": 9, + "timezone": "America/Boise", + }, + ) + + (call,) = create_mock.call_args_list + assert call.kwargs["enabled_tools"] == ["class_search", "web_search"] + + def test_explicit_enabled_tools_intersected_with_rbac(self, monkeypatch): + """A granted tool is kept; an ungranted one is dropped, not frozen in. + + FakeRoleService grants {class_search, web_search}; ``gmail_search`` is + outside the caller's role and must never reach the stored snapshot β€” + the tool filter downstream performs no RBAC check of its own. + """ + client = _make_client(monkeypatch) + create_mock = AsyncMock(return_value=_make_schedule()) + monkeypatch.setattr(schedules_routes, "create_scheduled_prompt", create_mock) + + client.post( + "/schedules", + json={ + "label": "Briefing", + "promptText": "Go", + "cadence": "daily", + "hourLocal": 9, + "timezone": "America/Boise", + "enabledTools": ["class_search", "gmail_search"], + }, + ) + + (call,) = create_mock.call_args_list + assert call.kwargs["enabled_tools"] == ["class_search"] + + def test_explicit_ungranted_tools_all_dropped(self, monkeypatch): + """A request made entirely of ungranted tools freezes an empty set.""" + client = _make_client(monkeypatch) + create_mock = AsyncMock(return_value=_make_schedule()) + monkeypatch.setattr(schedules_routes, "create_scheduled_prompt", create_mock) + + client.post( + "/schedules", + json={ + "label": "Briefing", + "promptText": "Go", + "cadence": "daily", + "hourLocal": 9, + "timezone": "America/Boise", + "enabledTools": ["gmail_search", "some_admin_tool"], + }, + ) + + (call,) = create_mock.call_args_list + assert call.kwargs["enabled_tools"] == [] + + def test_weekly_without_weekday_is_422(self, monkeypatch): + client = _make_client(monkeypatch) + response = client.post( + "/schedules", + json={ + "label": "Weekly", + "promptText": "Go", + "cadence": "weekly", + "hourLocal": 9, + "timezone": "America/Boise", + }, + ) + assert response.status_code == 422 + + def test_interval_happy_path(self, monkeypatch): + client = _make_client(monkeypatch) + created = _make_schedule(cadence="interval", intervalValue=6, intervalUnit="hours", weekday=None) + create_mock = AsyncMock(return_value=created) + monkeypatch.setattr(schedules_routes, "create_scheduled_prompt", create_mock) + + response = client.post( + "/schedules", + json={ + "label": "Every 6 hours", + "promptText": "Check in", + "cadence": "interval", + "hourLocal": 9, + "timezone": "America/Boise", + "intervalValue": 6, + "intervalUnit": "hours", + }, + ) + + assert response.status_code == 201 + body = response.json() + assert body["cadence"] == "interval" + assert body["intervalValue"] == 6 + assert body["intervalUnit"] == "hours" + + (call,) = create_mock.call_args_list + assert call.kwargs["interval_value"] == 6 + assert call.kwargs["interval_unit"] == "hours" + + def test_interval_without_value_is_422(self, monkeypatch): + client = _make_client(monkeypatch) + response = client.post( + "/schedules", + json={ + "label": "Interval", + "promptText": "Go", + "cadence": "interval", + "hourLocal": 9, + "timezone": "America/Boise", + "intervalUnit": "hours", + }, + ) + assert response.status_code == 422 + + def test_interval_below_floor_is_422(self, monkeypatch): + client = _make_client(monkeypatch) + response = client.post( + "/schedules", + json={ + "label": "Too frequent", + "promptText": "Go", + "cadence": "interval", + "hourLocal": 9, + "timezone": "America/Boise", + "intervalValue": 5, + "intervalUnit": "minutes", + }, + ) + assert response.status_code == 422 + + def test_over_cap_is_400(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr( + schedules_routes, + "create_scheduled_prompt", + AsyncMock(side_effect=ScheduledPromptLimitExceeded("too many")), + ) + + response = client.post( + "/schedules", + json={ + "label": "One too many", + "promptText": "Go", + "cadence": "daily", + "hourLocal": 9, + "timezone": "America/Boise", + }, + ) + assert response.status_code == 400 + + def test_empty_prompt_is_422(self, monkeypatch): + client = _make_client(monkeypatch) + response = client.post( + "/schedules", + json={ + "label": "Briefing", + "promptText": "", + "cadence": "daily", + "hourLocal": 9, + "timezone": "America/Boise", + }, + ) + assert response.status_code == 422 + + def test_hour_out_of_range_is_422(self, monkeypatch): + client = _make_client(monkeypatch) + response = client.post( + "/schedules", + json={ + "label": "Briefing", + "promptText": "Go", + "cadence": "daily", + "hourLocal": 24, + "timezone": "America/Boise", + }, + ) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# GET /schedules, /schedules/{id} +# --------------------------------------------------------------------------- + + +class TestListAndGet: + def test_list_returns_only_the_caller_schedules(self, monkeypatch): + client = _make_client(monkeypatch, user_id=USER_ID) + list_mock = AsyncMock(return_value=[_make_schedule()]) + monkeypatch.setattr(schedules_routes, "list_scheduled_prompts", list_mock) + + response = client.get("/schedules") + + assert response.status_code == 200 + assert len(response.json()["schedules"]) == 1 + list_mock.assert_awaited_once_with(USER_ID) + + def test_get_by_id(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=_make_schedule())) + + response = client.get(f"/schedules/{SCHEDULE_ID}") + + assert response.status_code == 200 + assert response.json()["scheduleId"] == SCHEDULE_ID + + def test_get_missing_is_404(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=None)) + + assert client.get(f"/schedules/{SCHEDULE_ID}").status_code == 404 + + def test_get_another_users_schedule_is_404_not_leaked(self, monkeypatch): + """Ownership isolation: the route always queries by the caller's own + user_id, so another user's schedule_id simply resolves to nothing β€” + it can never be fetched cross-account regardless of guessability.""" + client = _make_client(monkeypatch, user_id=OTHER_USER_ID) + get_mock = AsyncMock(return_value=None) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", get_mock) + + response = client.get(f"/schedules/{SCHEDULE_ID}") + + assert response.status_code == 404 + get_mock.assert_awaited_once_with(OTHER_USER_ID, SCHEDULE_ID) + + +# --------------------------------------------------------------------------- +# PATCH /schedules/{id} +# --------------------------------------------------------------------------- + + +class TestUpdateSchedule: + def test_edit_label(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=_make_schedule())) + update_mock = AsyncMock(return_value=_make_schedule(label="Renamed")) + monkeypatch.setattr(schedules_routes, "update_scheduled_prompt", update_mock) + + response = client.patch(f"/schedules/{SCHEDULE_ID}", json={"label": "Renamed"}) + + assert response.status_code == 200 + assert response.json()["label"] == "Renamed" + + def test_missing_schedule_is_404(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=None)) + + response = client.patch(f"/schedules/{SCHEDULE_ID}", json={"label": "Renamed"}) + assert response.status_code == 404 + + def test_edit_enabled_tools_intersected_with_rbac(self, monkeypatch): + """A PATCH is a write into the same snapshot, so it gets the same + RBAC intersection β€” an edit cannot smuggle in an ungranted tool.""" + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=_make_schedule())) + update_mock = AsyncMock(return_value=_make_schedule()) + monkeypatch.setattr(schedules_routes, "update_scheduled_prompt", update_mock) + + response = client.patch( + f"/schedules/{SCHEDULE_ID}", + json={"enabledTools": ["web_search", "gmail_search"]}, + ) + + assert response.status_code == 200 + (call,) = update_mock.call_args_list + assert call.kwargs["enabled_tools"] == ["web_search"] + + def test_omitted_clearable_fields_pass_unset(self, monkeypatch): + """A label-only PATCH must leave assistant/tools untouched (UNSET).""" + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=_make_schedule())) + update_mock = AsyncMock(return_value=_make_schedule(label="Renamed")) + monkeypatch.setattr(schedules_routes, "update_scheduled_prompt", update_mock) + + response = client.patch(f"/schedules/{SCHEDULE_ID}", json={"label": "Renamed"}) + + assert response.status_code == 200 + (call,) = update_mock.call_args_list + assert call.kwargs["assistant_id"] is schedules_routes.UNSET + assert call.kwargs["enabled_tools"] is schedules_routes.UNSET + + def test_clear_assistant_passes_none(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=_make_schedule())) + update_mock = AsyncMock(return_value=_make_schedule()) + monkeypatch.setattr(schedules_routes, "update_scheduled_prompt", update_mock) + + response = client.patch(f"/schedules/{SCHEDULE_ID}", json={"clearAssistant": True}) + + assert response.status_code == 200 + (call,) = update_mock.call_args_list + assert call.kwargs["assistant_id"] is None + + def test_clear_tools_resnapshots_current_rbac(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=_make_schedule())) + update_mock = AsyncMock(return_value=_make_schedule()) + monkeypatch.setattr(schedules_routes, "update_scheduled_prompt", update_mock) + + response = client.patch(f"/schedules/{SCHEDULE_ID}", json={"clearTools": True}) + + assert response.status_code == 200 + (call,) = update_mock.call_args_list + # clear = re-snapshot the caller's full RBAC-allowed set (FakeRoleService default). + assert call.kwargs["enabled_tools"] == ["class_search", "web_search"] + + def test_clear_flag_with_value_is_422(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=_make_schedule())) + + assert client.patch( + f"/schedules/{SCHEDULE_ID}", json={"clearAssistant": True, "assistantId": "ast-1"} + ).status_code == 422 + assert client.patch( + f"/schedules/{SCHEDULE_ID}", json={"clearTools": True, "enabledTools": ["web_search"]} + ).status_code == 422 + + def test_switching_to_weekly_without_weekday_is_422(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr( + schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=_make_schedule(cadence="daily", weekday=None)) + ) + + response = client.patch(f"/schedules/{SCHEDULE_ID}", json={"cadence": "weekly"}) + assert response.status_code == 422 + + def test_state_transition_to_paused(self, monkeypatch): + client = _make_client(monkeypatch) + active = _make_schedule(state="active") + paused = _make_schedule(state="paused", stateReason="Paused by user") + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(side_effect=[active, paused])) + monkeypatch.setattr(schedules_routes, "update_scheduled_prompt", AsyncMock(return_value=active)) + set_state_mock = AsyncMock(return_value=True) + monkeypatch.setattr(schedules_routes, "set_schedule_state", set_state_mock) + + response = client.patch(f"/schedules/{SCHEDULE_ID}", json={"state": "paused"}) + + assert response.status_code == 200 + assert response.json()["state"] == "paused" + set_state_mock.assert_awaited_once() + assert set_state_mock.call_args.args[2] == "paused" + + def test_state_transition_to_active_recomputes_next_run_at(self, monkeypatch): + client = _make_client(monkeypatch) + paused = _make_schedule(state="paused", nextRunAt=None) + active = _make_schedule(state="active") + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(side_effect=[paused, paused, active])) + monkeypatch.setattr(schedules_routes, "update_scheduled_prompt", AsyncMock(return_value=paused)) + set_state_mock = AsyncMock(return_value=True) + monkeypatch.setattr(schedules_routes, "set_schedule_state", set_state_mock) + + response = client.patch(f"/schedules/{SCHEDULE_ID}", json={"state": "active"}) + + assert response.status_code == 200 + set_state_mock.assert_awaited_once() + assert set_state_mock.call_args.args[2] == "active" + assert set_state_mock.call_args.kwargs["next_run_at"] is not None + + +# --------------------------------------------------------------------------- +# POST /schedules/{id}/pause, /resume +# --------------------------------------------------------------------------- + + +class TestPauseResume: + def test_pause(self, monkeypatch): + client = _make_client(monkeypatch) + active = _make_schedule(state="active") + paused = _make_schedule(state="paused") + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(side_effect=[active, paused])) + set_state_mock = AsyncMock(return_value=True) + monkeypatch.setattr(schedules_routes, "set_schedule_state", set_state_mock) + + response = client.post(f"/schedules/{SCHEDULE_ID}/pause") + + assert response.status_code == 200 + assert response.json()["state"] == "paused" + + def test_pause_missing_is_404(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=None)) + + assert client.post(f"/schedules/{SCHEDULE_ID}/pause").status_code == 404 + + def test_pause_already_paused_is_idempotent(self, monkeypatch): + client = _make_client(monkeypatch) + paused = _make_schedule(state="paused") + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=paused)) + set_state_mock = AsyncMock(return_value=True) + monkeypatch.setattr(schedules_routes, "set_schedule_state", set_state_mock) + + response = client.post(f"/schedules/{SCHEDULE_ID}/pause") + + assert response.status_code == 200 + set_state_mock.assert_not_awaited() + + def test_resume(self, monkeypatch): + client = _make_client(monkeypatch) + paused = _make_schedule(state="paused") + active = _make_schedule(state="active") + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(side_effect=[paused, active])) + set_state_mock = AsyncMock(return_value=True) + monkeypatch.setattr(schedules_routes, "set_schedule_state", set_state_mock) + + response = client.post(f"/schedules/{SCHEDULE_ID}/resume") + + assert response.status_code == 200 + assert response.json()["state"] == "active" + set_state_mock.assert_awaited_once() + assert set_state_mock.call_args.args[2] == "active" + + def test_resume_from_paused_error(self, monkeypatch): + client = _make_client(monkeypatch) + errored = _make_schedule(state="paused_error", stateReason="Too many failures") + active = _make_schedule(state="active") + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(side_effect=[errored, active])) + monkeypatch.setattr(schedules_routes, "set_schedule_state", AsyncMock(return_value=True)) + + response = client.post(f"/schedules/{SCHEDULE_ID}/resume") + assert response.status_code == 200 + + def test_resume_missing_is_404(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=None)) + + assert client.post(f"/schedules/{SCHEDULE_ID}/resume").status_code == 404 + + +# --------------------------------------------------------------------------- +# DELETE /schedules/{id} +# --------------------------------------------------------------------------- + + +class TestDeleteSchedule: + def test_delete(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=_make_schedule())) + delete_mock = AsyncMock(return_value=True) + monkeypatch.setattr(schedules_routes, "delete_scheduled_prompt", delete_mock) + + response = client.delete(f"/schedules/{SCHEDULE_ID}") + + assert response.status_code == 204 + delete_mock.assert_awaited_once_with(USER_ID, SCHEDULE_ID) + + def test_delete_missing_is_404(self, monkeypatch): + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "get_scheduled_prompt", AsyncMock(return_value=None)) + + assert client.delete(f"/schedules/{SCHEDULE_ID}").status_code == 404 diff --git a/backend/tests/routes/test_sessions.py b/backend/tests/routes/test_sessions.py index 58f7861d..1900bc44 100644 --- a/backend/tests/routes/test_sessions.py +++ b/backend/tests/routes/test_sessions.py @@ -762,3 +762,122 @@ def test_returns_404_when_session_not_found(self, app, make_user, authenticated_ resp = client.get("/sessions/nonexistent/messages") assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# POST /sessions/{session_id}/interrupt β€” client stop signal +# --------------------------------------------------------------------------- + + +class TestSignalTurnInterrupted: + """POST /sessions/{session_id}/interrupt records deliberate stop intent. + + This endpoint is the authoritative carrier of `user_stopped` for the + interrupted-turn flow (the stream-cancellation path can only infer + `connection_lost`). Cookie-auth via get_current_user_from_session per the + app-api auth rule. + """ + + def test_returns_204_and_records_user_stopped(self, app, make_user, authenticated_client): + user = make_user() + client = authenticated_client(app, user) + + recorder = AsyncMock() + with patch( + "apis.app_api.sessions.routes.set_interrupted_turn", + recorder, + ): + resp = client.post( + "/sessions/sess-001/interrupt", + json={"reason": "user_stopped"}, + ) + + assert resp.status_code == 204 + recorder.assert_awaited_once_with( + "sess-001", + user.user_id, + reason="user_stopped", + source="client_signal", + ) + + def test_rejects_non_client_attested_reason(self, app, make_user, authenticated_client): + """`connection_lost` is server-inferred only β€” a client must not be + able to plant (or downgrade to) it through this endpoint.""" + user = make_user() + client = authenticated_client(app, user) + + recorder = AsyncMock() + with patch( + "apis.app_api.sessions.routes.set_interrupted_turn", + recorder, + ): + resp = client.post( + "/sessions/sess-001/interrupt", + json={"reason": "connection_lost"}, + ) + + assert resp.status_code == 422 + recorder.assert_not_awaited() + + def test_returns_401_for_unauthenticated(self, app, unauthenticated_client): + client = unauthenticated_client(app) + resp = client.post( + "/sessions/sess-001/interrupt", + json={"reason": "user_stopped"}, + ) + assert resp.status_code == 401 + + +class TestMarkSessionRead: + """POST /sessions/{session_id}/read clears the durable unread flag. + + Called by the SPA when the user opens a session that a scheduled + (unattended) run left unread. Cookie-auth via + get_current_user_from_session per the app-api auth rule. + """ + + def test_returns_204_and_clears_unread(self, app, make_user, authenticated_client): + user = make_user() + client = authenticated_client(app, user) + + recorder = AsyncMock() + with patch( + "apis.app_api.sessions.routes.mark_session_read", + recorder, + ): + resp = client.post("/sessions/sess-001/read") + + assert resp.status_code == 204 + recorder.assert_awaited_once_with(session_id="sess-001", user_id=user.user_id) + + def test_returns_401_for_unauthenticated(self, app, unauthenticated_client): + client = unauthenticated_client(app) + resp = client.post("/sessions/sess-001/read") + assert resp.status_code == 401 + + +class TestMarkSessionUnread: + """POST /sessions/{session_id}/unread sets the durable unread flag. + + The manual counterpart to /read β€” lets a user re-flag a conversation so + the sidebar dot returns. Cookie-auth via get_current_user_from_session. + """ + + def test_returns_204_and_sets_unread(self, app, make_user, authenticated_client): + user = make_user() + client = authenticated_client(app, user) + + recorder = AsyncMock() + with patch( + "apis.app_api.sessions.routes.mark_session_unread", + recorder, + ): + resp = client.post("/sessions/sess-001/unread") + + assert resp.status_code == 204 + recorder.assert_awaited_once_with(session_id="sess-001", user_id=user.user_id) + + def test_returns_401_for_unauthenticated(self, app, unauthenticated_client): + client = unauthenticated_client(app) + resp = client.post("/sessions/sess-001/unread") + assert resp.status_code == 401 diff --git a/backend/tests/routes/test_sync_policies.py b/backend/tests/routes/test_sync_policies.py new file mode 100644 index 00000000..5e0f5fc8 --- /dev/null +++ b/backend/tests/routes/test_sync_policies.py @@ -0,0 +1,450 @@ +"""Tests for sync-policy routes (KB sync β€” scheduled re-index). + +Endpoints under test (all edit-gated: owner or editor share): +- POST /assistants/{id}/sync-policies β†’ create (201; 404/400 source checks; 409 dup; 400 cap) +- GET /assistants/{id}/sync-policies β†’ list (200) +- PATCH /assistants/{id}/sync-policies/{pid} β†’ interval / pause / resume (409 for reauth pause) +- DELETE /assistants/{id}/sync-policies/{pid} β†’ delete + source-specific cleanup (204) +- POST /assistants/{id}/sync-policies/{pid}/run-now β†’ manual sync (202; 429 cooldown) + +The service layer is mocked β€” its DynamoDB semantics are covered by +tests/shared/test_sync_policies.py. These tests pin the HTTP contract: +status codes, permission gate, source validation, and cleanup fan-out. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apis.app_api.sync_policies.routes import router +from apis.shared.auth import get_current_user_from_session +from apis.shared.auth.models import User +from apis.shared.sync_policies.models import SyncPolicy +from apis.shared.sync_policies.service import ( + DuplicateSyncPolicy, + RunNowCooldown, + SyncPolicyLimitExceeded, +) +from tests.routes.conftest import mock_no_auth + +ROUTES_MODULE = "apis.app_api.sync_policies.routes" +ASSISTANT_ID = "ast-001" +USER_ID = "user-001" +POLICY_ID = "syn-abc123def456" +NOW = "2026-07-03T00:00:00+00:00Z" + + +def _make_policy(**overrides) -> SyncPolicy: + defaults = dict( + policyId=POLICY_ID, + assistantId=ASSISTANT_ID, + sourceType="drive_file", + sourceRef="doc-001", + interval="daily", + state="active", + nextSyncAt=NOW, + createdByUserId=USER_ID, + createdAt=NOW, + updatedAt=NOW, + ) + defaults.update(overrides) + return SyncPolicy.model_validate(defaults) + + +@pytest.fixture +def app(): + """Minimal FastAPI app mounting only the sync-policies router.""" + _app = FastAPI() + _app.include_router(router) + return _app + + +def _override_user(app: FastAPI, user_id: str = USER_ID) -> None: + app.dependency_overrides[get_current_user_from_session] = lambda: User( + user_id=user_id, email=f"{user_id}@example.com", name="Test User", roles=["User"] + ) + + +def _resolve(permission): + """Build a resolve_assistant_permission return value.""" + assistant = SimpleNamespace(owner_id=USER_ID) if permission else None + return AsyncMock(return_value=(assistant, permission)) + + +DRIVE_SOURCE = {"status": "complete", "sourceFileId": "drive-file-1"} +CRAWL_SOURCE = {"status": "complete"} + + +class TestPermissionGate: + """Every route shares the owner/editor gate β€” exercised through create.""" + + def test_unauthenticated_401(self, app): + mock_no_auth(app) + response = TestClient(app).get(f"/assistants/{ASSISTANT_ID}/sync-policies") + assert response.status_code == 401 + + def test_unknown_assistant_404(self, app): + _override_user(app) + with patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve(None)): + response = TestClient(app).get(f"/assistants/{ASSISTANT_ID}/sync-policies") + assert response.status_code == 404 + + def test_viewer_share_403(self, app): + _override_user(app) + with patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("viewer")): + response = TestClient(app).post( + f"/assistants/{ASSISTANT_ID}/sync-policies", + json={"sourceType": "drive_file", "sourceRef": "doc-001", "interval": "daily"}, + ) + assert response.status_code == 403 + + def test_editor_share_allowed(self, app): + _override_user(app, user_id="editor-user") + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("editor")), + patch(f"{ROUTES_MODULE}.list_sync_policies", AsyncMock(return_value=[])), + ): + response = TestClient(app).get(f"/assistants/{ASSISTANT_ID}/sync-policies") + assert response.status_code == 200 + + +class TestCreatePolicy: + def _post(self, app, source_type="drive_file", source_ref="doc-001", interval="daily"): + return TestClient(app).post( + f"/assistants/{ASSISTANT_ID}/sync-policies", + json={"sourceType": source_type, "sourceRef": source_ref, "interval": interval}, + ) + + def test_create_drive_file_201_and_backpointer(self, app): + _override_user(app) + policy = _make_policy() + update_fields = MagicMock() + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.records.get_source_item", return_value=DRIVE_SOURCE), + patch(f"{ROUTES_MODULE}.create_sync_policy", AsyncMock(return_value=policy)) as create, + patch(f"{ROUTES_MODULE}.records.update_document_sync_fields", update_fields), + ): + response = self._post(app) + + assert response.status_code == 201 + body = response.json() + assert body["policyId"] == POLICY_ID + assert body["sourceType"] == "drive_file" + assert body["state"] == "active" + create.assert_awaited_once_with( + assistant_id=ASSISTANT_ID, + source_type="drive_file", + source_ref="doc-001", + interval="daily", + created_by_user_id=USER_ID, + ) + update_fields.assert_called_once_with(ASSISTANT_ID, "doc-001", sync_policy_id=POLICY_ID) + + def test_create_web_crawl_writes_no_document_backpointer(self, app): + _override_user(app) + policy = _make_policy(sourceType="web_crawl", sourceRef="crawl-001") + update_fields = MagicMock() + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.records.get_source_item", return_value=CRAWL_SOURCE), + patch(f"{ROUTES_MODULE}.create_sync_policy", AsyncMock(return_value=policy)), + patch(f"{ROUTES_MODULE}.records.update_document_sync_fields", update_fields), + ): + response = self._post(app, source_type="web_crawl", source_ref="crawl-001") + + assert response.status_code == 201 + update_fields.assert_not_called() + + def test_missing_source_404(self, app): + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.records.get_source_item", return_value=None), + ): + response = self._post(app) + assert response.status_code == 404 + + def test_deleting_source_404(self, app): + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.records.get_source_item", return_value={"status": "deleting"}), + ): + response = self._post(app) + assert response.status_code == 404 + + def test_device_uploaded_document_400(self, app): + """No import provenance β†’ nothing external to sync from.""" + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.records.get_source_item", return_value={"status": "complete"}), + ): + response = self._post(app) + assert response.status_code == 400 + + def test_duplicate_policy_409(self, app): + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.records.get_source_item", return_value=DRIVE_SOURCE), + patch(f"{ROUTES_MODULE}.create_sync_policy", AsyncMock(side_effect=DuplicateSyncPolicy("doc-001"))), + ): + response = self._post(app) + assert response.status_code == 409 + + def test_policy_cap_400(self, app): + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.records.get_source_item", return_value=DRIVE_SOURCE), + patch( + f"{ROUTES_MODULE}.create_sync_policy", + AsyncMock(side_effect=SyncPolicyLimitExceeded("limit of 10 reached")), + ), + ): + response = self._post(app) + assert response.status_code == 400 + + def test_invalid_interval_422(self, app): + _override_user(app) + with patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")): + response = self._post(app, interval="hourly") + assert response.status_code == 422 + + +class TestListPolicies: + def test_list_200_camel_case(self, app): + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch( + f"{ROUTES_MODULE}.list_sync_policies", + AsyncMock(return_value=[_make_policy(), _make_policy(policyId="syn-2", sourceRef="doc-2")]), + ), + ): + response = TestClient(app).get(f"/assistants/{ASSISTANT_ID}/sync-policies") + + assert response.status_code == 200 + body = response.json() + assert [p["policyId"] for p in body["policies"]] == [POLICY_ID, "syn-2"] + assert body["policies"][0]["nextSyncAt"] == NOW + + +class TestUpdatePolicy: + def _patch(self, app, body): + return TestClient(app).patch(f"/assistants/{ASSISTANT_ID}/sync-policies/{POLICY_ID}", json=body) + + def test_interval_change(self, app): + _override_user(app) + changed = _make_policy(interval="weekly") + change = AsyncMock(return_value=changed) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.get_sync_policy", AsyncMock(return_value=_make_policy())), + patch(f"{ROUTES_MODULE}.change_policy_interval", change), + ): + response = self._patch(app, {"interval": "weekly"}) + + assert response.status_code == 200 + assert response.json()["interval"] == "weekly" + change.assert_awaited_once_with(ASSISTANT_ID, POLICY_ID, "weekly") + + def test_same_interval_is_noop(self, app): + _override_user(app) + change = AsyncMock() + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.get_sync_policy", AsyncMock(return_value=_make_policy())), + patch(f"{ROUTES_MODULE}.change_policy_interval", change), + ): + response = self._patch(app, {"interval": "daily"}) + + assert response.status_code == 200 + change.assert_not_awaited() + + def test_pause(self, app): + _override_user(app) + set_state = AsyncMock(return_value=True) + paused = _make_policy(state="paused_user", stateReason="Paused by user") + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.get_sync_policy", AsyncMock(side_effect=[_make_policy(), paused])), + patch(f"{ROUTES_MODULE}.set_policy_state", set_state), + ): + response = self._patch(app, {"state": "paused_user"}) + + assert response.status_code == 200 + assert response.json()["state"] == "paused_user" + set_state.assert_awaited_once_with( + ASSISTANT_ID, POLICY_ID, "paused_user", state_reason="Paused by user" + ) + + def test_resume_comes_due_immediately(self, app): + _override_user(app) + set_state = AsyncMock(return_value=True) + paused = _make_policy(state="paused_user") + resumed = _make_policy(state="active") + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.get_sync_policy", AsyncMock(side_effect=[paused, resumed])), + patch(f"{ROUTES_MODULE}.set_policy_state", set_state), + ): + response = self._patch(app, {"state": "active"}) + + assert response.status_code == 200 + assert response.json()["state"] == "active" + # Resume re-arms due-now, not one interval out + args, kwargs = set_state.await_args + assert args == (ASSISTANT_ID, POLICY_ID, "active") + assert kwargs["next_sync_at"] is not None + + def test_resume_of_reauth_pause_409(self, app): + """Only a fresh OAuth consent resumes a reauth pause.""" + _override_user(app) + set_state = AsyncMock() + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.get_sync_policy", AsyncMock(return_value=_make_policy(state="paused_reauth"))), + patch(f"{ROUTES_MODULE}.set_policy_state", set_state), + ): + response = self._patch(app, {"state": "active"}) + + assert response.status_code == 409 + set_state.assert_not_awaited() + + def test_pause_of_reauth_pause_allowed(self, app): + """A user may still explicitly park a reauth-paused policy.""" + _override_user(app) + set_state = AsyncMock(return_value=True) + reauth = _make_policy(state="paused_reauth") + parked = _make_policy(state="paused_user", stateReason="Paused by user") + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.get_sync_policy", AsyncMock(side_effect=[reauth, parked])), + patch(f"{ROUTES_MODULE}.set_policy_state", set_state), + ): + response = self._patch(app, {"state": "paused_user"}) + + assert response.status_code == 200 + + def test_invalid_state_422(self, app): + """paused_reauth / paused_error / paused_inactive are system-owned.""" + _override_user(app) + with patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")): + response = self._patch(app, {"state": "paused_reauth"}) + assert response.status_code == 422 + + def test_missing_policy_404(self, app): + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.get_sync_policy", AsyncMock(return_value=None)), + ): + response = self._patch(app, {"interval": "weekly"}) + assert response.status_code == 404 + + +class TestDeletePolicy: + def _delete(self, app): + return TestClient(app).delete(f"/assistants/{ASSISTANT_ID}/sync-policies/{POLICY_ID}") + + def test_delete_drive_file_clears_backpointer(self, app): + _override_user(app) + delete_policy = AsyncMock(return_value=True) + delete_marker = AsyncMock() + clear_backpointer = MagicMock() + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.get_sync_policy", AsyncMock(return_value=_make_policy())), + patch(f"{ROUTES_MODULE}.delete_sync_policy", delete_policy), + patch(f"{ROUTES_MODULE}.delete_reauth_marker", delete_marker), + patch(f"{ROUTES_MODULE}.records.clear_document_sync_policy_id", clear_backpointer), + ): + response = self._delete(app) + + assert response.status_code == 204 + delete_policy.assert_awaited_once_with(ASSISTANT_ID, POLICY_ID) + delete_marker.assert_awaited_once_with(USER_ID, POLICY_ID) + clear_backpointer.assert_called_once_with(ASSISTANT_ID, "doc-001") + + def test_delete_web_crawl_restores_job_ttl(self, app): + _override_user(app) + policy = _make_policy(sourceType="web_crawl", sourceRef="crawl-001") + restore_ttl = AsyncMock() + clear_backpointer = MagicMock() + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.get_sync_policy", AsyncMock(return_value=policy)), + patch(f"{ROUTES_MODULE}.delete_sync_policy", AsyncMock(return_value=True)), + patch(f"{ROUTES_MODULE}.delete_reauth_marker", AsyncMock()), + patch("apis.app_api.web_sources.crawl_repository.restore_crawl_ttl", restore_ttl), + patch(f"{ROUTES_MODULE}.records.clear_document_sync_policy_id", clear_backpointer), + ): + response = self._delete(app) + + assert response.status_code == 204 + restore_ttl.assert_awaited_once_with(assistant_id=ASSISTANT_ID, crawl_id="crawl-001") + clear_backpointer.assert_not_called() + + def test_missing_policy_404(self, app): + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.get_sync_policy", AsyncMock(return_value=None)), + ): + response = self._delete(app) + assert response.status_code == 404 + + +class TestRunNow: + def _post(self, app): + return TestClient(app).post(f"/assistants/{ASSISTANT_ID}/sync-policies/{POLICY_ID}/run-now") + + def test_run_now_202(self, app): + _override_user(app) + triggered = _make_policy(lastManualRunAt=NOW) + trigger = AsyncMock(return_value=triggered) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.trigger_run_now", trigger), + ): + response = self._post(app) + + assert response.status_code == 202 + assert response.json()["policyId"] == POLICY_ID + trigger.assert_awaited_once_with(ASSISTANT_ID, POLICY_ID) + + def test_missing_policy_404(self, app): + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.trigger_run_now", AsyncMock(side_effect=KeyError(POLICY_ID))), + ): + response = self._post(app) + assert response.status_code == 404 + + def test_paused_policy_409(self, app): + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch( + f"{ROUTES_MODULE}.trigger_run_now", + AsyncMock(side_effect=ValueError("Cannot run-now a policy in state paused_user")), + ), + ): + response = self._post(app) + assert response.status_code == 409 + + def test_cooldown_429(self, app): + _override_user(app) + with ( + patch(f"{ROUTES_MODULE}.resolve_assistant_permission", _resolve("owner")), + patch(f"{ROUTES_MODULE}.trigger_run_now", AsyncMock(side_effect=RunNowCooldown(POLICY_ID))), + ): + response = self._post(app) + assert response.status_code == 429 diff --git a/backend/tests/shared/conftest.py b/backend/tests/shared/conftest.py index fdd8d40a..a9d6393c 100644 --- a/backend/tests/shared/conftest.py +++ b/backend/tests/shared/conftest.py @@ -233,10 +233,13 @@ def sessions_metadata_table(aws, monkeypatch): {"AttributeName": "GSI1SK", "AttributeType": "S"}, {"AttributeName": "GSI_PK", "AttributeType": "S"}, {"AttributeName": "GSI_SK", "AttributeType": "S"}, + {"AttributeName": "GSI3_PK", "AttributeType": "S"}, + {"AttributeName": "GSI3_SK", "AttributeType": "S"}, ], gsis=[ _gsi("UserTimestampIndex", "GSI1PK", "GSI1SK"), _gsi("SessionLookupIndex", "GSI_PK", "GSI_SK"), + _gsi("DueScheduleIndex", "GSI3_PK", "GSI3_SK"), ], ) @@ -260,11 +263,14 @@ def assistants_table(aws, monkeypatch): {"AttributeName": "GSI2_SK", "AttributeType": "S"}, {"AttributeName": "GSI3_PK", "AttributeType": "S"}, {"AttributeName": "GSI3_SK", "AttributeType": "S"}, + {"AttributeName": "GSI4_PK", "AttributeType": "S"}, + {"AttributeName": "GSI4_SK", "AttributeType": "S"}, ], gsis=[ _gsi("OwnerStatusIndex", "GSI_PK", "GSI_SK"), _gsi("VisibilityStatusIndex", "GSI2_PK", "GSI2_SK"), _gsi("SharedWithIndex", "GSI3_PK", "GSI3_SK"), + _gsi("DueSyncIndex", "GSI4_PK", "GSI4_SK"), ], ) diff --git a/backend/tests/shared/test_agent_compat.py b/backend/tests/shared/test_agent_compat.py new file mode 100644 index 00000000..161fed0d --- /dev/null +++ b/backend/tests/shared/test_agent_compat.py @@ -0,0 +1,122 @@ +"""Agent Designer Phase 1 β€” compat mapping, serialization, and model-contract tests. + +Pure library tests (no boto3): the D2 read-side compat mapping, the D3 model/binding +shapes, the R3 pydantic naming landmine, and the Decimal round-trip helpers. +""" + +from decimal import Decimal + +from apis.shared.assistants.compat import effective_bindings, to_agent_view +from apis.shared.assistants.models import AgentBinding, AgentModelConfig, Assistant +from apis.shared.assistants.serialization import from_ddb, to_ddb_safe + + +def _legacy_assistant(**overrides) -> Assistant: + """A legacy Assistant row β€” no bindings, no modelConfig.""" + base = dict( + assistant_id="ast_123", + owner_id="u1", + owner_name="Alice", + name="Bot", + description="A bot", + instructions="You are helpful.", + vector_index_id="assistants-index", + visibility="PRIVATE", + created_at="2026-07-07T00:00:00Z", + updated_at="2026-07-07T00:00:00Z", + status="COMPLETE", + ) + base.update(overrides) + return Assistant(**base) + + +class TestCompatMapping: + def test_legacy_synthesizes_single_kb_binding_reffing_assistant_id(self): + a = _legacy_assistant() + bindings = effective_bindings(a) + assert len(bindings) == 1 + (kb,) = bindings + assert kb.kind == "knowledge_base" + # The KB's only stable identity today IS the assistant id (R4). + assert kb.ref == "ast_123" + assert kb.config == {"vectorIndexId": "assistants-index"} + + def test_legacy_modelconfig_is_none_not_fabricated(self): + # R1: absent model must map to None ("resolve as today"), never a fake id. + a = _legacy_assistant() + assert a.model_settings is None + assert to_agent_view(a)["modelConfig"] is None + + def test_stored_bindings_returned_verbatim(self): + stored = [AgentBinding(kind="memory_space", ref="spc_1", config={"access": "readwrite"})] + a = _legacy_assistant(bindings=stored) + assert effective_bindings(a) == stored + + def test_unknown_kind_survives_read(self): + # Forward/rollback compat: a kind written by newer code passes through. + a = _legacy_assistant(bindings=[AgentBinding(kind="future_kind", ref="x", config={})]) + assert effective_bindings(a)[0].kind == "future_kind" + + def test_empty_bindings_list_is_not_synthesized(self): + # An explicit empty list means "no bindings", distinct from absent (legacy). + a = _legacy_assistant(bindings=[]) + assert effective_bindings(a) == [] + + def test_agent_view_uses_agent_id_and_omits_owner_id(self): + view = to_agent_view(_legacy_assistant()) + assert view["agentId"] == "ast_123" + assert "ownerId" not in view and "owner_id" not in view + + +class TestModelContract: + def test_modelconfig_alias_roundtrip(self): + # R3: field is ``model_settings`` in Python, ``modelConfig`` on the wire. + a = _legacy_assistant( + model_settings=AgentModelConfig(model_id="us.anthropic.claude", params={"temperature": 0.7}) + ) + assert a.model_settings.model_id == "us.anthropic.claude" + dumped = a.model_dump(by_alias=True) + assert dumped["modelConfig"]["modelId"] == "us.anthropic.claude" + + def test_assistant_validates_modelconfig_from_wire_alias(self): + a = Assistant.model_validate( + { + "assistantId": "ast_9", + "ownerId": "u1", + "ownerName": "Alice", + "name": "B", + "description": "d", + "instructions": "i", + "vectorIndexId": "assistants-index", + "visibility": "PRIVATE", + "createdAt": "t", + "updatedAt": "t", + "status": "COMPLETE", + "modelConfig": {"modelId": "m1"}, + "bindings": [{"kind": "tool", "ref": "t1", "config": {}}], + } + ) + assert a.model_settings.model_id == "m1" + assert a.bindings[0].kind == "tool" + + def test_binding_config_defaults_to_empty_dict(self): + assert AgentBinding(kind="skill", ref="s1").config == {} + + +class TestSerialization: + def test_float_to_decimal_and_back(self): + params = {"temperature": 0.7, "topP": 1.0, "maxTokens": 4096, "stop": ["x"], "stream": True} + safe = to_ddb_safe(params) + assert isinstance(safe["temperature"], Decimal) + # Floats that happen to be integral still convert (they arrived as float). + assert isinstance(safe["topP"], Decimal) + # Native ints are already DynamoDB-safe β€” left untouched. + assert isinstance(safe["maxTokens"], int) + # bool must not be coerced to Decimal. + assert safe["stream"] is True + back = from_ddb(safe) + assert back["temperature"] == 0.7 and isinstance(back["temperature"], float) + # Integral decimals read back as int, not 1.0. + assert back["topP"] == 1 and isinstance(back["topP"], int) + assert back["maxTokens"] == 4096 and isinstance(back["maxTokens"], int) + assert back["stop"] == ["x"] diff --git a/backend/tests/shared/test_assistants_agent_fields.py b/backend/tests/shared/test_assistants_agent_fields.py new file mode 100644 index 00000000..e5c96cf7 --- /dev/null +++ b/backend/tests/shared/test_assistants_agent_fields.py @@ -0,0 +1,65 @@ +"""Agent Designer Phase 1 β€” bindings + modelConfig persistence round-trip (moto). + +Proves the D3 fields survive a real DynamoDB write/read: float params round-trip through +Decimal, bindings are preserved, and legacy rows still read back with no agent fields. +""" + +import pytest + +from apis.shared.assistants.models import AgentBinding, AgentModelConfig + + +class TestAgentFieldsPersistence: + @pytest.fixture(autouse=True) + def _set_env(self, monkeypatch): + monkeypatch.setenv("S3_ASSISTANTS_VECTOR_STORE_INDEX_NAME", "test-index") + + @pytest.mark.asyncio + async def test_create_with_bindings_and_modelconfig_roundtrips(self, assistants_table): + from apis.shared.assistants.service import create_assistant, get_assistant + + created = await create_assistant( + owner_id="u1", + owner_name="Alice", + name="Oliver", + description="Chief of Staff", + instructions="You are Oliver.", + model_settings=AgentModelConfig(model_id="m1", params={"temperature": 0.7, "maxTokens": 4096}), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "readwrite"})], + ) + got = await get_assistant(created.assistant_id, "u1") + assert got is not None + # Float survived the Decimal round trip as a native float. + assert got.model_settings.model_id == "m1" + assert got.model_settings.params["temperature"] == 0.7 + assert isinstance(got.model_settings.params["temperature"], float) + assert got.model_settings.params["maxTokens"] == 4096 + assert len(got.bindings) == 1 + assert got.bindings[0].kind == "memory_space" + assert got.bindings[0].config == {"access": "readwrite"} + + @pytest.mark.asyncio + async def test_legacy_create_has_no_agent_fields(self, assistants_table): + from apis.shared.assistants.service import create_assistant, get_assistant + + created = await create_assistant( + owner_id="u1", owner_name="Alice", name="Plain", description="d", instructions="i" + ) + got = await get_assistant(created.assistant_id, "u1") + assert got.model_settings is None + assert got.bindings is None # absent β†’ compat synthesizes KB on read + + @pytest.mark.asyncio + async def test_update_sets_bindings(self, assistants_table): + from apis.shared.assistants.service import create_assistant, get_assistant, update_assistant + + created = await create_assistant( + owner_id="u1", owner_name="Alice", name="Bot", description="d", instructions="i" + ) + await update_assistant( + assistant_id=created.assistant_id, + owner_id="u1", + bindings=[AgentBinding(kind="tool", ref="gateway_x", config={})], + ) + got = await get_assistant(created.assistant_id, "u1") + assert got.bindings is not None and got.bindings[0].kind == "tool" diff --git a/backend/tests/shared/test_assistants_extended.py b/backend/tests/shared/test_assistants_extended.py index b4c33861..5cece660 100644 --- a/backend/tests/shared/test_assistants_extended.py +++ b/backend/tests/shared/test_assistants_extended.py @@ -125,3 +125,66 @@ def test_augment_prompt_max_length_boundary(self): ] result = augment_prompt_with_context("Q?", chunks, max_context_length=80) assert "[Context 1]" in result + + +class TestBumpLastUsedAt: + """KB-sync inactivity signal: throttled conditional write on METADATA.""" + + @pytest.fixture(autouse=True) + def _set_env(self, assistants_table, monkeypatch): + monkeypatch.setenv("S3_ASSISTANTS_VECTOR_STORE_INDEX_NAME", "test-index") + self.table = assistants_table + + async def _create(self): + from apis.shared.assistants.service import create_assistant + return await create_assistant( + owner_id="u1", owner_name="Alice", name="Bot", + description="d", instructions="hi", + ) + + @pytest.mark.asyncio + async def test_first_bump_wins_and_stamps(self): + from apis.shared.assistants.service import bump_last_used_at + assistant = await self._create() + + assert await bump_last_used_at(assistant.assistant_id) is True + + item = self.table.get_item( + Key={"PK": f"AST#{assistant.assistant_id}", "SK": "METADATA"} + )["Item"] + assert "lastUsedAt" in item + + @pytest.mark.asyncio + async def test_second_bump_within_throttle_loses(self): + from apis.shared.assistants.service import bump_last_used_at + assistant = await self._create() + assert await bump_last_used_at(assistant.assistant_id) is True + + # Fresh stamp: concurrent chat turns must not stampede the row β€” + # only the caller that advances a stale timestamp gets True. + assert await bump_last_used_at(assistant.assistant_id) is False + + @pytest.mark.asyncio + async def test_stale_stamp_bumps_again(self): + from apis.shared.assistants.service import bump_last_used_at + assistant = await self._create() + self.table.update_item( + Key={"PK": f"AST#{assistant.assistant_id}", "SK": "METADATA"}, + UpdateExpression="SET lastUsedAt = :old", + ExpressionAttributeValues={":old": "2000-01-01T00:00:00+00:00Z"}, + ) + + assert await bump_last_used_at(assistant.assistant_id) is True + + @pytest.mark.asyncio + async def test_missing_assistant_returns_false(self): + from apis.shared.assistants.service import bump_last_used_at + # attribute_exists(PK) guard: a bump must never create a phantom row + assert await bump_last_used_at("ast-missing") is False + assert "Item" not in self.table.get_item(Key={"PK": "AST#ast-missing", "SK": "METADATA"}) + + @pytest.mark.asyncio + async def test_unconfigured_table_returns_false(self, monkeypatch): + from apis.shared.assistants.service import bump_last_used_at + monkeypatch.delenv("DYNAMODB_ASSISTANTS_TABLE_NAME", raising=False) + assert await bump_last_used_at("ast-any") is False diff --git a/backend/tests/shared/test_memory_hydration.py b/backend/tests/shared/test_memory_hydration.py new file mode 100644 index 00000000..e9e76ad8 --- /dev/null +++ b/backend/tests/shared/test_memory_hydration.py @@ -0,0 +1,117 @@ +"""Agent Designer Phase 3 (PR-B) β€” Memory-Space hydration helper. + +Resolves alwaysLoad specs against a fake MemorySpaceService: MEMORY.md, the +latest:/ scheme, bare slugs, missing-entry skip, default, budget cap. +""" + +from dataclasses import dataclass + +import pytest + +from apis.shared.memory.hydration import ( + DEFAULT_ALWAYS_LOAD, + render_memory_block, + resolve_always_load, +) +from apis.shared.memory.service import MemorySpaceNotFoundError + + +@dataclass +class _Ref: + slug: str + entry_type: str + updated: str + + +class _FakeService: + """Minimal MemorySpaceService stand-in; filters like the real list_entries.""" + + def __init__(self, index="", entries=None, bodies=None): + self._index = index + self._entries = entries or [] + self._bodies = bodies or {} + + def read_index(self, space_id, user_id, user_email=None): + return self._index + + def list_entries(self, space_id, user_id, user_email=None, *, entry_type=None, where=None): + return [e for e in self._entries if entry_type is None or e.entry_type == entry_type] + + def read_entry(self, space_id, user_id, user_email, slug): + if slug not in self._bodies: + raise MemorySpaceNotFoundError(f"entry '{slug}' not found") + return self._bodies[slug] + + +def _resolve(service, always_load, **kw): + return resolve_always_load(service, "spc_1", "u1", "u1@x.edu", always_load, **kw) + + +class TestHydration: + def test_default_is_memory_md(self): + frags = _resolve(_FakeService(index="# Index"), None) + assert DEFAULT_ALWAYS_LOAD == ["MEMORY.md"] + assert [f.label for f in frags] == ["MEMORY.md"] + assert frags[0].text == "# Index" + + def test_empty_index_skipped(self): + assert _resolve(_FakeService(index=""), ["MEMORY.md"]) == [] + + def test_latest_picks_most_recent_matching_type_and_prefix(self): + svc = _FakeService( + entries=[ + _Ref("daily-2026-07-05", "episodic", "2026-07-05"), + _Ref("daily-2026-07-07", "episodic", "2026-07-07"), + _Ref("weekly-2026-07-01", "episodic", "2026-07-01"), + _Ref("daily-note", "fact", "2026-07-09"), # wrong type, ignored + ], + bodies={"daily-2026-07-07": "latest daily"}, + ) + frags = _resolve(svc, ["latest:episodic/daily"]) + assert len(frags) == 1 + assert frags[0].label == "latest:episodic/daily β†’ daily-2026-07-07" + assert frags[0].text == "latest daily" + + def test_latest_with_invalid_type_treats_whole_as_slug_prefix(self): + svc = _FakeService( + entries=[_Ref("proj-x", "fact", "2026-07-07")], + bodies={"proj-x": "project x"}, + ) + frags = _resolve(svc, ["latest:proj"]) + assert frags[0].text == "project x" + + def test_latest_no_match_skipped(self): + assert _resolve(_FakeService(entries=[]), ["latest:episodic/daily"]) == [] + + def test_bare_slug_reads_entry(self): + svc = _FakeService(bodies={"jane-doe": "Jane's profile"}) + frags = _resolve(svc, ["jane-doe"]) + assert frags[0].label == "jane-doe" and frags[0].text == "Jane's profile" + + def test_missing_slug_is_skipped_not_raised(self): + assert _resolve(_FakeService(bodies={}), ["ghost"]) == [] + + def test_budget_truncates_with_marker(self): + svc = _FakeService(index="A" * 500) + frags = _resolve(svc, ["MEMORY.md"], max_total_bytes=100) + assert frags[0].text.startswith("A" * 100) + assert "truncated" in frags[0].text + + def test_budget_stops_further_fragments(self): + svc = _FakeService(index="A" * 100, bodies={"e": "B" * 100}) + frags = _resolve(svc, ["MEMORY.md", "e"], max_total_bytes=100) + # First fragment exhausts the budget; the second is not loaded. + assert [f.label for f in frags] == ["MEMORY.md"] + + +class TestRenderBlock: + def test_empty_when_no_fragments(self): + assert render_memory_block("Oliver's Brain", []) == "" + + def test_renders_labeled_sections(self): + svc = _FakeService(index="# Index", bodies={"jane": "profile"}) + frags = _resolve(svc, ["MEMORY.md", "jane"]) + block = render_memory_block("Oliver's Brain", frags) + assert 'Bound Memory β€” "Oliver\'s Brain"' in block + assert "### MEMORY.md" in block and "# Index" in block + assert "### jane" in block and "profile" in block diff --git a/backend/tests/shared/test_memory_spaces.py b/backend/tests/shared/test_memory_spaces.py new file mode 100644 index 00000000..9613fc39 --- /dev/null +++ b/backend/tests/shared/test_memory_spaces.py @@ -0,0 +1,569 @@ +"""Tests for the Memory Spaces repository + service (PR-1, data layer). + +moto-backed DynamoDB (with the OwnerIndex/MemberIndex GSIs) + S3. Exercises +row (de)serialization, GSI listings, and the full permission-gated service +API: create/get/list/delete, share/revoke, index + entry I/O, and the +role-gating (viewer reads, editor writes, owner shares/deletes). +""" + +import boto3 +import pytest +from moto import mock_aws + +from apis.shared.memory.models import MemoryIndex, MemorySpace, SpaceMember +from apis.shared.memory.repository import MemorySpaceRepository, OptimisticLockError +from apis.shared.memory.service import ( + MemorySpaceConcurrencyError, + MemorySpaceNotFoundError, + MemorySpacePermissionError, + MemorySpaceService, +) +from apis.shared.memory.store import MemorySpaceStore + +AWS_REGION = "us-east-1" +BUCKET = "test-memory-spaces" +TABLE = "test-memory-spaces" + +OWNER = "user-owner" +OWNER_EMAIL = "owner@example.edu" +FRIEND = "user-friend" +FRIEND_EMAIL = "friend@example.edu" +STRANGER = "user-stranger" +STRANGER_EMAIL = "stranger@example.edu" + + +@pytest.fixture() +def aws_env(monkeypatch): + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") + with mock_aws(): + yield + + +@pytest.fixture() +def table(aws_env): + ddb = boto3.client("dynamodb", region_name=AWS_REGION) + ddb.create_table( + TableName=TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + {"AttributeName": "GSI1PK", "AttributeType": "S"}, + {"AttributeName": "GSI1SK", "AttributeType": "S"}, + {"AttributeName": "GSI2PK", "AttributeType": "S"}, + {"AttributeName": "GSI2SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + GlobalSecondaryIndexes=[ + { + "IndexName": "OwnerIndex", + "KeySchema": [ + {"AttributeName": "GSI1PK", "KeyType": "HASH"}, + {"AttributeName": "GSI1SK", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }, + { + "IndexName": "MemberIndex", + "KeySchema": [ + {"AttributeName": "GSI2PK", "KeyType": "HASH"}, + {"AttributeName": "GSI2SK", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }, + ], + ) + return MemorySpaceRepository(table_name=TABLE) + + +@pytest.fixture() +def store(aws_env): + client = boto3.client("s3", region_name=AWS_REGION) + client.create_bucket(Bucket=BUCKET) + return MemorySpaceStore(bucket_name=BUCKET, s3_client=client) + + +@pytest.fixture() +def service(table, store): + return MemorySpaceService(repository=table, store=store) + + +@pytest.fixture() +def space(service): + return service.create_space(OWNER, OWNER_EMAIL, "My Brain", template="blank") + + +# ============================ repository ============================ + + +class TestRepository: + def test_space_round_trip(self, table): + s = MemorySpace( + space_id="spc_1", + name="X", + template="blank", + owner_id=OWNER, + owner_email=OWNER_EMAIL, + created_at="t0", + updated_at="t0", + index_s3_key="spaces/spc_1/abc", + index_content_hash="abc", + ) + table.put_space(s) + got = table.get_space("spc_1") + assert got is not None + assert got.name == "X" + assert got.owner_id == OWNER + assert got.index_s3_key == "spaces/spc_1/abc" + + def test_get_missing_space_returns_none(self, table): + assert table.get_space("nope") is None + + def test_list_owned_via_gsi(self, table): + for i in range(3): + table.put_space( + MemorySpace( + space_id=f"spc_{i}", + name=f"S{i}", + owner_id=OWNER, + created_at=f"t{i}", + updated_at=f"t{i}", + ) + ) + table.put_space( + MemorySpace(space_id="other", name="O", owner_id="someone-else") + ) + owned = table.list_owned(OWNER) + assert {s.space_id for s in owned} == {"spc_0", "spc_1", "spc_2"} + + def test_index_default_empty(self, table): + idx = table.get_index("spc_new") + assert idx.entries == [] + assert idx.version == 0 + + def test_index_round_trip_with_indexed_numbers(self, table): + idx = MemoryIndex(space_id="spc_1", version=2) + from apis.shared.memory.models import MemoryEntryRef + + idx.entries.append( + MemoryEntryRef( + slug="jane", + entry_type="entity", + description="VP", + content_hash="h", + size=123, + s3_key="spaces/spc_1/h", + updated="t", + updated_by=OWNER, + indexed={"open": True, "count": 3, "ratio": 0.5}, + ) + ) + table.put_index(idx) + got = table.get_index("spc_1") + assert got.version == 2 + assert len(got.entries) == 1 + e = got.entries[0] + assert e.size == 123 and isinstance(e.size, int) + assert e.indexed == {"open": True, "count": 3, "ratio": 0.5} + + def test_member_round_trip_and_listing(self, table): + table.put_member("spc_1", SpaceMember(email=FRIEND_EMAIL, permission="editor")) + got = table.get_member("spc_1", FRIEND_EMAIL) + assert got is not None and got.permission == "editor" + assert [m.email for m in table.list_members("spc_1")] == [FRIEND_EMAIL] + assert table.list_member_space_ids(FRIEND_EMAIL) == ["spc_1"] + + def test_member_email_normalized(self, table): + table.put_member("spc_1", SpaceMember(email="MiXeD@Example.EDU")) + assert table.get_member("spc_1", "mixed@example.edu") is not None + + def test_delete_member(self, table): + table.put_member("spc_1", SpaceMember(email=FRIEND_EMAIL)) + table.delete_member("spc_1", FRIEND_EMAIL) + assert table.get_member("spc_1", FRIEND_EMAIL) is None + + def test_delete_space_removes_all_rows(self, table): + table.put_space(MemorySpace(space_id="spc_1", name="X", owner_id=OWNER)) + table.put_index(MemoryIndex(space_id="spc_1")) + table.put_member("spc_1", SpaceMember(email=FRIEND_EMAIL)) + table.delete_space("spc_1") + assert table.get_space("spc_1") is None + assert table.get_member("spc_1", FRIEND_EMAIL) is None + assert table.get_index("spc_1").entries == [] + + +# ============================ service: lifecycle ============================ + + +class TestCreateAndList: + def test_create_seeds_index_and_rows(self, service): + s = service.create_space(OWNER, OWNER_EMAIL, "Brain", template="chief-of-staff") + assert s.space_id.startswith("spc_") + assert s.template == "chief-of-staff" + assert s.index_s3_key + # index text is seeded from the template + text = service.read_index(s.space_id, OWNER, OWNER_EMAIL) + assert "Strategic priorities" in text + + def test_create_rejects_unknown_template(self, service): + with pytest.raises(Exception): + service.create_space(OWNER, OWNER_EMAIL, "X", template="does-not-exist") + + def test_create_rejects_blank_name(self, service): + with pytest.raises(Exception): + service.create_space(OWNER, OWNER_EMAIL, " ", template="blank") + + def test_list_owned_and_shared(self, service): + a = service.create_space(OWNER, OWNER_EMAIL, "A") + b = service.create_space(OWNER, OWNER_EMAIL, "B") + # a third space owned by someone else, shared with FRIEND + other = service.create_space(STRANGER, STRANGER_EMAIL, "Shared") + service.share(other.space_id, STRANGER, STRANGER_EMAIL, FRIEND_EMAIL, "viewer") + + owner_spaces = { + s.space_id for s, _ in service.list_spaces_for_user(OWNER, OWNER_EMAIL) + } + assert owner_spaces == {a.space_id, b.space_id} + + friend = service.list_spaces_for_user(FRIEND, FRIEND_EMAIL) + assert {s.space_id for s, _ in friend} == {other.space_id} + # shared-in space carries the member's actual grant, not a placeholder + assert friend[0][1] == "viewer" + + def test_delete_space_owner_only(self, space, service): + with pytest.raises(MemorySpacePermissionError): + service.delete_space(space.space_id, STRANGER, STRANGER_EMAIL) + service.delete_space(space.space_id, OWNER, OWNER_EMAIL) + with pytest.raises(MemorySpaceNotFoundError): + service.get_space(space.space_id, OWNER, OWNER_EMAIL) + + +# ============================ service: permissions ============================ + + +class TestPermissions: + def test_owner_resolves(self, space, service): + _, role = service.resolve_permission(space.space_id, OWNER, OWNER_EMAIL) + assert role == "owner" + + def test_member_resolves(self, space, service): + service.share(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "editor") + _, role = service.resolve_permission(space.space_id, FRIEND, FRIEND_EMAIL) + assert role == "editor" + + def test_stranger_has_no_role(self, space, service): + s, role = service.resolve_permission(space.space_id, STRANGER, STRANGER_EMAIL) + assert s is not None and role is None + + def test_missing_space_resolves_none(self, service): + s, role = service.resolve_permission("nope", OWNER, OWNER_EMAIL) + assert s is None and role is None + + def test_get_space_denied_for_stranger(self, space, service): + with pytest.raises(MemorySpacePermissionError): + service.get_space(space.space_id, STRANGER, STRANGER_EMAIL) + + def test_get_missing_space_raises_not_found(self, service): + with pytest.raises(MemorySpaceNotFoundError): + service.get_space("nope", OWNER, OWNER_EMAIL) + + +# ============================ service: sharing ============================ + + +class TestSharing: + def test_share_requires_owner(self, space, service): + service.share(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "viewer") + # an editor cannot re-share + with pytest.raises(MemorySpacePermissionError): + service.share(space.space_id, FRIEND, FRIEND_EMAIL, STRANGER_EMAIL, "viewer") + + def test_list_members_editor_ok_viewer_denied(self, space, service): + service.share(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "editor") + service.share(space.space_id, OWNER, OWNER_EMAIL, STRANGER_EMAIL, "viewer") + assert len(service.list_members(space.space_id, OWNER, OWNER_EMAIL)) == 2 + # editor may view members + assert len(service.list_members(space.space_id, FRIEND, FRIEND_EMAIL)) == 2 + # viewer may not + with pytest.raises(MemorySpacePermissionError): + service.list_members(space.space_id, STRANGER, STRANGER_EMAIL) + + def test_revoke(self, space, service): + service.share(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "viewer") + service.revoke(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL) + _, role = service.resolve_permission(space.space_id, FRIEND, FRIEND_EMAIL) + assert role is None + + def test_update_share_changes_role_preserving_origin(self, space, service): + created = service.share( + space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "viewer" + ) + updated = service.update_share( + space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "editor" + ) + assert updated.permission == "editor" + assert updated.created_at == created.created_at + _, role = service.resolve_permission(space.space_id, FRIEND, FRIEND_EMAIL) + assert role == "editor" + + def test_update_share_unknown_member_raises(self, space, service): + with pytest.raises(MemorySpaceNotFoundError): + service.update_share( + space.space_id, OWNER, OWNER_EMAIL, STRANGER_EMAIL, "editor" + ) + + def test_update_share_requires_owner(self, space, service): + service.share(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "editor") + with pytest.raises(MemorySpacePermissionError): + service.update_share( + space.space_id, FRIEND, FRIEND_EMAIL, FRIEND_EMAIL, "viewer" + ) + + def test_member_can_leave(self, space, service): + service.share(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "editor") + service.leave_space(space.space_id, FRIEND, FRIEND_EMAIL) + _, role = service.resolve_permission(space.space_id, FRIEND, FRIEND_EMAIL) + assert role is None + # the space itself still exists for the owner + assert service.get_space(space.space_id, OWNER, OWNER_EMAIL) is not None + + def test_owner_cannot_leave(self, space, service): + with pytest.raises(Exception): + service.leave_space(space.space_id, OWNER, OWNER_EMAIL) + + def test_non_member_cannot_leave(self, space, service): + with pytest.raises(MemorySpacePermissionError): + service.leave_space(space.space_id, STRANGER, STRANGER_EMAIL) + + +# ============================ service: index + entries ============================ + + +class TestIndexAndEntries: + def test_update_and_read_index(self, space, service): + service.update_index(space.space_id, OWNER, OWNER_EMAIL, "# New index\n") + assert service.read_index(space.space_id, OWNER, OWNER_EMAIL) == "# New index\n" + + def test_update_index_requires_editor(self, space, service): + service.share(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "viewer") + with pytest.raises(MemorySpacePermissionError): + service.update_index(space.space_id, FRIEND, FRIEND_EMAIL, "nope") + + def test_write_and_read_entry(self, space, service): + ref = service.write_entry( + space.space_id, + OWNER, + OWNER_EMAIL, + "jane-doe", + "# Jane\nVP Research", + entry_type="entity", + description="VP Research", + indexed={"status": "active"}, + ) + assert ref.slug == "jane-doe" + assert ref.updated_by == OWNER + body = service.read_entry(space.space_id, OWNER, OWNER_EMAIL, "jane-doe") + assert "VP Research" in body + + def test_write_entry_requires_editor(self, space, service): + service.share(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "viewer") + with pytest.raises(MemorySpacePermissionError): + service.write_entry(space.space_id, FRIEND, FRIEND_EMAIL, "x", "body") + + def test_editor_can_write(self, space, service): + service.share(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "editor") + ref = service.write_entry(space.space_id, FRIEND, FRIEND_EMAIL, "note", "hi") + assert ref.updated_by == FRIEND + + def test_write_replaces_same_slug_and_gcs_old(self, space, service): + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "n", "v1") + old_ref = service._find_ref(space.space_id, "n") + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "n", "v2") + # manifest has a single entry with the new content + entries = service.list_entries(space.space_id, OWNER, OWNER_EMAIL) + assert len(entries) == 1 + assert service.read_entry(space.space_id, OWNER, OWNER_EMAIL, "n") == "v2" + # old content-addressed object was garbage collected + from apis.shared.memory.store import MemorySpaceStoreError + + with pytest.raises(MemorySpaceStoreError): + service.store.get(old_ref.s3_key) + + def test_list_entries_filter_by_type_and_where(self, space, service): + service.write_entry( + space.space_id, OWNER, OWNER_EMAIL, "p1", "b", + entry_type="entity", indexed={"open": True}, + ) + service.write_entry( + space.space_id, OWNER, OWNER_EMAIL, "p2", "b", + entry_type="entity", indexed={"open": False}, + ) + service.write_entry( + space.space_id, OWNER, OWNER_EMAIL, "f1", "b", entry_type="fact" + ) + entities = service.list_entries( + space.space_id, OWNER, OWNER_EMAIL, entry_type="entity" + ) + assert {e.slug for e in entities} == {"p1", "p2"} + open_ones = service.list_entries( + space.space_id, OWNER, OWNER_EMAIL, where={"open": True} + ) + assert {e.slug for e in open_ones} == {"p1"} + + def test_read_missing_entry_raises(self, space, service): + with pytest.raises(MemorySpaceNotFoundError): + service.read_entry(space.space_id, OWNER, OWNER_EMAIL, "ghost") + + def test_delete_entry(self, space, service): + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "n", "v") + ref = service._find_ref(space.space_id, "n") + service.delete_entry(space.space_id, OWNER, OWNER_EMAIL, "n") + assert service.list_entries(space.space_id, OWNER, OWNER_EMAIL) == [] + from apis.shared.memory.store import MemorySpaceStoreError + + with pytest.raises(MemorySpaceStoreError): + service.store.get(ref.s3_key) + + def test_delete_missing_entry_raises(self, space, service): + with pytest.raises(MemorySpaceNotFoundError): + service.delete_entry(space.space_id, OWNER, OWNER_EMAIL, "ghost") + + def test_viewer_can_read_entry(self, space, service): + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "n", "shared body") + service.share(space.space_id, OWNER, OWNER_EMAIL, FRIEND_EMAIL, "viewer") + assert ( + service.read_entry(space.space_id, FRIEND, FRIEND_EMAIL, "n") + == "shared body" + ) + + +# ==================== service: manifest concurrency (A4) ==================== + + +class TestConsolidation: + def test_reports_healthy_space(self, space, service): + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "a", "one") + report = service.consolidate(space.space_id, OWNER, OWNER_EMAIL) + assert report.entry_count == 1 + assert report.over_cap is False + assert report.duplicate_groups == [] + assert report.dead_links == [] + assert report.orphans_deleted == 0 + + def test_gc_deletes_orphaned_objects(self, space, service): + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "a", "one") + # Simulate a leaked object (crashed write) directly in the store. + orphan_key = service.store.put( + space_id=space.space_id, content=b"leaked", content_type="text/markdown" + ) + assert orphan_key in service.store.list_keys(space.space_id) + + report = service.consolidate(space.space_id, OWNER, OWNER_EMAIL) + assert report.orphans_deleted == 1 + assert orphan_key not in service.store.list_keys(space.space_id) + # the live entry's object is untouched + assert service.read_entry(space.space_id, OWNER, OWNER_EMAIL, "a") == "one" + + def test_gc_can_be_skipped(self, space, service): + service.store.put( + space_id=space.space_id, content=b"leaked", content_type="text/markdown" + ) + report = service.consolidate( + space.space_id, OWNER, OWNER_EMAIL, apply_gc=False + ) + assert report.orphans_deleted == 0 + + def test_reports_duplicate_content_without_merging(self, space, service): + # Two different slugs, byte-identical bodies β†’ same content hash. + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "a", "same body") + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "b", "same body") + report = service.consolidate(space.space_id, OWNER, OWNER_EMAIL) + assert report.duplicate_groups == [["a", "b"]] + # both entries still exist β€” consolidation never auto-merges + assert len(service.list_entries(space.space_id, OWNER, OWNER_EMAIL)) == 2 + + def test_reports_dead_wikilinks(self, space, service): + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "jane", "hi") + service.update_index( + space.space_id, OWNER, OWNER_EMAIL, "- [[jane]]\n- [[ghost]]\n" + ) + report = service.consolidate(space.space_id, OWNER, OWNER_EMAIL) + assert report.dead_links == ["ghost"] + assert report.stripped_dead_links is False + # index untouched unless stripping is requested + assert "[[ghost]]" in service.read_index(space.space_id, OWNER, OWNER_EMAIL) + + def test_strip_dead_links_unlinks_but_keeps_prose(self, space, service): + service.update_index( + space.space_id, OWNER, OWNER_EMAIL, "See [[ghost]] for details.\n" + ) + report = service.consolidate( + space.space_id, OWNER, OWNER_EMAIL, strip_dead_links=True + ) + assert report.stripped_dead_links is True + text = service.read_index(space.space_id, OWNER, OWNER_EMAIL) + assert "[[ghost]]" not in text + assert "See ghost for details." in text + + def test_over_cap_flag(self, space, service, monkeypatch): + monkeypatch.setenv("MEMORY_SPACE_INDEX_CAP", "2") + for slug in ("a", "b", "c"): + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, slug, slug) + report = service.consolidate(space.space_id, OWNER, OWNER_EMAIL) + assert report.index_cap == 2 + assert report.over_cap is True + # over-cap is reported, never auto-evicted + assert len(service.list_entries(space.space_id, OWNER, OWNER_EMAIL)) == 3 + + def test_requires_editor(self, space, service): + service.share(space.space_id, OWNER, OWNER_EMAIL, STRANGER_EMAIL, "viewer") + with pytest.raises(MemorySpacePermissionError): + service.consolidate(space.space_id, STRANGER, STRANGER_EMAIL) + + +class TestManifestConcurrency: + def test_version_increments_per_write(self, space, service): + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "a", "1") + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "b", "2") + service.delete_entry(space.space_id, OWNER, OWNER_EMAIL, "a") + # three manifest mutations from the seeded version 0 + assert service.repository.get_index(space.space_id).version == 3 + + def test_repository_rejects_stale_conditional_write(self, space, service): + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "a", "1") # -> v1 + stale = service.repository.get_index(space.space_id) + # a writer that read v0 tries to commit against the now-v1 row + with pytest.raises(OptimisticLockError): + service.repository.put_index(stale, expected_version=0) + + def test_write_retries_and_converges_on_transient_conflict(self, space, service): + real_put = service.repository.put_index + calls = {"n": 0} + + def flaky(index, *, expected_version=None): + calls["n"] += 1 + if calls["n"] == 1: # first attempt loses the race, then converges + raise OptimisticLockError("transient") + return real_put(index, expected_version=expected_version) + + service.repository.put_index = flaky + ref = service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "a", "1") + assert ref.slug == "a" + assert calls["n"] >= 2 + assert [ + e.slug for e in service.list_entries(space.space_id, OWNER, OWNER_EMAIL) + ] == ["a"] + + def test_write_gives_up_after_max_retries(self, space, service): + def always_conflict(index, *, expected_version=None): + raise OptimisticLockError("perpetual") + + service.repository.put_index = always_conflict + with pytest.raises(MemorySpaceConcurrencyError): + service.write_entry(space.space_id, OWNER, OWNER_EMAIL, "a", "1") diff --git a/backend/tests/shared/test_memory_store.py b/backend/tests/shared/test_memory_store.py new file mode 100644 index 00000000..d3499642 --- /dev/null +++ b/backend/tests/shared/test_memory_store.py @@ -0,0 +1,136 @@ +"""Tests for the S3-backed Memory Space byte store (PR-1). + +moto-backed: exercises content-hash keying, dedupe (no second put for +identical bytes), get/delete round-trip, and the not-configured guard. +Mirrors ``test_skills_resource_store.py``. +""" + +import boto3 +import pytest +from moto import mock_aws + +from apis.shared.memory.store import ( + MemorySpaceStore, + MemorySpaceStoreError, + compute_content_hash, + content_key, + get_memory_space_store, +) + +AWS_REGION = "us-east-1" +BUCKET = "test-memory-spaces" + + +@pytest.fixture() +def aws_env(monkeypatch): + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") + with mock_aws(): + yield + + +@pytest.fixture() +def s3_client(aws_env): + client = boto3.client("s3", region_name=AWS_REGION) + client.create_bucket(Bucket=BUCKET) + return client + + +@pytest.fixture() +def store(s3_client): + return MemorySpaceStore(bucket_name=BUCKET, s3_client=s3_client) + + +class TestContentKey: + def test_key_is_content_addressed(self): + digest = compute_content_hash(b"# Memory") + assert content_key("spc_abc", digest) == f"spaces/spc_abc/{digest}" + + def test_hash_is_stable_and_distinct(self): + assert compute_content_hash(b"a") == compute_content_hash(b"a") + assert compute_content_hash(b"a") != compute_content_hash(b"b") + + +class TestPutGetDelete: + def test_put_returns_content_addressed_key(self, store): + key = store.put( + space_id="spc_abc", content=b"# Memory", content_type="text/markdown" + ) + assert key == content_key("spc_abc", compute_content_hash(b"# Memory")) + + def test_get_round_trip(self, store): + key = store.put( + space_id="spc_abc", content=b"hello", content_type="text/markdown" + ) + assert store.get(key) == b"hello" + + def test_put_is_idempotent_dedupe(self, store, s3_client): + k1 = store.put(space_id="s", content=b"same", content_type="text/markdown") + k2 = store.put(space_id="s", content=b"same", content_type="text/markdown") + assert k1 == k2 + listed = s3_client.list_objects_v2(Bucket=BUCKET).get("Contents", []) + assert len(listed) == 1 + + def test_different_content_distinct_keys(self, store): + k1 = store.put(space_id="s", content=b"one", content_type="text/markdown") + k2 = store.put(space_id="s", content=b"two", content_type="text/markdown") + assert k1 != k2 + + def test_get_missing_raises(self, store): + with pytest.raises(MemorySpaceStoreError): + store.get("spaces/s/deadbeef") + + def test_delete_round_trip(self, store): + key = store.put(space_id="s", content=b"bye", content_type="text/markdown") + store.delete(key) + with pytest.raises(MemorySpaceStoreError): + store.get(key) + + def test_delete_absent_is_noop(self, store): + # Best-effort: deleting a missing key does not raise. + store.delete("spaces/s/missing") + + +class TestListKeys: + def test_lists_only_the_space_prefix(self, store): + k1 = store.put(space_id="s1", content=b"a", content_type="text/markdown") + k2 = store.put(space_id="s1", content=b"b", content_type="text/markdown") + store.put(space_id="s2", content=b"c", content_type="text/markdown") + assert set(store.list_keys("s1")) == {k1, k2} + + def test_empty_space_returns_empty(self, store): + assert store.list_keys("nope") == [] + + def test_disabled_returns_empty(self, monkeypatch): + monkeypatch.delenv("S3_MEMORY_SPACES_BUCKET_NAME", raising=False) + assert MemorySpaceStore(bucket_name=None).list_keys("s") == [] + + +class TestNotConfigured: + def test_disabled_when_no_bucket(self, monkeypatch): + monkeypatch.delenv("S3_MEMORY_SPACES_BUCKET_NAME", raising=False) + store = MemorySpaceStore(bucket_name=None) + assert store.enabled is False + + def test_put_raises_when_not_configured(self, monkeypatch): + monkeypatch.delenv("S3_MEMORY_SPACES_BUCKET_NAME", raising=False) + store = MemorySpaceStore(bucket_name=None) + with pytest.raises(MemorySpaceStoreError): + store.put(space_id="s", content=b"x", content_type="text/markdown") + + def test_delete_when_disabled_is_noop(self, monkeypatch): + monkeypatch.delenv("S3_MEMORY_SPACES_BUCKET_NAME", raising=False) + MemorySpaceStore(bucket_name=None).delete("spaces/s/x") + + +def test_global_store_singleton(monkeypatch): + monkeypatch.setenv("S3_MEMORY_SPACES_BUCKET_NAME", "b") + import apis.shared.memory.store as store_mod + + store_mod._store = None # reset the process-global for a clean read + a = get_memory_space_store() + b = get_memory_space_store() + assert a is b + store_mod._store = None diff --git a/backend/tests/shared/test_preview_prefix_consistency.py b/backend/tests/shared/test_preview_prefix_consistency.py new file mode 100644 index 00000000..4a50c50d --- /dev/null +++ b/backend/tests/shared/test_preview_prefix_consistency.py @@ -0,0 +1,22 @@ +"""Guard: the preview-session prefix stays in lockstep across its copies. + +``apis.shared.sessions.preview`` deliberately re-declares the ``"preview-"`` +literal (rather than importing it from ``agents``) so the lean scheduled-runs +Lambda image can detect preview sessions without pulling in agents/strands. +This test fails loudly if that copy ever drifts from the canonical +``agents.main_agent.config.constants.Prefixes.PREVIEW_SESSION``. +""" + +from apis.shared.sessions.preview import PREVIEW_SESSION_PREFIX, is_preview_session + + +def test_shared_prefix_matches_agents_constant() -> None: + from agents.main_agent.config.constants import Prefixes + + assert PREVIEW_SESSION_PREFIX == Prefixes.PREVIEW_SESSION + + +def test_is_preview_session_behavior() -> None: + assert is_preview_session("preview-abc123") + assert not is_preview_session("headless-075af61855bf4240") + assert not is_preview_session("") diff --git a/backend/tests/shared/test_scheduled_prompts.py b/backend/tests/shared/test_scheduled_prompts.py new file mode 100644 index 00000000..f5cc6354 --- /dev/null +++ b/backend/tests/shared/test_scheduled_prompts.py @@ -0,0 +1,515 @@ +"""Scheduled-prompt repository tests (moto DynamoDB). + +Covers: +- cadence -> next_run_at math (daily/weekday/weekly, timezone-aware, DST-safe) +- sparse DueScheduleIndex (paused schedules are physically absent) +- conditional re-arm (double-fired dispatcher tick is idempotent) +- state transitions (active <-> paused, paused_error) +- snapshot semantics (enabled_tools frozen at creation) +- per-user cap +- delete = total revocation +""" + +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +import pytest + +from apis.shared.scheduled_prompts.models import DUE_INDEX_PK +from apis.shared.scheduled_prompts.service import ( + ScheduledPromptLimitExceeded, + compute_next_run_at, + create_scheduled_prompt, + delete_scheduled_prompt, + get_scheduled_prompt, + interval_to_minutes, + list_due_schedules, + list_scheduled_prompts, + max_schedules_per_user, + rearm_schedule, + record_run_result, + set_schedule_state, + update_scheduled_prompt, +) + +pytestmark = pytest.mark.asyncio + +USER_ID = "user-1" +OTHER_USER_ID = "user-2" + + +async def _make_schedule( + label="Morning Briefing", + prompt_text="Summarize my day", + cadence="daily", + hour_local=9, + timezone_name="America/Boise", + weekday=None, + user_id=USER_ID, + **kwargs, +): + return await create_scheduled_prompt( + user_id=user_id, + label=label, + prompt_text=prompt_text, + cadence=cadence, + hour_local=hour_local, + timezone_name=timezone_name, + weekday=weekday, + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# compute_next_run_at β€” cadence math +# --------------------------------------------------------------------------- + + +class TestComputeNextRunAt: + def test_daily_before_hour_fires_today(self): + # 2026-07-05 06:00 Boise time (MDT, UTC-6); "9am daily" -> today 9am. + from_time = datetime(2026, 7, 5, 12, 0, tzinfo=timezone.utc) # 06:00 MDT + result = compute_next_run_at("daily", 9, "America/Boise", from_time=from_time) + expected_utc = datetime(2026, 7, 5, 9, 0, tzinfo=ZoneInfo("America/Boise")).astimezone(timezone.utc) + assert result == expected_utc.isoformat().replace("+00:00", "Z") + + def test_daily_after_hour_rolls_to_tomorrow(self): + # 2026-07-05 10:00 Boise time; "9am daily" already passed -> tomorrow. + from_time = datetime(2026, 7, 5, 16, 0, tzinfo=timezone.utc) # 10:00 MDT + result = compute_next_run_at("daily", 9, "America/Boise", from_time=from_time) + expected = datetime(2026, 7, 6, 9, 0, tzinfo=ZoneInfo("America/Boise")).astimezone(timezone.utc) + assert result == expected.isoformat().replace("+00:00", "Z") + + def test_daily_exactly_on_hour_rolls_to_tomorrow(self): + # candidate <= base is treated as already passed (strictly future guarantee). + from_time = datetime(2026, 7, 5, 15, 0, tzinfo=timezone.utc) # 09:00 MDT exactly + result = compute_next_run_at("daily", 9, "America/Boise", from_time=from_time) + expected = datetime(2026, 7, 6, 9, 0, tzinfo=ZoneInfo("America/Boise")).astimezone(timezone.utc) + assert result == expected.isoformat().replace("+00:00", "Z") + + def test_weekday_skips_weekend(self): + # 2026-07-05 is a Sunday in Boise; "9am weekday" should land Monday 7/6. + from_time = datetime(2026, 7, 5, 12, 0, tzinfo=timezone.utc) # Sun 06:00 MDT + result = compute_next_run_at("weekday", 9, "America/Boise", from_time=from_time) + expected = datetime(2026, 7, 6, 9, 0, tzinfo=ZoneInfo("America/Boise")).astimezone(timezone.utc) + assert result == expected.isoformat().replace("+00:00", "Z") + + def test_weekday_friday_afternoon_rolls_to_monday(self): + # 2026-07-03 is a Friday; if "9am weekday" already passed, next is Monday 7/6. + from_time = datetime(2026, 7, 3, 16, 0, tzinfo=timezone.utc) # Fri 10:00 MDT + result = compute_next_run_at("weekday", 9, "America/Boise", from_time=from_time) + expected = datetime(2026, 7, 6, 9, 0, tzinfo=ZoneInfo("America/Boise")).astimezone(timezone.utc) + assert result == expected.isoformat().replace("+00:00", "Z") + + def test_weekly_lands_on_requested_weekday(self): + # 2026-07-05 is Sunday (weekday()==6). Requesting Wednesday (2). + from_time = datetime(2026, 7, 5, 12, 0, tzinfo=timezone.utc) + result = compute_next_run_at("weekly", 9, "America/Boise", weekday=2, from_time=from_time) + expected = datetime(2026, 7, 8, 9, 0, tzinfo=ZoneInfo("America/Boise")).astimezone(timezone.utc) + assert result == expected.isoformat().replace("+00:00", "Z") + + def test_weekly_same_day_but_already_passed_rolls_a_full_week(self): + # Sunday (weekday()==6) requesting Sunday, but after 9am -> next Sunday. + from_time = datetime(2026, 7, 5, 17, 0, tzinfo=timezone.utc) # Sun 11:00 MDT + result = compute_next_run_at("weekly", 9, "America/Boise", weekday=6, from_time=from_time) + expected = datetime(2026, 7, 12, 9, 0, tzinfo=ZoneInfo("America/Boise")).astimezone(timezone.utc) + assert result == expected.isoformat().replace("+00:00", "Z") + + def test_weekly_requires_weekday(self): + with pytest.raises(ValueError, match="weekday is required"): + compute_next_run_at("weekly", 9, "America/Boise", weekday=None) + + def test_unknown_cadence_raises(self): + with pytest.raises(ValueError, match="Unknown cadence"): + compute_next_run_at("monthly", 9, "America/Boise") # type: ignore[arg-type] + + def test_interval_adds_a_plain_delta_from_now(self): + # "every 90 minutes" is a fixed delta off from_time β€” no hour/tz anchor. + from_time = datetime(2026, 7, 5, 12, 0, tzinfo=timezone.utc) + result = compute_next_run_at( + "interval", 9, "America/Boise", from_time=from_time, interval_minutes=90 + ) + expected = datetime(2026, 7, 5, 13, 30, tzinfo=timezone.utc) + assert result == expected.isoformat().replace("+00:00", "Z") + + def test_interval_ignores_hour_and_timezone(self): + # hour_local/timezone are meaningless for interval; two different zones + # produce the same UTC delta. + from_time = datetime(2026, 7, 5, 12, 0, tzinfo=timezone.utc) + a = compute_next_run_at("interval", 3, "America/Boise", from_time=from_time, interval_minutes=360) + b = compute_next_run_at("interval", 21, "Asia/Tokyo", from_time=from_time, interval_minutes=360) + assert a == b == "2026-07-05T18:00:00Z" + + def test_interval_requires_positive_minutes(self): + with pytest.raises(ValueError, match="interval_minutes is required"): + compute_next_run_at("interval", 9, "America/Boise") + with pytest.raises(ValueError, match="interval_minutes is required"): + compute_next_run_at("interval", 9, "America/Boise", interval_minutes=0) + + +class TestIntervalToMinutes: + def test_hours_convert(self): + assert interval_to_minutes(6, "hours") == 360 + + def test_minutes_passthrough(self): + assert interval_to_minutes(45, "minutes") == 45 + + def test_missing_half_is_none(self): + assert interval_to_minutes(None, "hours") is None + assert interval_to_minutes(6, None) is None + + def test_different_timezone_produces_different_utc_time(self): + from_time = datetime(2026, 7, 5, 0, 0, tzinfo=timezone.utc) + boise = compute_next_run_at("daily", 9, "America/Boise", from_time=from_time) + tokyo = compute_next_run_at("daily", 9, "Asia/Tokyo", from_time=from_time) + assert boise != tokyo + + +# --------------------------------------------------------------------------- +# create / get / list +# --------------------------------------------------------------------------- + + +class TestCreateAndGet: + async def test_create_get_roundtrip(self, sessions_metadata_table): + schedule = await _make_schedule() + + assert schedule.schedule_id.startswith("sched-") + assert schedule.state == "active" + assert schedule.next_run_at is not None + assert schedule.runs_today == 0 + assert schedule.max_runs_per_day == 24 + + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched is not None + assert fetched.label == "Morning Briefing" + assert fetched.prompt_text == "Summarize my day" + assert fetched.cadence == "daily" + assert fetched.hour_local == 9 + assert fetched.timezone == "America/Boise" + + async def test_active_schedule_has_due_index_keys(self, sessions_metadata_table): + schedule = await _make_schedule() + + item = sessions_metadata_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"} + )["Item"] + assert item["GSI3_PK"] == DUE_INDEX_PK + assert item["GSI3_SK"] == f"{schedule.next_run_at}#{schedule.schedule_id}" + + async def test_get_missing_returns_none(self, sessions_metadata_table): + assert await get_scheduled_prompt(USER_ID, "sched-missing") is None + + async def test_list_scopes_to_owner(self, sessions_metadata_table): + mine = await _make_schedule(user_id=USER_ID) + await _make_schedule(user_id=OTHER_USER_ID, label="Someone else's") + + mine_list = await list_scheduled_prompts(USER_ID) + assert [s.schedule_id for s in mine_list] == [mine.schedule_id] + + theirs_list = await list_scheduled_prompts(OTHER_USER_ID) + assert len(theirs_list) == 1 + assert theirs_list[0].schedule_id != mine.schedule_id + + async def test_weekly_requires_weekday_at_creation(self, sessions_metadata_table): + with pytest.raises(ValueError): + await _make_schedule(cadence="weekly", weekday=None) + + async def test_interval_persists_value_and_unit(self, sessions_metadata_table): + schedule = await _make_schedule( + cadence="interval", interval_value=6, interval_unit="hours" + ) + assert schedule.cadence == "interval" + assert schedule.next_run_at is not None + + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched is not None + assert fetched.interval_value == 6 + assert fetched.interval_unit == "hours" + + async def test_per_user_cap_enforced(self, sessions_metadata_table, monkeypatch): + monkeypatch.setenv("SCHEDULED_RUNS_MAX_PER_USER", "2") + assert max_schedules_per_user() == 2 + + await _make_schedule(label="one") + await _make_schedule(label="two") + with pytest.raises(ScheduledPromptLimitExceeded): + await _make_schedule(label="three") + + +# --------------------------------------------------------------------------- +# Snapshot semantics β€” enabled_tools frozen at creation (Phase A punch #7) +# --------------------------------------------------------------------------- + + +class TestEnabledToolsSnapshot: + async def test_explicit_tools_are_persisted_verbatim(self, sessions_metadata_table): + schedule = await _make_schedule(enabled_tools=["class_search", "web_search"]) + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.enabled_tools == ["class_search", "web_search"] + + async def test_none_enabled_tools_persists_as_none(self, sessions_metadata_table): + # The service itself does no lazy resolution; the route layer is + # responsible for resolving "None" to a concrete snapshot before + # calling create_scheduled_prompt. Here we assert the service is a + # dumb store β€” it never re-derives tools at read time. + schedule = await _make_schedule(enabled_tools=None) + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.enabled_tools is None + + async def test_editing_the_schedule_does_not_change_the_snapshot(self, sessions_metadata_table): + schedule = await _make_schedule(enabled_tools=["class_search"]) + await update_scheduled_prompt(USER_ID, schedule.schedule_id, label="New label") + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.enabled_tools == ["class_search"] + + async def test_explicit_tools_update_replaces_the_snapshot(self, sessions_metadata_table): + schedule = await _make_schedule(enabled_tools=["class_search"]) + await update_scheduled_prompt(USER_ID, schedule.schedule_id, enabled_tools=["gmail_search"]) + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.enabled_tools == ["gmail_search"] + + +# --------------------------------------------------------------------------- +# list_due_schedules β€” sparse index behavior +# --------------------------------------------------------------------------- + + +class TestListDueSchedules: + async def test_active_overdue_schedule_is_due(self, sessions_metadata_table): + schedule = await _make_schedule() + # Force it overdue by re-arming to the past. + await set_schedule_state(USER_ID, schedule.schedule_id, "active", next_run_at="2000-01-01T00:00:00Z") + + due = await list_due_schedules(now="2999-01-01T00:00:00Z") + assert schedule.schedule_id in [s.schedule_id for s in due] + + async def test_paused_schedule_is_physically_absent_from_due_index(self, sessions_metadata_table): + schedule = await _make_schedule() + await set_schedule_state(USER_ID, schedule.schedule_id, "active", next_run_at="2000-01-01T00:00:00Z") + await set_schedule_state(USER_ID, schedule.schedule_id, "paused", state_reason="Paused by user") + + due = await list_due_schedules(now="2999-01-01T00:00:00Z") + assert schedule.schedule_id not in [s.schedule_id for s in due] + + # And the row itself has no GSI keys once paused. + item = sessions_metadata_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"} + )["Item"] + assert "GSI3_PK" not in item + assert "GSI3_SK" not in item + + async def test_not_yet_due_schedule_is_excluded(self, sessions_metadata_table): + schedule = await _make_schedule() # next_run_at is in the future by construction + due = await list_due_schedules(now="2000-01-01T00:00:00Z") + assert schedule.schedule_id not in [s.schedule_id for s in due] + + +# --------------------------------------------------------------------------- +# State transitions +# --------------------------------------------------------------------------- + + +class TestSetScheduleState: + async def test_pause_removes_gsi_keys(self, sessions_metadata_table): + schedule = await _make_schedule() + ok = await set_schedule_state(USER_ID, schedule.schedule_id, "paused", state_reason="Paused by user") + assert ok is True + + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.state == "paused" + assert fetched.state_reason == "Paused by user" + + async def test_resume_requires_next_run_at(self, sessions_metadata_table): + schedule = await _make_schedule() + await set_schedule_state(USER_ID, schedule.schedule_id, "paused") + with pytest.raises(ValueError): + await set_schedule_state(USER_ID, schedule.schedule_id, "active") + + async def test_resume_readds_gsi_keys(self, sessions_metadata_table): + schedule = await _make_schedule() + await set_schedule_state(USER_ID, schedule.schedule_id, "paused") + await set_schedule_state(USER_ID, schedule.schedule_id, "active", next_run_at="2999-01-01T00:00:00Z") + + item = sessions_metadata_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"} + )["Item"] + assert item["GSI3_PK"] == DUE_INDEX_PK + assert item["GSI3_SK"] == f"2999-01-01T00:00:00Z#{schedule.schedule_id}" + + async def test_paused_error_state(self, sessions_metadata_table): + schedule = await _make_schedule() + await set_schedule_state(USER_ID, schedule.schedule_id, "paused_error", state_reason="Too many failures") + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.state == "paused_error" + assert fetched.state_reason == "Too many failures" + + async def test_set_state_on_missing_schedule_returns_false(self, sessions_metadata_table): + ok = await set_schedule_state(USER_ID, "sched-missing", "paused") + assert ok is False + + +class TestRearmSchedule: + async def test_rearm_advances_next_run_at(self, sessions_metadata_table): + schedule = await _make_schedule() + ok = await rearm_schedule( + USER_ID, schedule.schedule_id, expected_next_run_at=schedule.next_run_at, new_next_run_at="2999-06-01T00:00:00Z" + ) + assert ok is True + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.next_run_at == "2999-06-01T00:00:00Z" + + async def test_conditional_rearm_is_idempotent_against_double_dispatch(self, sessions_metadata_table): + schedule = await _make_schedule() + first = await rearm_schedule( + USER_ID, schedule.schedule_id, expected_next_run_at=schedule.next_run_at, new_next_run_at="2999-06-01T00:00:00Z" + ) + # A second dispatcher racing on the same stale expected value loses. + second = await rearm_schedule( + USER_ID, schedule.schedule_id, expected_next_run_at=schedule.next_run_at, new_next_run_at="2999-07-01T00:00:00Z" + ) + assert first is True + assert second is False + + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.next_run_at == "2999-06-01T00:00:00Z" + + +# --------------------------------------------------------------------------- +# record_run_result β€” runaway-guard counter +# --------------------------------------------------------------------------- + + +class TestRecordRunResult: + async def test_records_status_and_session(self, sessions_metadata_table): + schedule = await _make_schedule() + await record_run_result(USER_ID, schedule.schedule_id, status="completed", session_id="sess-1") + + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.last_run_status == "completed" + assert fetched.last_run_session_id == "sess-1" + assert fetched.runs_today == 1 + + async def test_records_error(self, sessions_metadata_table): + schedule = await _make_schedule() + await record_run_result(USER_ID, schedule.schedule_id, status="failed", error="boom") + + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.last_run_status == "failed" + assert fetched.last_error == "boom" + + async def test_runs_today_increments_within_same_day(self, sessions_metadata_table): + schedule = await _make_schedule() + await record_run_result(USER_ID, schedule.schedule_id, status="completed") + await record_run_result(USER_ID, schedule.schedule_id, status="completed") + + fetched = await get_scheduled_prompt(USER_ID, schedule.schedule_id) + assert fetched.runs_today == 2 + + +# --------------------------------------------------------------------------- +# update_scheduled_prompt +# --------------------------------------------------------------------------- + + +class TestUpdateScheduledPrompt: + async def test_update_missing_returns_none(self, sessions_metadata_table): + assert await update_scheduled_prompt(USER_ID, "sched-missing", label="x") is None + + async def test_non_cadence_field_update_does_not_touch_next_run_at(self, sessions_metadata_table): + schedule = await _make_schedule() + original_next_run_at = schedule.next_run_at + + updated = await update_scheduled_prompt(USER_ID, schedule.schedule_id, label="Renamed") + assert updated.label == "Renamed" + assert updated.next_run_at == original_next_run_at + + async def test_cadence_change_on_active_schedule_recomputes_next_run_at(self, sessions_metadata_table): + schedule = await _make_schedule(cadence="daily", hour_local=9) + updated = await update_scheduled_prompt(USER_ID, schedule.schedule_id, hour_local=14) + + assert updated.hour_local == 14 + assert updated.next_run_at != schedule.next_run_at + + item = sessions_metadata_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"} + )["Item"] + assert item["GSI3_SK"] == f"{updated.next_run_at}#{schedule.schedule_id}" + + async def test_cadence_change_on_paused_schedule_does_not_touch_due_index(self, sessions_metadata_table): + schedule = await _make_schedule(cadence="daily", hour_local=9) + await set_schedule_state(USER_ID, schedule.schedule_id, "paused") + + updated = await update_scheduled_prompt(USER_ID, schedule.schedule_id, hour_local=14) + assert updated.hour_local == 14 + assert updated.state == "paused" + + item = sessions_metadata_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"} + )["Item"] + assert "GSI3_PK" not in item + + async def test_unset_clearable_field_leaves_it_unchanged(self, sessions_metadata_table): + schedule = await _make_schedule(assistant_id="ast-123", enabled_tools=["gmail_search"]) + # A label-only edit must not disturb the clearable fields (default UNSET). + updated = await update_scheduled_prompt(USER_ID, schedule.schedule_id, label="Renamed") + assert updated.assistant_id == "ast-123" + assert updated.enabled_tools == ["gmail_search"] + + async def test_set_clearable_field_updates_it(self, sessions_metadata_table): + schedule = await _make_schedule(assistant_id="ast-123", enabled_tools=["gmail_search"]) + updated = await update_scheduled_prompt( + USER_ID, schedule.schedule_id, assistant_id="ast-456", enabled_tools=["calendar_list"] + ) + assert updated.assistant_id == "ast-456" + assert updated.enabled_tools == ["calendar_list"] + + async def test_clear_assistant_removes_attribute(self, sessions_metadata_table): + schedule = await _make_schedule(assistant_id="ast-123") + updated = await update_scheduled_prompt(USER_ID, schedule.schedule_id, assistant_id=None) + assert updated.assistant_id is None + item = sessions_metadata_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"} + )["Item"] + assert "assistantId" not in item + + async def test_clear_enabled_tools_removes_attribute(self, sessions_metadata_table): + schedule = await _make_schedule(enabled_tools=["gmail_search"]) + updated = await update_scheduled_prompt(USER_ID, schedule.schedule_id, enabled_tools=None) + assert updated.enabled_tools is None + item = sessions_metadata_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"} + )["Item"] + assert "enabledTools" not in item + + async def test_clear_is_a_noop_when_field_already_absent(self, sessions_metadata_table): + # REMOVE on a missing attribute is harmless β€” no error, still absent. + schedule = await _make_schedule() # no assistant_id + updated = await update_scheduled_prompt(USER_ID, schedule.schedule_id, assistant_id=None) + assert updated is not None + assert updated.assistant_id is None + + +# --------------------------------------------------------------------------- +# Delete = total revocation +# --------------------------------------------------------------------------- + + +class TestDelete: + async def test_delete_removes_the_record_entirely(self, sessions_metadata_table): + schedule = await _make_schedule() + await delete_scheduled_prompt(USER_ID, schedule.schedule_id) + + assert await get_scheduled_prompt(USER_ID, schedule.schedule_id) is None + item = sessions_metadata_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SCHEDPROMPT#{schedule.schedule_id}"} + ) + assert "Item" not in item + + async def test_delete_drops_it_from_due_index_too(self, sessions_metadata_table): + schedule = await _make_schedule() + await set_schedule_state(USER_ID, schedule.schedule_id, "active", next_run_at="2000-01-01T00:00:00Z") + await delete_scheduled_prompt(USER_ID, schedule.schedule_id) + + due = await list_due_schedules(now="2999-01-01T00:00:00Z") + assert schedule.schedule_id not in [s.schedule_id for s in due] diff --git a/backend/tests/shared/test_sessions_metadata.py b/backend/tests/shared/test_sessions_metadata.py index 7b80f10b..47bb3749 100644 --- a/backend/tests/shared/test_sessions_metadata.py +++ b/backend/tests/shared/test_sessions_metadata.py @@ -74,6 +74,99 @@ async def test_get_nonexistent(self, sessions_metadata_table): assert result is None +class TestInterruptedTurnMarker: + """Refresh-survival marker for a turn interrupted before completion.""" + + @pytest.mark.asyncio + async def test_set_then_clear(self, sessions_metadata_table): + from apis.shared.sessions.metadata import ( + store_session_metadata, + get_session_metadata, + set_interrupted_turn, + clear_interrupted_turn, + ) + await store_session_metadata(session_id="i1", user_id="u1", session_metadata=_make_session_metadata(session_id="i1")) + + result = await get_session_metadata("i1", "u1") + assert not result.last_turn_interrupted + + await set_interrupted_turn("i1", "u1", reason="connection_lost", source="cancellation") + result = await get_session_metadata("i1", "u1") + assert result.last_turn_interrupted is True + assert result.last_turn_interrupt_reason == "connection_lost" + assert result.last_turn_interrupted_at + + # The clear is a POP: it returns the settled reason so the turn-start + # caller can drive the next-turn model note from the same read+write. + cleared_reason = await clear_interrupted_turn("i1", "u1") + assert cleared_reason == "connection_lost" + result = await get_session_metadata("i1", "u1") + assert not result.last_turn_interrupted + assert result.last_turn_interrupt_reason is None + + # Idempotent: popping an already-clear marker returns None. + assert await clear_interrupted_turn("i1", "u1") is None + + @pytest.mark.asyncio + async def test_user_stopped_wins_over_connection_lost(self, sessions_metadata_table): + # Client stop signal lands first; the cancellation fallback must NOT + # downgrade it. + from apis.shared.sessions.metadata import ( + store_session_metadata, + get_session_metadata, + set_interrupted_turn, + ) + await store_session_metadata(session_id="i2", user_id="u1", session_metadata=_make_session_metadata(session_id="i2")) + + await set_interrupted_turn("i2", "u1", reason="user_stopped", source="client_signal") + await set_interrupted_turn("i2", "u1", reason="connection_lost", source="cancellation") + + result = await get_session_metadata("i2", "u1") + assert result.last_turn_interrupt_reason == "user_stopped" + + @pytest.mark.asyncio + async def test_user_stopped_upgrades_connection_lost(self, sessions_metadata_table): + # Cancellation fallback lands first; a later client signal upgrades it. + from apis.shared.sessions.metadata import ( + store_session_metadata, + get_session_metadata, + set_interrupted_turn, + ) + await store_session_metadata(session_id="i3", user_id="u1", session_metadata=_make_session_metadata(session_id="i3")) + + await set_interrupted_turn("i3", "u1", reason="connection_lost", source="cancellation") + await set_interrupted_turn("i3", "u1", reason="user_stopped", source="client_signal") + + result = await get_session_metadata("i3", "u1") + assert result.last_turn_interrupt_reason == "user_stopped" + + @pytest.mark.asyncio + async def test_set_noop_when_session_missing(self, sessions_metadata_table): + from apis.shared.sessions.metadata import set_interrupted_turn, get_session_metadata + # No store first β€” must not raise, and nothing to read back. + await set_interrupted_turn("i-missing", "u1", reason="connection_lost") + assert await get_session_metadata("i-missing", "u1") is None + + @pytest.mark.asyncio + async def test_survives_response_round_trip(self, sessions_metadata_table): + from apis.shared.sessions.metadata import ( + store_session_metadata, + get_session_metadata, + set_interrupted_turn, + ) + from apis.shared.sessions.models import SessionMetadataResponse + + await store_session_metadata(session_id="i4", user_id="u1", session_metadata=_make_session_metadata(session_id="i4")) + await set_interrupted_turn("i4", "u1", reason="user_stopped", source="client_signal") + meta = await get_session_metadata("i4", "u1") + + resp = SessionMetadataResponse.model_validate(meta.model_dump(by_alias=True)) + assert resp.last_turn_interrupted is True + dumped = resp.model_dump(by_alias=True) + assert dumped["lastTurnInterrupted"] is True + assert dumped["lastTurnInterruptReason"] == "user_stopped" + + class TestTruncatedTurnMarker: """Refresh-survival marker for the max_tokens 'Continue' affordance.""" @@ -416,6 +509,84 @@ async def test_noop_for_preview_session(self, sessions_metadata_table): assert items == [] +class TestSessionUnread: + """Durable unread flag set by unattended (scheduled) runs, cleared on open.""" + + @pytest.mark.asyncio + async def test_set_then_mark_read(self, sessions_metadata_table): + from apis.shared.sessions.metadata import ( + ensure_session_metadata_exists, + set_session_unread, + mark_session_read, + get_session_metadata, + ) + await ensure_session_metadata_exists("s1", "u1") + assert (await get_session_metadata("s1", "u1")).unread is False + + await set_session_unread("s1", "u1", True) + assert (await get_session_metadata("s1", "u1")).unread is True + + await mark_session_read("s1", "u1") + assert (await get_session_metadata("s1", "u1")).unread is False + + @pytest.mark.asyncio + async def test_mark_unread_then_read_roundtrip(self, sessions_metadata_table): + """The manual mark_session_unread wrapper sets the flag; read clears it.""" + from apis.shared.sessions.metadata import ( + ensure_session_metadata_exists, + mark_session_unread, + mark_session_read, + get_session_metadata, + ) + await ensure_session_metadata_exists("s1", "u1") + + await mark_session_unread("s1", "u1") + assert (await get_session_metadata("s1", "u1")).unread is True + + await mark_session_read("s1", "u1") + assert (await get_session_metadata("s1", "u1")).unread is False + + @pytest.mark.asyncio + async def test_survives_sk_rotation(self, sessions_metadata_table): + """Unread set post-run must survive a later per-turn SK rotation.""" + from apis.shared.sessions.metadata import ( + ensure_session_metadata_exists, + set_session_unread, + update_session_activity, + get_session_metadata, + ) + await ensure_session_metadata_exists("s1", "u1") + await set_session_unread("s1", "u1", True) + await update_session_activity(session_id="s1", user_id="u1", last_model="claude-3") + assert (await get_session_metadata("s1", "u1")).unread is True + + @pytest.mark.asyncio + async def test_noop_when_session_missing(self, sessions_metadata_table): + from apis.shared.sessions.metadata import set_session_unread + # No row exists β€” best-effort, must not raise or create a row. + await set_session_unread("ghost", "u1", True) + assert sessions_metadata_table.scan()["Items"] == [] + + @pytest.mark.asyncio + async def test_noop_for_preview_session(self, sessions_metadata_table): + from apis.shared.sessions.metadata import set_session_unread + await set_session_unread("preview-abc", "u1", True) + assert sessions_metadata_table.scan()["Items"] == [] + + @pytest.mark.asyncio + async def test_user_isolation(self, sessions_metadata_table): + """A set for one user must not flip another user's same-id session.""" + from apis.shared.sessions.metadata import ( + ensure_session_metadata_exists, + set_session_unread, + get_session_metadata, + ) + await ensure_session_metadata_exists("s1", "u1") + # Wrong owner β†’ GSI ownership check returns None β†’ no-op. + await set_session_unread("s1", "other-user", True) + assert (await get_session_metadata("s1", "u1")).unread is False + + class TestEnsureSessionMetadataExists: @pytest.mark.asyncio async def test_repeated_calls_do_not_create_duplicates(self, sessions_metadata_table): diff --git a/backend/tests/shared/test_sync_policies.py b/backend/tests/shared/test_sync_policies.py new file mode 100644 index 00000000..fbd51f38 --- /dev/null +++ b/backend/tests/shared/test_sync_policies.py @@ -0,0 +1,401 @@ +"""Sync policy repository tests (moto DynamoDB). + +Covers the runaway-prevention data invariants: +- sparse DueSyncIndex (paused policies are physically absent) +- conditional re-arm (double-fired dispatcher tick is idempotent) +- breaker counters (failure streaks vs source-gone streaks) +- per-assistant cap and one-policy-per-source uniqueness +- delete cascades (per-source and per-assistant) +""" + +import pytest + +from apis.shared.sync_policies.models import DUE_INDEX_PK +from apis.shared.sync_policies.service import ( + DuplicateSyncPolicy, + RunNowCooldown, + SyncPolicyLimitExceeded, + change_policy_interval, + create_sync_policy, + delete_reauth_marker, + delete_sync_policies_for_assistant, + delete_sync_policies_for_source, + get_sync_policy, + list_due_policies, + list_sync_policies, + put_reauth_marker, + rearm_policy, + record_sync_result, + resume_inactive_policies, + resume_reauth_policies, + set_policy_state, + trigger_run_now, +) + +pytestmark = pytest.mark.asyncio + +ASSISTANT_ID = "ast-abc123def456" +USER_ID = "user-1" + +FUTURE = "2999-01-01T00:00:00+00:00Z" +PAST = "2000-01-01T00:00:00+00:00Z" + + +async def _make_policy(source_ref="doc-1", source_type="drive_file", interval="daily"): + return await create_sync_policy( + assistant_id=ASSISTANT_ID, + source_type=source_type, + source_ref=source_ref, + interval=interval, + created_by_user_id=USER_ID, + ) + + +class TestCreateAndGet: + async def test_create_get_roundtrip(self, assistants_table): + policy = await _make_policy() + + assert policy.policy_id.startswith("syn-") + assert policy.state == "active" + assert policy.next_sync_at is not None + assert policy.consecutive_failures == 0 + + fetched = await get_sync_policy(ASSISTANT_ID, policy.policy_id) + assert fetched is not None + assert fetched.source_ref == "doc-1" + assert fetched.source_type == "drive_file" + assert fetched.interval == "daily" + assert fetched.created_by_user_id == USER_ID + + async def test_active_policy_has_due_index_keys(self, assistants_table): + policy = await _make_policy() + + item = assistants_table.get_item( + Key={"PK": f"AST#{ASSISTANT_ID}", "SK": f"SYNCPOL#{policy.policy_id}"} + )["Item"] + assert item["GSI4_PK"] == DUE_INDEX_PK + assert item["GSI4_SK"] == f"{policy.next_sync_at}#{policy.policy_id}" + + async def test_get_missing_returns_none(self, assistants_table): + assert await get_sync_policy(ASSISTANT_ID, "syn-missing") is None + + async def test_duplicate_source_rejected(self, assistants_table): + await _make_policy(source_ref="doc-1") + with pytest.raises(DuplicateSyncPolicy): + await _make_policy(source_ref="doc-1") + + async def test_per_assistant_cap_enforced(self, assistants_table, monkeypatch): + monkeypatch.setenv("KB_SYNC_MAX_POLICIES_PER_ASSISTANT", "2") + await _make_policy(source_ref="doc-1") + await _make_policy(source_ref="doc-2") + with pytest.raises(SyncPolicyLimitExceeded): + await _make_policy(source_ref="doc-3") + + +class TestDueSweep: + async def test_due_query_returns_only_overdue(self, assistants_table): + overdue = await _make_policy(source_ref="doc-1") + pending = await _make_policy(source_ref="doc-2") + + # Force one policy overdue and keep one in the future + assert await set_policy_state(ASSISTANT_ID, overdue.policy_id, "active", next_sync_at=PAST) + assert await set_policy_state(ASSISTANT_ID, pending.policy_id, "active", next_sync_at=FUTURE) + + due = await list_due_policies() + assert [p.policy_id for p in due] == [overdue.policy_id] + + async def test_due_query_respects_limit(self, assistants_table): + for i in range(3): + policy = await _make_policy(source_ref=f"doc-{i}") + await set_policy_state(ASSISTANT_ID, policy.policy_id, "active", next_sync_at=PAST) + + due = await list_due_policies(limit=2) + assert len(due) == 2 + + async def test_paused_policy_leaves_due_index(self, assistants_table): + policy = await _make_policy() + await set_policy_state(ASSISTANT_ID, policy.policy_id, "active", next_sync_at=PAST) + + assert await set_policy_state( + ASSISTANT_ID, policy.policy_id, "paused_error", state_reason="source no longer accessible" + ) + + # Sparse index: paused policy is physically absent, not filtered + assert await list_due_policies() == [] + item = assistants_table.get_item( + Key={"PK": f"AST#{ASSISTANT_ID}", "SK": f"SYNCPOL#{policy.policy_id}"} + )["Item"] + assert "GSI4_PK" not in item + assert "GSI4_SK" not in item + assert item["stateReason"] == "source no longer accessible" + + async def test_reactivation_rejoins_due_index_and_clears_reason(self, assistants_table): + policy = await _make_policy() + await set_policy_state(ASSISTANT_ID, policy.policy_id, "paused_reauth", state_reason="reconnect Google Drive") + assert await set_policy_state(ASSISTANT_ID, policy.policy_id, "active", next_sync_at=PAST) + + due = await list_due_policies() + assert [p.policy_id for p in due] == [policy.policy_id] + assert due[0].state_reason is None + + async def test_activate_without_next_sync_at_raises(self, assistants_table): + policy = await _make_policy() + with pytest.raises(ValueError): + await set_policy_state(ASSISTANT_ID, policy.policy_id, "active") + + async def test_set_state_on_missing_policy_returns_false(self, assistants_table): + assert not await set_policy_state(ASSISTANT_ID, "syn-missing", "paused_user") + + +class TestRearm: + async def test_rearm_wins_with_expected_value(self, assistants_table): + policy = await _make_policy() + + assert await rearm_policy(ASSISTANT_ID, policy.policy_id, policy.next_sync_at, FUTURE) + + updated = await get_sync_policy(ASSISTANT_ID, policy.policy_id) + assert updated.next_sync_at == FUTURE + item = assistants_table.get_item( + Key={"PK": f"AST#{ASSISTANT_ID}", "SK": f"SYNCPOL#{policy.policy_id}"} + )["Item"] + assert item["GSI4_SK"] == f"{FUTURE}#{policy.policy_id}" + + async def test_rearm_loses_on_stale_expected_value(self, assistants_table): + policy = await _make_policy() + assert await rearm_policy(ASSISTANT_ID, policy.policy_id, policy.next_sync_at, FUTURE) + + # Second dispatcher with the stale pre-rearm value must lose + assert not await rearm_policy(ASSISTANT_ID, policy.policy_id, policy.next_sync_at, PAST) + updated = await get_sync_policy(ASSISTANT_ID, policy.policy_id) + assert updated.next_sync_at == FUTURE + + +class TestRunResults: + async def test_failure_increments_streak(self, assistants_table): + policy = await _make_policy() + + await record_sync_result(ASSISTANT_ID, policy.policy_id, "failed") + await record_sync_result(ASSISTANT_ID, policy.policy_id, "failed") + + updated = await get_sync_policy(ASSISTANT_ID, policy.policy_id) + assert updated.consecutive_failures == 2 + assert updated.consecutive_not_found == 0 + assert updated.last_result == "failed" + + async def test_not_found_failure_increments_both_streaks(self, assistants_table): + policy = await _make_policy() + + await record_sync_result(ASSISTANT_ID, policy.policy_id, "failed", not_found=True) + + updated = await get_sync_policy(ASSISTANT_ID, policy.policy_id) + assert updated.consecutive_failures == 1 + assert updated.consecutive_not_found == 1 + + async def test_success_resets_streaks(self, assistants_table): + policy = await _make_policy() + await record_sync_result(ASSISTANT_ID, policy.policy_id, "failed", not_found=True) + + await record_sync_result(ASSISTANT_ID, policy.policy_id, "unchanged") + + updated = await get_sync_policy(ASSISTANT_ID, policy.policy_id) + assert updated.consecutive_failures == 0 + assert updated.consecutive_not_found == 0 + assert updated.last_result == "unchanged" + assert updated.last_sync_at is not None + assert updated.sync_run_started_at is None + + +class TestDeleteCascades: + async def test_delete_for_source_removes_only_matching(self, assistants_table): + keep = await _make_policy(source_ref="doc-keep") + await _make_policy(source_ref="doc-gone") + + deleted = await delete_sync_policies_for_source(ASSISTANT_ID, "doc-gone") + + assert deleted == 1 + remaining = await list_sync_policies(ASSISTANT_ID) + assert [p.policy_id for p in remaining] == [keep.policy_id] + + async def test_delete_for_assistant_removes_all(self, assistants_table): + await _make_policy(source_ref="doc-1") + await _make_policy(source_ref="crawl-1", source_type="web_crawl") + + deleted = await delete_sync_policies_for_assistant(ASSISTANT_ID) + + assert deleted == 2 + assert await list_sync_policies(ASSISTANT_ID) == [] + assert await list_due_policies() == [] + + +class TestIntervalChange: + async def test_active_policy_rearms_and_moves_due_key(self, assistants_table): + policy = await _make_policy(interval="daily") + old_next = policy.next_sync_at + + updated = await change_policy_interval(ASSISTANT_ID, policy.policy_id, "monthly") + + assert updated.interval == "monthly" + assert updated.next_sync_at > old_next # one *new* interval out + item = assistants_table.get_item( + Key={"PK": f"AST#{ASSISTANT_ID}", "SK": f"SYNCPOL#{policy.policy_id}"} + )["Item"] + assert item["GSI4_SK"] == f"{updated.next_sync_at}#{policy.policy_id}" + + async def test_paused_policy_remembers_interval_without_due_keys(self, assistants_table): + policy = await _make_policy(interval="daily") + await set_policy_state(ASSISTANT_ID, policy.policy_id, "paused_user") + + updated = await change_policy_interval(ASSISTANT_ID, policy.policy_id, "weekly") + + assert updated.interval == "weekly" + assert updated.state == "paused_user" + item = assistants_table.get_item( + Key={"PK": f"AST#{ASSISTANT_ID}", "SK": f"SYNCPOL#{policy.policy_id}"} + )["Item"] + assert "GSI4_SK" not in item # still invisible to the dispatcher + + async def test_missing_policy_returns_none(self, assistants_table): + assert await change_policy_interval(ASSISTANT_ID, "syn-missing", "weekly") is None + + +class TestRunNow: + async def test_makes_policy_due_immediately(self, assistants_table): + policy = await _make_policy(interval="daily") + assert await list_due_policies() == [] # created one interval out + + updated = await trigger_run_now(ASSISTANT_ID, policy.policy_id) + + assert updated.last_manual_run_at is not None + assert updated.next_sync_at == updated.last_manual_run_at + due = await list_due_policies() + assert [p.policy_id for p in due] == [policy.policy_id] + + async def test_second_trigger_within_cooldown_rejected(self, assistants_table): + policy = await _make_policy() + await trigger_run_now(ASSISTANT_ID, policy.policy_id) + + with pytest.raises(RunNowCooldown): + await trigger_run_now(ASSISTANT_ID, policy.policy_id) + + async def test_trigger_allowed_after_cooldown_expires(self, assistants_table): + policy = await _make_policy() + # Simulate an old manual run: stamp beyond the cooldown window. + assistants_table.update_item( + Key={"PK": f"AST#{ASSISTANT_ID}", "SK": f"SYNCPOL#{policy.policy_id}"}, + UpdateExpression="SET lastManualRunAt = :old", + ExpressionAttributeValues={":old": PAST}, + ) + + updated = await trigger_run_now(ASSISTANT_ID, policy.policy_id) + + assert updated.last_manual_run_at > PAST + + async def test_paused_policy_rejected(self, assistants_table): + policy = await _make_policy() + await set_policy_state(ASSISTANT_ID, policy.policy_id, "paused_user") + + with pytest.raises(ValueError): + await trigger_run_now(ASSISTANT_ID, policy.policy_id) + + async def test_missing_policy_raises_key_error(self, assistants_table): + with pytest.raises(KeyError): + await trigger_run_now(ASSISTANT_ID, "syn-missing") + + +class TestReauthMarkers: + async def test_fresh_consent_resumes_matching_provider_due_now(self, assistants_table): + policy = await _make_policy() + await set_policy_state(ASSISTANT_ID, policy.policy_id, "paused_reauth", state_reason="Reconnect") + await put_reauth_marker(USER_ID, ASSISTANT_ID, policy.policy_id, "google-workspace") + + resumed = await resume_reauth_policies(USER_ID, "google-workspace") + + assert resumed == 1 + updated = await get_sync_policy(ASSISTANT_ID, policy.policy_id) + assert updated.state == "active" + assert [p.policy_id for p in await list_due_policies()] == [policy.policy_id] + # Marker consumed + marker = assistants_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SYNCREAUTH#{policy.policy_id}"} + ).get("Item") + assert marker is None + + async def test_other_providers_markers_left_alone(self, assistants_table): + policy = await _make_policy() + await set_policy_state(ASSISTANT_ID, policy.policy_id, "paused_reauth") + await put_reauth_marker(USER_ID, ASSISTANT_ID, policy.policy_id, "microsoft-365") + + resumed = await resume_reauth_policies(USER_ID, "google-workspace") + + assert resumed == 0 + updated = await get_sync_policy(ASSISTANT_ID, policy.policy_id) + assert updated.state == "paused_reauth" + marker = assistants_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SYNCREAUTH#{policy.policy_id}"} + ).get("Item") + assert marker is not None # still waiting on its own provider + + async def test_stale_marker_cleaned_without_resuming(self, assistants_table): + # Marker outlived its policy's pause (user resumed another way, or + # the policy was deleted): resume must re-verify, not trust it. + active = await _make_policy(source_ref="doc-active") + await put_reauth_marker(USER_ID, ASSISTANT_ID, active.policy_id, "google-workspace") + await put_reauth_marker(USER_ID, ASSISTANT_ID, "syn-deleted", "google-workspace") + + resumed = await resume_reauth_policies(USER_ID, "google-workspace") + + assert resumed == 0 + for policy_id in (active.policy_id, "syn-deleted"): + marker = assistants_table.get_item( + Key={"PK": f"USER#{USER_ID}", "SK": f"SYNCREAUTH#{policy_id}"} + ).get("Item") + assert marker is None + + async def test_delete_marker_is_idempotent(self, assistants_table): + await delete_reauth_marker(USER_ID, "syn-never-existed") # no raise + + +class TestResumeInactive: + async def test_resumes_only_inactivity_pauses(self, assistants_table): + dormant = await _make_policy(source_ref="doc-dormant") + user_paused = await _make_policy(source_ref="doc-user") + await set_policy_state(ASSISTANT_ID, dormant.policy_id, "paused_inactive") + await set_policy_state(ASSISTANT_ID, user_paused.policy_id, "paused_user") + + resumed = await resume_inactive_policies(ASSISTANT_ID) + + assert resumed == 1 + assert (await get_sync_policy(ASSISTANT_ID, dormant.policy_id)).state == "active" + assert (await get_sync_policy(ASSISTANT_ID, user_paused.policy_id)).state == "paused_user" + assert [p.policy_id for p in await list_due_policies()] == [dormant.policy_id] + + async def test_no_inactive_policies_is_noop(self, assistants_table): + await _make_policy() + assert await resume_inactive_policies(ASSISTANT_ID) == 0 + + +class TestTimestampFormat: + """The stored timestamps are surfaced verbatim to the SPA, where + ``new Date(iso)`` parses them. A ``…+00:00Z`` string (offset AND Z) is + invalid ISO 8601 β†’ Invalid Date β†’ a blank sync-status line. Guard the + generators against that regression.""" + + async def test_generated_timestamps_are_valid_iso(self, assistants_table): + from datetime import datetime + + from apis.shared.sync_policies.service import ( + _get_current_timestamp, + compute_next_sync_at, + ) + + policy = await _make_policy() + for ts in ( + _get_current_timestamp(), + compute_next_sync_at("weekly"), + policy.next_sync_at, + ): + assert ts.endswith("Z"), ts + assert "+00:00" not in ts, ts + # Round-trips to an aware datetime the way JS `Date` accepts. + parsed = datetime.fromisoformat(ts.replace("Z", "+00:00")) + assert parsed.tzinfo is not None diff --git a/backend/tests/shared/test_users.py b/backend/tests/shared/test_users.py index 18d46c5f..6fe3ff8a 100644 --- a/backend/tests/shared/test_users.py +++ b/backend/tests/shared/test_users.py @@ -197,3 +197,67 @@ async def test_sync_user_from_jwt_convenience(self, sync_service): user.picture = None profile, is_new = await sync_service.sync_user_from_jwt(user) assert is_new is True + + @pytest.mark.asyncio + async def test_sync_timestamps_are_strict_iso8601(self, sync_service): + """Regression: timestamps must carry a single trailing ``Z`` and no + ``+00:00`` offset. The old ``isoformat() + "Z"`` produced ``…+00:00Z``, + which is invalid ISO 8601 and parses to ``Invalid Date`` in Safari, + blanking the admin user list / detail dates.""" + from datetime import datetime + + profile, _ = await sync_service.sync_from_jwt( + {"sub": "u1", "email": "alice@example.com", "name": "Alice"} + ) + for ts in (profile.created_at, profile.last_login_at): + assert ts.endswith("Z") + assert "+00:00" not in ts + # Parses cleanly as a UTC-aware datetime (JS ``new Date`` parity). + assert datetime.fromisoformat(ts.replace("Z", "+00:00")).tzinfo is not None + + +# =================================================================== +# Legacy timestamp healing on read +# =================================================================== + +class TestTimestampHealingOnRead: + """Rows persisted before the sync fix carry ``…+00:00Z``; the read path + normalizes them to a single trailing ``Z`` so already-persisted + ``created_at`` (never rewritten) still renders in strict engines.""" + + def test_item_to_profile_heals_legacy_suffix(self, user_repository): + item = { + "userId": "u1", + "email": "alice@example.com", + "name": "Alice", + "emailDomain": "example.com", + "createdAt": "2026-01-01T00:00:00+00:00Z", + "lastLoginAt": "2026-02-02T00:00:00+00:00Z", + "status": "active", + } + profile = user_repository._item_to_profile(item) + assert profile.created_at == "2026-01-01T00:00:00Z" + assert profile.last_login_at == "2026-02-02T00:00:00Z" + + def test_item_to_profile_leaves_valid_suffix_untouched(self, user_repository): + item = { + "userId": "u1", + "email": "alice@example.com", + "emailDomain": "example.com", + "createdAt": "2026-01-01T00:00:00Z", + "lastLoginAt": "2026-02-02T00:00:00Z", + "status": "active", + } + profile = user_repository._item_to_profile(item) + assert profile.created_at == "2026-01-01T00:00:00Z" + assert profile.last_login_at == "2026-02-02T00:00:00Z" + + def test_item_to_list_item_heals_legacy_suffix(self, user_repository): + item = { + "userId": "u1", + "email": "alice@example.com", + "status": "active", + "lastLoginAt": "2026-02-02T00:00:00+00:00Z", + } + list_item = user_repository._item_to_list_item(item) + assert list_item.last_login_at == "2026-02-02T00:00:00Z" diff --git a/backend/tests/supply_chain/test_docker_scanning.py b/backend/tests/supply_chain/test_docker_scanning.py index 7ae441ab..b57910a7 100644 --- a/backend/tests/supply_chain/test_docker_scanning.py +++ b/backend/tests/supply_chain/test_docker_scanning.py @@ -16,6 +16,7 @@ "Dockerfile.app-api", "Dockerfile.inference-api", "Dockerfile.rag-ingestion", + "Dockerfile.kb-sync", ] diff --git a/backend/tests/supply_chain/test_dockerfile_pinning.py b/backend/tests/supply_chain/test_dockerfile_pinning.py index eef11e7b..47034caf 100644 --- a/backend/tests/supply_chain/test_dockerfile_pinning.py +++ b/backend/tests/supply_chain/test_dockerfile_pinning.py @@ -15,6 +15,8 @@ BACKEND_DIR / "Dockerfile.app-api", BACKEND_DIR / "Dockerfile.inference-api", BACKEND_DIR / "Dockerfile.rag-ingestion", + BACKEND_DIR / "Dockerfile.kb-sync", + BACKEND_DIR / "Dockerfile.scheduled-runs", ] # apt-get version pin: package=version (e.g., gcc=4:14.2.0-1) diff --git a/backend/uv.lock b/backend/uv.lock index c7ba86b0..6d131517 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.0.4" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/docs/kaizen/research/2026-07-03.md b/docs/kaizen/research/2026-07-03.md new file mode 100644 index 00000000..1d6a12d2 --- /dev/null +++ b/docs/kaizen/research/2026-07-03.md @@ -0,0 +1,218 @@ +# Kaizen Research β€” Friday, July 3, 2026 +> Scan window: June 19 – July 3, 2026 (14 days β€” the June 26 run was skipped, so this is a double window) +> Web budget: ~64/50 used (overage β€” double window + 14 subagents; see Web Budget block). + +## TL;DR + +**Two dependency bumps are now reliability/subtraction wins, not routine currency.** `bedrock-agentcore` **1.17.0 (Jul 2) fixes the SSE streaming-bridge deadlock (issue #482)** β€” an invisible, cross-session container hang in exactly the SSE-over-Runtime pattern our inference-api runs; we sit on **1.9.1** and are exposed today, and the fix upstream **retires** the queued [2026-05-22] "defensive guard against #482" work item. In parallel, **Strands 1.45** (we're 5 minors back on 1.40) ships **optional hook ordering** β€” a direct lever on our known "approval hook can't see through the tool-fold" hole β€” plus `cache_tools_ttl`, `context_manager="auto"`, and per-invocation `Limits`. The **#1 idea is the `bedrock-agentcore` β†’ 1.17.0 bump.** Most pressing internal signal is unchanged and now escalating: **Nightly Build & Test has failed every day through June 30, including after the #518 path-repoint in 1.0.2 landed** β€” the fix was incomplete or the cause moved. + +## External Scan + +### What's moving this week + +The shape of the fortnight is **Bedrock model churn resolving in our favor and two upstream releases quietly closing our custom-code debt.** On models: **Claude Fable 5 was reinstated on Bedrock July 1** (reversing the mid-June export-control revocation that forced last week's withdrawal), **Claude Sonnet 5 went GA on Bedrock June 30** at promotional $2/$10 pricing through Aug 31, and Opus 4.8 reached GovCloud β€” the model-settings surface that looked frozen two weeks ago now has three live moves. On the SDK side, both AWS (`bedrock-agentcore` 1.17.0) and Strands (1.45) shipped fixes/primitives that map onto open work: the SSE deadlock guard, the approval-hook ordering hole, cache-point plumbing, and compaction. The surprise is defensive: the AgentCore SDK deadlock (#482) is a *silent, process-wide* hang that keeps `/ping` green β€” the worst failure class for a runtime β€” and we're eight minors behind the fix. Meanwhile the MCP spec crossed a milestone that touches us directly: **MCP Apps (SEP-1865) graduates from proposal to an official extension in the 2026-07-28 RC.** UI/UX convergence continued around one pattern we have a gap in β€” **tool-approval as an explicit, first-class state** β€” showing up simultaneously in assistant-ui and the Vercel AI SDK. + +### Notable items by source + +> **Annotation conventions:** +> - `*relevance*:` β€” impact-on-existing-code lens. +> - `*unlocks*:` β€” capability-unlock lens (new primitive / new product surface). + +#### AWS Bedrock / AgentCore +- **Claude Sonnet 5 GA on Bedrock (Jun 30) + Opus 4.8 in GovCloud** β€” "Most capable Sonnet," positioned for coding/agents at Sonnet pricing. β€” https://aws.amazon.com/about-aws/whats-new/2026/06/claude-sonnet-5-now-available-on-aws β€” *relevance*: inference-api model selection β€” candidate model-id. Token-counting gotcha: Bedrock `CountTokens` rejects `us.*` inference-profile ids, so `CountTokensBedrockModel` must de-prefix any new Sonnet 5 profile id. β€” *unlocks*: cheaper capable tier than Opus 4.8 (see Pricing). +- **AgentCore Policy now enforces Bedrock Guardrails at the gateway layer** β€” Guardrails evaluates gateway-target inputs and authorized-action outputs (prompt injection, harmful content, sensitive-data exposure) outside the agent's code. β€” https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-guardrails.html β€” *relevance*: all our Gateway MCP targets route through one gateway, so one policy blankets every tool β€” a gateway-level alternative/complement to in-agent controls and to queued issue #480. β€” *unlocks*: model-independent FERPA/duty-of-care enforcement the agent can't reason around. +- **AgentCore Identity can reference existing Secrets Manager ARNs in Credential Providers** β€” Point at a pre-existing secret (own CMK, rotation, tagging) instead of AgentCore minting/owning it. β€” https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html β€” *relevance*: `apis/shared/oauth/agentcore_identity.py` + Identity construct β€” brings external MCP OAuth client secrets under our governance/rotation. +- **AgentCore Runtime default quota increases (auto-applied)** β€” Concurrent sessions 1,000β†’2,500 (us-west-2), `InvokeAgentRuntime` 25β†’200 TPS, new-session 100β†’400 TPM. β€” https://aws.amazon.com/about-aws/whats-new/2026/07/amazon-bedrock-agentcore-increases-default-runtime-quota-limits/ β€” *relevance*: our dev-ai runtime inherits the higher ceilings with no CDK change; relaxes a scaling constraint on the `/invocations` path. +- **Gateway inbound source validation (`allowedWorkloadConfiguration`)** β€” Lock a Runtime to accept invocations only when they originate from your gateway. β€” https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-http-runtime.html β€” *relevance*: our JWT-fronted runtime could adopt this hardening; net-new security primitive. β€” Also GA'd but lower-relevance: AgentCore Harness GA, **Managed Knowledge Base GA** (managed RAG through the gateway β€” a potential alternative to our `rag-ingestion` pipeline), Web Search tool GA ($7/1k), WAF for Gateway. + +#### Strands Agents +**Latest `strands-agents==1.45.0` (Jun 25); we pin 1.40.0 (May 14) β€” 5 minors behind.** Note: the SDK moved into the dual-language monorepo `strands-agents/harness-sdk` between 1.42 and 1.43 (PyPI name unchanged; watch for `harness-sdk` links next scan). +- **1.41 β€” Bedrock `cache_tools_ttl` / cache-point TTL** (#2232) β€” *relevance*: we reason about cache points in `to_bedrock_config`; native TTL may let us drop custom cache-point plumbing (subtraction audit). +- **1.43 β€” `context_manager="auto"` auto-compaction facade + message pinning + reasoning-block preservation + count_tokens folds in JSON blocks** β€” *relevance*: overlaps our `TurnBasedSessionManager` compaction + `compaction` SSE; count_tokens change may shift `projected_input_tokens` math. **Subtraction only if** the native facade can feed our bespoke SSE/checkpoint contract (see decisions.md 2026-05-18 scope note). +- **1.43 β€” optional hook ordering** (#2559) β€” *relevance*: directly useful for the known BeforeToolCall consent/approval ordering hole behind the tool-fold. +- **1.42 β€” `Limits` (per-invocation token/cost caps)** (#2360) β€” *unlocks*: first-class budget guard; nothing equivalent in our loop. +- **1.45 β€” `BedrockModel._format_request` / `_convert_non_streaming_to_streaming` now overridable** (#2315) β€” *relevance*: cleaner extension point than monkey-patching for the fine-grained-tool-streaming beta header. **Only breaking change 1.41β†’1.45 is Mistral requiring `mistralai>=2.0` (N/A to us).** + +#### Reference repo (aws-samples/sample-strands-agent-with-agentcore) +- **Default Sonnet β†’ Sonnet 5 + `NO_TEMPERATURE_MODELS` guard (#157, Jul 1)** β€” Sonnet 5 **rejects `temperature` on ConverseStream** ("temperature is deprecated for this model"); they added an allowlist-style suppression in `model_factory.py` + a frontend `noTemperature` flag. β€” https://github.com/aws-samples/sample-strands-agent-with-agentcore/commit/35bc3a9 β€” *applicability*: **worth porting.** We inject params in `to_bedrock_config`; the moment we add Sonnet 5 we hit the same rejection. Confirm we have a per-model temperature-suppression guard rather than unconditionally sending `temperature`. +- Otherwise quiet: Dependabot cluster + React 18β†’19 / Tailwind v4 frontend modernization (N/A β€” we're Angular). **No Identity/Memory/Gateway/A2A-server wiring changes this window.** + +#### MCP ecosystem +- **MCP Apps (SEP-1865) is now an OFFICIAL extension in the 2026-07-28 RC** β€” Server-rendered interactive HTML in sandboxed iframes; UI templates declared upfront for prefetch/security review; UI actions route through the same JSON-RPC audit path as tool calls. β€” https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ β€” *relevance*: our `ui_resource`/`ui_tool_input_partial` SSE events are the host-side realization. Reconcile: (1) the RC "declared upfront for prefetch" model vs. our early-mount header-shell; (2) conformance-check `AGENTCORE_MCP_APPS_HOST_ENABLED` once v2 SDKs confirm App support (the June 29 beta-SDK post did **not** call out MCP Apps coverage β€” unconfirmed). +- **Sessionless transport lands as SEP-2567** β€” Removes `Mcp-Session-Id`; adds routable `Mcp-Method`/`Mcp-Name` headers for gateway/LB routing without body parsing. β€” same RC URL β€” *relevance*: high for Gateway MCP targets (routing win); changes sticky-session assumptions in our MCP client/target path. +- **Elicitation redesigned as Multi Round-Trip Requests (SEP-2322)** β€” Server-initiated prompts no longer need a held-open SSE stream; `InputRequiredResult` + encoded state + retry with `inputResponses`. β€” same RC URL β€” *relevance*: spec-blessed pattern for our `oauth_required` / interrupt-resume flows; may simplify resume-turn handling but changes the contract. +- **Enterprise-Managed Authorization (EMA) extension went stable (Jun 18)** β€” One org-managed login covers many connected servers. β€” https://blog.modelcontextprotocol.io/posts/enterprise-managed-auth/ β€” *relevance*: intersects our two-axis Gateway auth / token-vault work; could cut per-provider `oauth_required` prompting for the Boise State institutional deployment. + +#### FastMCP +- **No release in window. Latest 3.4.2 (Jun 6); we don't pin it.** Carry-forward for next server pin: **3.4.0's `ToolResult(is_error=…)` β†’ `CallToolResult.isError`** (additive) and the **`joserfc` auth-stack migration** are the two items to smoke-test when a Lambda server re-resolves FastMCP. No transport/SEP-2567 or Lambda-adapter change. β€” https://github.com/jlowin/fastmcp/releases + +#### Agentic UI/UX patterns +- **Tool-approval as a first-class part state (assistant-ui `eve@0.0.2`)** β€” Tool parts carry a required `approval` field; denied tools surface a dedicated `output-denied` part, not a synthetic error. β€” https://github.com/Yonom/assistant-ui/releases β€” *fit*: pattern-only (Angular signal store) β€” *where it'd land*: tool-approval hook + tool-call card + `tool_use`/`tool_result` SSE pair. **Directly addresses our "approval hook still has the hole" gap.** +- **Human-in-the-loop tool gate (Vercel AI SDK)** β€” Emits a paused `approval-requested` part showing the tool's input args; client calls `addToolApprovalResponse(id, bool)` and auto-resubmits once *all* approvals resolve. β€” https://ai-sdk.dev/cookbook/next/human-in-the-loop β€” *fit*: pattern-only β€” *where it'd land*: paused state on the tool-call card + resume path (mirrors our `beginContinuationStreaming`). The **auto-resubmit-when-all-approvals-resolved** condition is the piece worth stealing for multi-tool turns. +- **Agent transparency is the strongest driver of user preference (NN/g State of UX 2026)** β€” Follow-the-reasoning, task status, **data sources used** outweigh other factors; trust rests on transparency + control + graceful failure. β€” https://www.nngroup.com/articles/state-of-ux-2026/ β€” *fit*: design directive β€” *where it'd land*: validates our tool-call cards + context badge + compaction events; the untapped one is **"data sources used" provenance** on every `tool_result` card β€” we already carry `serverName`/`icon` on `ui_resource`, so surfacing which MCP server/RAG source fed a result is low-lift, high-trust. +- **assistant-ui `PartGroups` (hierarchical adjacent grouping)** β€” Cluster message parts into a grouped unit. β€” https://github.com/Yonom/assistant-ui/releases β€” *fit*: pattern-only β€” *where it'd land*: message-list; a way to group `tool_use` + `ui_resource` + `tool_result` (and A2A sub-calls) under one parent turn for multi-agent attribution. + +#### Frontier model announcements +- **Claude Fable 5 REINSTATED on Bedrock (Jul 1)** β€” Mid-June export-control suspension lifted after a new safety classifier; `us.anthropic.claude-fable-5`, 1M context, adaptive thinking, auto-fallback to Opus 4.8. β€” https://aws.amazon.com/blogs/aws/anthropic-claude-fable-5-on-aws-mythos-class-capabilities-with-built-in-safeguards-now-available/ β€” *relevance*: **reverses last week's withdrawal** β€” the [2026-06-12] "Add Fable 5 to model settings" item is live again (US inference profile only; Global profile unstable). β€” *unlocks*: a harness tier above Opus 4.8. +- **Sonnet 5 on Bedrock (Jun 30)** β€” see AWS section; strong cost/latency candidate for the inference-api selector and possibly a cheaper default tier than Opus 4.8. β€” https://www.aboutamazon.com/news/aws/anthropic-claude-4-opus-sonnet-amazon-bedrock +- OpenAI AgentKit / Google Managed Agents shipped but are off-Bedrock β€” reference-only, no change to our model options. (OpenAI is *sunsetting* Agent Builder + Evals after Nov 30, 2026 β€” caution against building on it.) + +#### Agent harness patterns +- **Subagent API errors no longer reported to parent as success (Claude Code 2.1.198–199)** β€” A subagent's usage-limit/API error was surfacing as a *successful* empty result. β€” https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md β€” *relevance*: audit that our A2A / delegated-tool calls surface remote failure as `stream_error`, not a benign empty `tool_result` the loop continues on. +- **Subagents + compaction inherit the session's thinking config (2.1.198)** β€” *relevance*: thread thinking-budget / model-tier / beta flags (our `fine-grained-tool-streaming`) through the compaction summarizer and any delegate β€” a summarizer at a weaker config than the turn it compresses is a quiet quality regression. +- **Mid-stream partials retained on 529/ECONNRESET + backoff retry (2.1.198–199)** β€” *relevance*: resilience pattern for our SSE loop / 600s-timeout class β€” preserve the partial assistant message + completed `tool_result`s when Bedrock throws mid-`converse_stream`. +- **(Ideas-only) Strands "context management that cuts costs in half" + Shell sandbox (Jun 18)** β€” https://strandsagents.com/blog/ β€” diff against our native-CountTokens compaction before building more custom context tooling (details unverified beyond snippet). + +#### Pricing / quota +- **Sonnet 5 promotional pricing $2/$10 per 1M in/out through Aug 31, 2026** (reverts to $3/$15 after). β€” https://aws.amazon.com/bedrock/pricing/ β€” the one active lever: Sonnet 5 is temporarily ~33% under steady-state, shifting default-tier economics vs. Opus 4.8. No Opus 4.8 price change, no prompt-caching or quota price change this window. + +#### Community + GitHub issues +- **AgentCore SDK #482 SSE deadlock β€” fixed in 1.17.0 (PR #563)** β€” Blocking `put()` on a bounded queue froze the *shared* worker event loop when an SSE consumer stopped draining (client disconnect / read timeout), starving all sessions process-wide while `/ping` stayed green. β€” https://github.com/aws/bedrock-agentcore-sdk-python/pull/563 β€” **flag first; we're on 1.9.1, exposed.** +- **`AgentCoreMemorySessionManager` silently drops history on eventually-consistent `ListEvents` (OPEN #564)** β€” https://github.com/aws/bedrock-agentcore-sdk-python/issues/564 β€” latent silent-data-loss if we lean on Memory restore; per memory, our continuity rides the agent cache (Memory write-only) so we're likely shielded β€” worth confirming our restore path doesn't depend on filtered `ListEvents`. +- **HN: a builder shipped a Strands agent to AgentCore Runtime end-to-end** β€” https://news.ycombinator.com/item?id=46954597 β€” real-world validation of our exact deploy path. +- **Anthropic eng: "context resets over compaction" for long-running agents** β€” hard context resets (initializer agent + artifacts for the next session) beat compaction alone for long tasks β€” a counterpoint to our compaction-first strategy. https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents +- **Cost: multi-agent spend is linear** β€” parallel-agent fan-out burns quota NΓ— (~$13/dev/active-day baseline reported); relevant to how we budget Agent-tool / subagent parallelism. https://www.cloudzero.com/blog/claude-code-agents/ + +#### Cookbook / courses +- **Agentic-search benchmark reproduction via Messages API (PR #749, Jun 30)** and a **managed-agents roadtrip cookbook** β€” https://github.com/anthropics/anthropic-cookbook β€” agent-loop references; the benchmark repro maps onto our Strands/Converse iterative tool-calling. No new caching/token-counting/MCP notebooks. + +#### opencode (harness reference) +- v1.17.4–13 (Jun 12–Jul 1): **reconnect MCP servers post-OAuth even if previously disabled**, **prioritize MCP content over structured output**, **fold MCP `initialize` instructions into session context** (parallels our OAuth-resume + ui_resource plumbing); **tiered thinking/reasoning variants** (cheap-vs-capable routing + reasoning-budget dial); **session snapshots + revert-to-earlier-message** (a user-driven context-rollback UX distinct from our automatic compaction). β€” https://github.com/anomalyco/opencode/releases β€” incremental; the reasoning-budget routing and session-revert are the two worth noting against our roadmap. + +#### LibreChat +- **No confirmed in-window signal** (releases page served stale 2025-dated notes; latest tag v0.8.7). β€” https://github.com/danny-avila/LibreChat/releases + +#### Seasonal +- Out of window β€” none scanned this week. + +### Patterns worth considering + +- **Tool-approval as an explicit lifecycle state (approve/deny/denied) on the tool part itself** β€” Appearing simultaneously in assistant-ui and Vercel AI SDK; both model a paused `approval-requested` state showing input args, then auto-resume once all approvals resolve. + - **Where**: assistant-ui `eve@0.0.2`, Vercel AI SDK human-in-the-loop cookbook. + - **Fit**: closes our known "approval hook can't see through the tool-fold" hole; replaces ad-hoc synthetic-error handling with explicit states on the `tool_use`/`tool_result` SSE pair. Reuses our existing `beginContinuationStreaming` resume path. + - **Verdict**: Worth trying. +- **Provenance ("data sources used") on every tool result** β€” NN/g's evidence puts source-attribution among the top trust drivers. + - **Where**: NN/g State of UX 2026. + - **Fit**: low-lift β€” we already carry `serverName`/`icon` on `ui_resource`; extend it to all `tool_result` cards. + - **Verdict**: Worth trying. + +## Internal Audit + +### Activity (last 14 days) +- **Commits on develop**: ~18 across the 1.0.1/1.0.2/1.0.3 release train; dominated by CI/CD cleanup, CodeQL/Dependabot remediation, and the assistant-tool-use re-enable (#517). +- **PRs opened**: 0 currently open against develop β€” **merged**: release + fix train (#517–#526) β€” **reverted**: 0 (the #517 assistant-tool-use change was itself a revert of the 1.0.0 KB-only restriction). +- **Issues opened (7d)**: 0 β€” **closed**: n/a. +- **CI failures (workflow β†’ count)**: **Nightly Build & Test β†’ ~7 in window** (daily through Jun 30); Platform Stack β†’ 5 (workflow_dispatch); Docs Deploy β†’ 3 (release pushes). + +### Repeated friction signals +- **Nightly Build & Test failing daily** (~7 in window; ~21 consecutive counting prior weeks) β€” evidence: runs 28019030767 (Jun 23) β†’ 28436894310 (Jun 30); the 1.0.2 fix #518 (repointed nightly to `scripts/backend/test.sh`, `scripts/frontend/install.sh|test.sh`) merged Jun 29 but **nightly still failed Jun 29 and Jun 30 after it**. + - **Hypothesis**: #518 fixed the path-not-found (`exit 127`) at the test/install steps but a *different* stage (the ephemeral deploy/teardown, per the Jun 23 `fix/nightly` auto-teardown work) is still failing β€” or the repoint was partial. + - **Fix candidate**: pull the Jun 30 nightly job logs (`gh run view 28436894310 --log-failed`), identify the failing step by name, and patch that specific stage in `nightly-deploy-pipeline.yml`. **This item is already open in the queue from 2026-06-19 β€” it stays open, not re-queued.** + +### Version-pin lag +| Dep | Pinned | Latest | Lag | Notes | +|---|---|---|---|---| +| bedrock-agentcore | 1.9.1 | 1.17.0 (Jul 2) | 8 minors | **SSE-deadlock #482 fixed in 1.17.0 β€” we're exposed today** | +| strands-agents | 1.40.0 (May 14) | 1.45.0 (Jun 25) | 5 minors / ~42d | cache_tools_ttl, context_manager="auto", Limits, hook ordering; only breaking = Mistral (N/A) | +| fastapi | 0.136.1 | 0.139.0 (Jul 1) | 3 minors | routine | +| @angular/core | 21.2.17 (Jun 10) | 22.0.5 (Jul 1) | **1 major** | Angular 22 β€” hold; major migration, not a kaizen bump | +| aws-cdk-lib | 2.260.0 (Jun 16) | 2.261.0 (Jul 2) | 1 release / ~16d | routine | +| vitest | 4.1.5 (Apr 21) | 4.1.9 (Jun 15) | 4 patches | routine | +| fastmcp (unpinned) | β€” | 3.4.2 (Jun 6) | β€” | not pinned; watch `joserfc` auth-stack + `ToolResult.is_error` on next server bump | +| boto3 | 1.43.9 | not fetched | β€” | skipped (budget) | +| constructs | 10.6.0 | not fetched | β€” | skipped (budget) | + +### Retirement candidates +- **Queued [2026-05-22] "Defensive guard against SDK #482 SSE-disconnect runtime deadlock"** β€” the fix is now upstream in `bedrock-agentcore` 1.17.0; adopting the bump **retires the need to hand-write the guard**. Convert the queue item from "write a guard" to "bump the pin." +- No dormant skills flagged this week (skill inventory unchanged; all `.claude/skills` referenced in recent PRs or actively maintained). + +### Risks introduced this week +- **We are exposed to the AgentCore SDK #482 deadlock today** β€” https://github.com/aws/bedrock-agentcore-sdk-python/pull/563 β€” a client abandoning an SSE stream against our inference-api Runtime can hang the container for *all* concurrent sessions while the health check stays green. Invisible until users on unrelated sessions report stalls. +- **Sonnet 5 `temperature` rejection** β€” https://github.com/aws-samples/sample-strands-agent-with-agentcore/commit/35bc3a9 β€” if we add Sonnet 5 to model settings without a per-model temperature-suppression guard, every Sonnet 5 turn fails on ConverseStream. + +## Ideas β€” Top 5 (ranked) + +| # | Idea | Surface | Effort | Impact | Subtracts? | Unlocks? | +|---|---|---|---|---|---|---| +| 1 | Bump `bedrock-agentcore` 1.9.1 β†’ 1.17.0 (closes SSE-deadlock #482) | backend | M | H | Retires queued [2026-05-22] hand-written deadlock guard | β€” | +| 2 | Bump Strands 1.40 β†’ 1.45 + adopt hook ordering; audit cache_tools_ttl / context_manager / Limits | backend | M–H | H | Candidate: custom cache-point plumbing + compaction simplification | Per-invocation cost caps (`Limits`) | +| 3 | Model-settings refresh: reinstate Fable 5 + add Sonnet 5 with temperature-suppression guard | cross-cutting | M | M–H | β€” (addition; justified β€” un-withdraws 06-12, adds a cheaper tier) | Fable 5 harness tier; Sonnet 5 cost/latency default | +| 4 | Tool-approval as first-class SSE/part state + tool_result source provenance | frontend + backend | M | M | Replaces ad-hoc synthetic-error approval handling | Closes the approval-hook fold hole; "data sources used" trust surface | +| 5 | Evaluate gateway-level Guardrails (AgentCore Policy) vs. in-agent #480 | infra + backend | M | M | Potential: one policy vs. per-tool control complexity | Model-independent FERPA/injection enforcement across all MCP targets | + +### 1. Bump `bedrock-agentcore` 1.9.1 β†’ 1.17.0 (closes SSE-deadlock #482) +- **Source**: https://github.com/aws/bedrock-agentcore-sdk-python/pull/563 (issue #482); internal risk (inference-api SSE-over-Runtime). +- **Surface area**: `backend/pyproject.toml`, inference-api chat router; full local pytest suite (the only correctness gate β€” pytest isn't in CI). +- **Change**: bump the pin to `1.17.0`; verify the asyncβ†’sync streaming bridge fix (`put_nowait` + disconnect `stop` event + source `aclose()`) is active on our `/invocations` streaming path; drop any planned hand-written guard. +- **Subtracts**: retires the queued [2026-05-22] "defensive guard against #482" work item β€” library-native subtraction. +- **Effort Γ— Impact**: Med Γ— High. +- **Verdict**: Worth trying (highest priority β€” active exposure). + +### 2. Bump Strands 1.40 β†’ 1.45 + adopt hook ordering; audit cache_tools_ttl / context_manager / Limits +- **Source**: Strands releases 1.41–1.45 (https://github.com/strands-agents/sdk-python/releases, now `harness-sdk` monorepo). +- **Surface area**: `backend/src/agents/main_agent/` (hooks, BedrockModel config, compaction), `to_bedrock_config`, context-attribution `CountTokensBedrockModel`. +- **Change**: spike-branch bump to 1.45.0; adopt **optional hook ordering** (#2559) to sequence the OAuth-consent + tool-approval BeforeToolCall hooks deterministically through the tool-fold; separately audit whether `cache_tools_ttl` (1.41) can replace custom cache-point plumbing and whether `context_manager="auto"` (1.43) can feed our `compaction` SSE contract (see decisions.md 2026-05-18 β€” not a bare drop-in). +- **Subtracts**: candidate removal of custom cache-point code; possible compaction simplification (gated on the SSE-contract check). +- **Unlocks**: `Limits` per-invocation token/cost caps β€” first-class budget guard we don't have. +- **Effort Γ— Impact**: Med–High Γ— High. +- **Verdict**: Worth trying (supersedes the queued 1.44 item). + +### 3. Model-settings refresh: reinstate Fable 5 + add Sonnet 5 with temperature-suppression guard +- **Source**: Fable 5 reinstatement (https://aws.amazon.com/blogs/aws/anthropic-claude-fable-5-on-aws-mythos-class-capabilities-with-built-in-safeguards-now-available/); Sonnet 5 GA + promo pricing (https://aws.amazon.com/bedrock/pricing/); ref-repo `NO_TEMPERATURE_MODELS` (commit 35bc3a9). +- **Surface area**: inference-api model config + model-settings admin surface; `to_bedrock_config` param injection; `CountTokensBedrockModel` de-prefix; frontend model picker. +- **Change**: re-add `us.anthropic.claude-fable-5` (un-withdrawing the [2026-06-12] item now that it's live, US inference profile only); add Sonnet 5; add a **per-model temperature-suppression guard** so Sonnet 5 doesn't send `temperature` on ConverseStream. +- **Subtracts**: addition only β€” justified: reverses a forced withdrawal and adds a materially cheaper capable tier ($2/$10 through Aug 31 vs. Opus 4.8). +- **Unlocks**: Fable 5 as a harness tier above Opus 4.8; Sonnet 5 as a cheaper default/agent tier. +- **Effort Γ— Impact**: Med Γ— Med–High. +- **Verdict**: Worth trying (guard is a prerequisite, not optional). + +### 4. Tool-approval as first-class SSE/part state + tool_result source provenance +- **Source**: assistant-ui `eve@0.0.2` (https://github.com/Yonom/assistant-ui/releases); Vercel AI SDK human-in-the-loop (https://ai-sdk.dev/cookbook/next/human-in-the-loop); NN/g State of UX 2026 (https://www.nngroup.com/articles/state-of-ux-2026/). +- **Surface area**: tool-approval BeforeToolCall hook, `tool_use`/`tool_result` SSE contract, frontend tool-call card + signal store; reuses `beginContinuationStreaming` resume. +- **Change**: model approve/deny/denied as explicit states on the tool-use SSE pair (with input args shown), auto-resume once all approvals in a turn resolve, and render a dedicated denied state instead of a synthetic error; separately, surface `serverName`/RAG-source provenance on every `tool_result` card. +- **Subtracts**: replaces ad-hoc synthetic-error approval handling with explicit states. +- **Unlocks**: closes the "approval hook can't see through the tool-fold" hole (paired with idea #2's hook ordering); "data sources used" trust surface per NN/g. +- **Effort Γ— Impact**: Med Γ— Med. +- **Verdict**: Worth trying. + +### 5. Evaluate gateway-level Guardrails (AgentCore Policy) vs. in-agent #480 +- **Source**: https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-guardrails.html; relates to queued issue #480 (configurable Guardrails). +- **Surface area**: PlatformStack Gateway construct + AgentCore Policy; `apis/shared` tool routing. +- **Change**: assess whether one gateway-level Guardrails policy (evaluating all MCP-target inputs/outputs outside the agent) is preferable to or complements the in-agent `guardrail_id` approach in #480; one policy blankets every Gateway MCP target. +- **Subtracts**: potentially replaces per-tool control complexity with a single enforcement point. +- **Unlocks**: model-independent FERPA/injection enforcement the agent can't reason around. +- **Effort Γ— Impact**: Med Γ— Med. +- **Verdict**: Monitor β†’ Worth trying (fold into the #480 decision rather than a separate track). + +## Take + +The system is trending *toward* the ecosystem this fortnight β€” both of our biggest open items got upstream answers. The single change that matters most is the **`bedrock-agentcore` 1.17.0 bump**: it's the rare item that is simultaneously a subtraction (deletes a guard we'd have written), a reliability fix (closes a silent cross-session hang we're exposed to), and cheap. Phil would notice idea #3 first as a user β€” Fable 5 back in the picker and a Sonnet 5 option β€” but idea #1 is what he'd want shipped before the next incident. The one thing that hasn't moved in three weeks is the nightly pipeline; #518 didn't fully fix it, and it deserves a log-diving pass this cycle rather than another week of red. + +--- + +## Sources Scanned + +| # | Source | URL | Accessed | Items | +|---|---|---|---|---| +| 1 | AWS Bedrock/AgentCore What's New + docs | https://aws.amazon.com/about-aws/whats-new/recent/feed/ | 2026-07-03 | 5+ | +| 2 | Strands Agents releases (harness-sdk monorepo) | https://github.com/strands-agents/sdk-python/releases | 2026-07-03 | 6 | +| 3 | Reference repo commits | https://github.com/aws-samples/sample-strands-agent-with-agentcore/commits/main | 2026-07-03 | 2 | +| 4 | MCP blog / RC / ext-apps | https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ | 2026-07-03 | 5 | +| 4a | FastMCP releases | https://github.com/jlowin/fastmcp/releases | 2026-07-03 | 1 (no in-window) | +| 4b | Agentic UI/UX (assistant-ui, AI SDK, NN/g) | https://github.com/Yonom/assistant-ui/releases | 2026-07-03 | 4 | +| 5 | Frontier models (Anthropic/AWS/OpenAI/Google) | https://www.anthropic.com/news | 2026-07-03 | 3 | +| 6 | Agent harness (Claude Code CHANGELOG) | https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md | 2026-07-03 | 4 | +| 6a | opencode releases | https://github.com/anomalyco/opencode/releases | 2026-07-03 | 3 | +| 7 | Bedrock pricing | https://aws.amazon.com/bedrock/pricing/ | 2026-07-03 | 1 | +| 8 | AgentCore SDK / toolkit issues | https://github.com/aws/bedrock-agentcore-sdk-python/pull/563 | 2026-07-03 | 4 | +| 9 | Community (HN, aggregators) | https://news.ycombinator.com/item?id=46954597 | 2026-07-03 | 4 | +| 10 | Anthropic cookbook commits | https://github.com/anthropics/anthropic-cookbook/commits/main | 2026-07-03 | 2 | +| 12 | LibreChat releases | https://github.com/danny-avila/LibreChat/releases | 2026-07-03 | 0 (stale) | +| 19 | Version pins (PyPI + npm registry) | https://pypi.org/project/strands-agents/ | 2026-07-03 | 7 deps | + +## Web Budget + +Used: **~64 / 50 requests** (overage). +Skipped (unreachable / rate-limited): npmjs.com HTML (403 β€” used `registry.npmjs.org` JSON instead); FastMCP `CHANGELOG.md` (404 β€” uses GitHub Releases); Reddit (domain-blocked β€” WebSearch summaries only); LibreChat releases served stale 2025 content. +Skipped (other, budget): `boto3` and `constructs` latest-version fetches; Strands issues tab (subagent budget went to disambiguating the harness-sdk monorepo migration). +Notes: overage is driven by the **double scan window** (the June 26 run was skipped, so this covers 14 days) and running all 14 source subagents. The version-pin subagent alone spent ~10 requests fighting npmjs 403s before falling back to the registry JSON API. Signal density justified the overage β€” the `bedrock-agentcore` #482 fix, the Strands 5-minor jump, and the Fable 5 reinstatement were each material. diff --git a/docs/kaizen/review-queue.md b/docs/kaizen/review-queue.md index cd5ff107..270b8f2d 100644 --- a/docs/kaizen/review-queue.md +++ b/docs/kaizen/review-queue.md @@ -5,36 +5,75 @@ Items added by `kaizen-research`, consumed by `kaizen-review-prep`. ## Open +### [2026-07-06] Spike: managed AgentCore Harness as the headless/scheduled run engine β€” βœ… SPIKE + Q2 LIVE PROBE COMPLETE, recommend Ship (headless-only, GO-with-boundary) +- **Source**: `scoping/2026-07-06-managed-harness-build-vs-adopt.md` (brief) + `scoping/2026-07-06-managed-harness-spike-findings.md` (**findings β€” 3 gating questions answered**). Surfaced while dogfooding scheduled runs (Phil asked whether we use the AWS Harness feature; we use the lower-level Runtime). AWS **managed Harness** is now GA. +- **Surface**: backend (`apis/shared/harness/run_agent_headless` β€” swap the Runtime `/invocations` target for an `InvokeHarness` endpoint on the headless lane only; swap `sse.py` accumulator for a Converse-stream one β†’ same `RunResult`) + infra (a managed-Harness resource + OAuth-inbound JWT authorizer β€” a 1:1 port of our existing Runtime `customJwtAuthorizer`, `inference-agentcore-construct.ts:275`). Interactive `inference-api` untouched. +- **Effort Γ— Impact**: M (spike) Γ— H +- **Subtracts**: potentially large β€” managed memory (fixes AgentCore-Memory-write-only-in-cloud β†’ F5), immutable versions + named endpoints (retires the ECR-tag/`update-function-code` fragility that bit the scheduled-runs deploy), auto observability, `InvokeHarness` Step Functions composition, execution limits/truncation β€” all as config on the proactive lane. +- **Unlocks**: managed long-term memory + ops maturity for proactive/scheduled agents without touching interactive chat; `export harness` keeps lock-in low. +- **Status**: open β€” **spike complete, all three gating questions CLEAR for the headless lane; recommend Ship a narrowly-scoped `InvokeHarness` probe.** Full replace remains a non-starter (managed Harness has `Hooks ❌`, `Choice of framework ❌`, no MCP Apps UI, not our SSE contract β€” interactive differentiation lives there); scope is headless-only. Answers: **(Q1) RBACβ†’`allowedTools` = qualified YES** β€” `allowedTools` is per-invoke-settable + globs cover our id shapes, and we already snapshot the RBAC-narrowed set statically at the app-api boundary (`schedules/routes.py:75`), so idβ†’glob is mechanical; Cedar-on-Gateway covers arg-level gating. Non-membership gates relocate (quota/cost β†’ dispatcher pre-gate + post rollup; tool-approval β†’ exclude on headless; OAuth-consent β†’ Identity outbound). **(Q2) per-user tokens = YES on mechanism** (OAuth-inbound `customJWTAuthorizer` = our authorizer; owner-minted token threads identity; Gateway `outboundAuth.oauth` = our `get_token_for_user` `USER_FEDERATION` exchange; **SigV4 canNOT do per-user identity** so must be OAuth-inbound) β€” **one residual needing a live probe: the `customParameters` vault-key gotcha**, since Gateway config performs the exchange and we lose call-site control of `customParameters`. **(Q3) lose MCP Apps + SSE = YES** β€” both are interactive affordances a scheduled run has no live consumer for; SPA loads the delivered session, not the harness stream. **Next action (Ship):** a scoped `InvokeHarness` prototype whose only job is to close the Q2 `customParameters` residual (one Gateway OAuth tool, one `customParameters`-sensitive provider, owner-minted Bearer, confirm the vaulted token resolves + a *missing* token fails legibly for `paused_reauth`). If it clears, adopt on the proactive lane behind a flag. Newly GA β†’ still verify pricing/quotas in the probe. **β†’ Q2 PROBE RUN LIVE (dev-ai, 2026-07-06) = GO-with-boundary** (details in findings doc "Q2 probe result"): confirmed live that (a) `CreateHarness` accepts our exact `customJWTAuthorizer` (1:1 port, harness READY); (b) `outboundAuth.oauth.customParameters` is honored β€” persisted verbatim on `GetHarness` (2-key & 3-key maps) β†’ **we can pin the same params the consent used**; (c) OAuth-inbound works (owner-minted Cognito Bearer β†’ HTTP 200, ran as owner); (d) the exchange calls the **same `GetResourceOauth2Token`** our `get_token_for_user` uses; (e) a failed exchange surfaces **legibly** as a typed `runtimeClientError` stream event β†’ maps to `paused_reauth`. **Boundary (new, blocking a clean positive):** the managed **Gateway** 3LO exchange fails with `ValidationException: You must provide a ResourceOauth2ReturnUrl` and does **not** source that URL from `defaultReturnUrl` (per-invoke or create-time), the `OAuth2CallbackUrl` header, **or** `UpdateWorkloadIdentity` `AllowedResourceOauth2ReturnUrl` β€” so a clean positive resolution couldn't be observed; cross-workload token visibility (platform-workload vs harness-workload) also unreached. **Decision:** Ship headless adoption, but keep `customParameters`-sensitive / all 3LO connectors on our own `get_token_for_user` (F1-proven) rather than the Harness-managed Gateway exchange until AWS's return-URL wiring is resolved (support ticket / re-probe). Pricing: no separate harness charge; **managed memory is on by default** (extra Memory cost per harness). Probe harness torn down; no new IAM/gateway-target footprint. + +### [2026-07-06] Watchlist: Bedrock Mantle endpoint β€” Claude-on-Mantle Strands provider gap + capability parity +- **Source**: Phil-initiated kaizen focus β€” scope a migration from the `bedrock-runtime` (Converse) endpoint to the new `bedrock-mantle` endpoint ([AWS endpoints doc](https://docs.aws.amazon.com/bedrock/latest/userguide/endpoints.html); [Opus 4.8 model card](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-4-8.html)). Strategic driver: align with where Bedrock is heading (new capability lands Mantle-first), not near-term need. +- **Surface**: backend β€” `agents/main_agent/core/agent_factory.py` (`_create_mantle_model` today is a Strands `OpenAIModel` β†’ Chat Completions; a Claude-on-Mantle path needs an Anthropic-Messages provider, which Opus 4.8's Mantle surface is limited to), `core/model_config.py` (`ModelProvider.MANTLE` already defined), `core/bedrock_count_tokens.py` (`CountTokensBedrockModel` β€” bedrock-runtime-only), plus the 3 direct-Converse bypasses (`inference_api/chat/converse_routes.py`, Nova title gen `chat/service.py:355`, Titan embeddings `shared/embeddings/bedrock_embeddings.py` via `invoke_model`). +- **Effort Γ— Impact**: (Claude-on-Mantle) M–H Γ— L-now β€” high cost, low near-term value. (Non-Claude Mantle lane) L–M Γ— M β€” finish the already-scaffolded OpenAI-compatible path. +- **Subtracts**: no (watchlist). If/when acted on, the non-Claude lane makes `ModelProvider.MANTLE`/`_create_mantle_model()` a first-class peer instead of dark scaffolding. +- **Unlocks**: Mantle-first capability gradient (Responses API, server-side tool use, async/long-running, Projects/Workspaces) once Claude parity lands; a uniform OpenAI-compatible lane for non-Claude models inside Bedrock without a second vendor SDK. +- **Status**: open β€” **strategic/future-proofing, not urgent; recommend Defer (watchlist) + a small non-Claude-lane spike.** Corrected findings: (1) `bedrock-runtime` is "fully supported," no EOL signal β€” Mantle recommendation is greenfield-onboarding language, but the capability gradient toward Mantle is real. (2) **The "persisted Converse wire shape = multi-model lock-in" concern does NOT hold** β€” verified `strands/types/content.py:78` `ContentBlock` (`toolUse`/`toolResult`/`reasoningContent`) IS Strands' provider-neutral canonical shape; every Strands provider round-trips it to/from Anthropic Messages / OpenAI Chat Completions. AgentCore Memory abstracts *persistence*; Strands abstracts *multi-model shape*. Switching a provider's endpoint changes Strands `format_request` internals, **not** our persistence schema or `_convert_content_block`. No schema-decoupling PR needed. (3) The real bedrock-runtime ties are narrow: the 3 direct-Converse bypasses, `CountTokens` (no Mantle equal β€” powers context-attribution + compaction), and cross-region profiles (`us.*`/`global.*`, Mantle-absent). (4) **Do NOT migrate the Claude chat path to Mantle yet**: Opus 4.8 on Mantle is Messages-API-only (Chat Completions/Responses = No), so Mantle's headline built-ins don't apply to our primary model; Mantle also lacks cross-region inference, native `CountTokens`, structured outputs, and **Guardrails** (runtime-only β€” see [2026-06-19] Guardrails item, which this reinforces). Pricing identical; Mantle default TPM not a win (`20M in/4M out` vs runtime `30M`). **Reopen trigger:** a Strands Anthropic-Messages-on-Mantle provider ships **AND** cross-region + native token counting reach Claude-on-Mantle. Interim, low-risk value = finishing the non-Claude OpenAI-compatible lane already scaffolded in `_create_mantle_model()`. + +### [2026-07-03] Bump `bedrock-agentcore` 1.9.1 β†’ 1.17.0 (closes SSE-deadlock #482) +- **Source**: research/2026-07-03.md β–Έ Top 5 #1 β€” https://github.com/aws/bedrock-agentcore-sdk-python/pull/563 (issue #482); internal inference-api SSE-over-Runtime exposure. +- **Surface**: backend (`backend/pyproject.toml`, inference-api chat router; full local pytest suite β€” the only correctness gate, pytest isn't in CI) +- **Effort Γ— Impact**: M Γ— H +- **Subtracts**: yes β€” retires the queued [2026-05-22] "defensive guard against #482" work item; the fix (`put_nowait` + disconnect stop-event + source `aclose()`) is now upstream (library-native subtraction) +- **Status**: open β€” **highest priority; we're on 1.9.1 and exposed today** to a silent, process-wide container hang that keeps `/ping` green when an SSE consumer stops draining. This supersedes/absorbs the queued [2026-05-22] guard item. + +### [2026-07-03] Bump Strands 1.40 β†’ 1.45 + adopt hook ordering; audit cache_tools_ttl / context_manager / Limits +- **Source**: research/2026-07-03.md β–Έ Top 5 #2 β€” Strands releases 1.41–1.45 (https://github.com/strands-agents/sdk-python/releases, now `harness-sdk` monorepo). +- **Surface**: backend (`backend/src/agents/main_agent/` hooks + BedrockModel config + compaction, `to_bedrock_config`, `CountTokensBedrockModel`) +- **Effort Γ— Impact**: M–H Γ— H +- **Subtracts**: candidate β€” custom cache-point plumbing (`cache_tools_ttl`, 1.41) and possible compaction simplification (`context_manager="auto"`, 1.43 β€” gated on the SSE-contract check per decisions.md 2026-05-18, not a bare drop-in) +- **Unlocks**: `Limits` per-invocation token/cost caps (first-class budget guard we lack) +- **Status**: open β€” **supersedes the queued [2026-06-19] "1.40 β†’ 1.44" item.** Adopt optional hook ordering (#2559) to deterministically sequence the OAuth-consent + tool-approval BeforeToolCall hooks through the tool-fold. Only breaking change 1.41β†’1.45 is Mistral (N/A). Run the full local pytest suite; watch compaction + count_tokens/context-attribution. + +### [2026-07-03] Model-settings refresh: reinstate Fable 5 + add Sonnet 5 with temperature-suppression guard +- **Source**: research/2026-07-03.md β–Έ Top 5 #3 β€” Fable 5 reinstated (https://aws.amazon.com/blogs/aws/anthropic-claude-fable-5-on-aws-mythos-class-capabilities-with-built-in-safeguards-now-available/); Sonnet 5 GA + promo pricing (https://aws.amazon.com/bedrock/pricing/); ref-repo `NO_TEMPERATURE_MODELS` (commit 35bc3a9). +- **Surface**: cross-cutting (inference-api model config + model-settings admin, `to_bedrock_config`, `CountTokensBedrockModel` de-prefix, frontend model picker) +- **Effort Γ— Impact**: M Γ— M–H +- **Subtracts**: addition only β€” justified: **un-withdraws the [2026-06-12] Fable 5 item** (revoked mid-June, reinstated July 1) and adds a materially cheaper capable tier (Sonnet 5 $2/$10 promo through Aug 31 vs. Opus 4.8) +- **Unlocks**: Fable 5 harness tier above Opus 4.8; Sonnet 5 as a cheaper default/agent tier +- **Status**: open β€” **the temperature-suppression guard is a prerequisite, not optional**: Sonnet 5 rejects `temperature` on ConverseStream. Fable 5 is US inference profile only (Global unstable). + +### [2026-07-03] Tool-approval as first-class SSE/part state + tool_result source provenance +- **Source**: research/2026-07-03.md β–Έ Top 5 #4 β€” assistant-ui `eve@0.0.2` (https://github.com/Yonom/assistant-ui/releases); Vercel AI SDK human-in-the-loop (https://ai-sdk.dev/cookbook/next/human-in-the-loop); NN/g State of UX 2026 (https://www.nngroup.com/articles/state-of-ux-2026/). +- **Surface**: frontend + backend (tool-approval BeforeToolCall hook, `tool_use`/`tool_result` SSE contract, frontend tool-call card + signal store; reuses `beginContinuationStreaming`) +- **Effort Γ— Impact**: M Γ— M +- **Subtracts**: yes β€” replaces ad-hoc synthetic-error approval handling with explicit approve/deny/denied states on the tool-use SSE pair +- **Unlocks**: closes the known "approval hook can't see through the tool-fold" hole (pairs with the Strands hook-ordering bump); "data sources used" provenance on `tool_result` cards (we already carry `serverName`/`icon` on `ui_resource`) β€” a top NN/g trust driver +- **Status**: open β€” auto-resume once all approvals in a turn resolve (the multi-tool piece worth stealing from the AI SDK). + +### [2026-07-03] Evaluate gateway-level Guardrails (AgentCore Policy) vs. in-agent #480 +- **Source**: research/2026-07-03.md β–Έ Top 5 #5 β€” https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-guardrails.html; relates to queued issue #480. +- **Surface**: infrastructure + backend (PlatformStack Gateway construct + AgentCore Policy; `apis/shared` tool routing) +- **Effort Γ— Impact**: M Γ— M +- **Subtracts**: potential β€” one gateway-level policy vs. per-tool control complexity +- **Unlocks**: model-independent FERPA/injection enforcement across all Gateway MCP targets (the agent can't reason around it) +- **Status**: open β€” **fold into the #480 decision** rather than run a separate track: assess whether one gateway policy is preferable to or complements the in-agent `guardrail_id` approach. + ### [2026-06-19] Wire configurable Bedrock Guardrails (issue #480) - **Source**: research/2026-06-19.md β–Έ Top 5 #1 β€” internal issue #480 (June 15) + AWS Summit NYC Guardrails cluster (`InvokeGuardrailChecks` API + AgentCore policy Guardrails GA, June 16). Strands `BedrockModel` already supports `guardrail_id`/`version`/`stream_processing_mode`/`trace`. - **Surface**: backend (`inference_api` `BedrockModel` construction) + infrastructure (optional `CDK_GUARDRAIL_ID` / `CDK_GUARDRAIL_VERSION` env vars threaded to inference-api runtime env) - **Effort Γ— Impact**: L-M Γ— H - **Subtracts**: addition only β€” config wiring of a capability Strands already exposes; zero-cost when unset; mirrors `CDK_ARTIFACTS_ENABLED`/`CDK_MCP_SANDBOX_ENABLED` optional-feature pattern - **Unlocks**: deployers attach content-safety filtering + staff-alerting monitoring to all model invocations without modifying inference-api source (FERPA duty-of-care for higher-ed: proactive self-harm/crisis-language monitoring Claude's reactive layer doesn't surface) -- **Status**: open β€” strongest fit of the week (filed issue + library-native path + AWS feature cluster aligned). Verify the guardrail *resource* region availability; confirm guardrail streaming mode is compatible with the SSE relay. +- **Status**: open β€” strongest fit (filed issue + library-native path). **Decide in-agent vs. gateway-level in one pass** β€” the [2026-07-03] "gateway-level Guardrails (AgentCore Policy)" item folds into this #480 decision (one gateway policy blankets every MCP target, model-independent). Verify guardrail *resource* region availability + SSE streaming-mode compatibility. Reviewed reviews/2026-07-03.md β–Έ Proposal #4. ### [2026-06-19] Fix Nightly Build & Test (`exit 127` at install β€” ~14 consecutive failures) - **Source**: research/2026-06-19.md β–Έ Internal Audit + Top 5 #2 β€” `gh run view 27820449858 --log-failed` shows `exit code 127` on every install/setup step (June 19); failing daily since June 5. Carries the [2026-06-12] nightly item forward with a sharper diagnosis (was "root cause unknown"). - **Surface**: CI β€” `.github/workflows/` nightly workflow install/setup steps (`setup-uv` / `setup-node` / cache action) - **Effort Γ— Impact**: L Γ— H -- **Subtracts**: no β€” hygiene; prerequisite for trusting the Strands 1.44 + bedrock-agentcore 1.15 bumps -- **Status**: open β€” time-sensitive; CI environment/runner regression (a binary expected by install returns 127, likely `uv`/`node` PATH after an action or runner-image change), NOT a test regression. Do not land dep bumps on a suite that can't install. Supersedes the [2026-06-12] nightly item. - -### [2026-06-19] Bump Strands 1.40 β†’ 1.44 β€” supersedes the [2026-06-12] 1.43 keystone -- **Source**: research/2026-06-19.md β–Έ Top 5 #3 β€” strands-agents v1.44.0 (June 16). **Supersedes** [2026-06-12] "Strands 1.40 β†’ 1.43" β€” target advances one more minor; no new Python breaking changes 1.41–1.44. -- **Surface**: backend (`pyproject.toml`/`uv.lock` β€” `strands-agents` 1.40β†’1.44, `strands-agents-tools` 0.5.2β†’0.8.1; agent invocation in `inference_api`; `BedrockModel`/`CacheConfig`; SSE `limit_*` stop-reason; tooling that assumed the old `vX.Y.Z` tag scheme β€” now `python/vX.Y.Z` in the `harness-sdk` monorepo) -- **Effort Γ— Impact**: M Γ— H -- **Subtracts**: yes β€” library-native `Limits` retires the hand-rolled runaway guardrail; `cache_tools_ttl` retires hand-rolled TTL; collapses the superseded 1.42/1.43 keystone entries + the #2635 guard into one PR -- **Unlocks**: native per-turn cost ceiling; 1h prompt caching; accurate context attribution on tool-heavy turns -- **Status**: open β€” prerequisite: green Nightly (see above). **Do NOT** adopt the new native memory-manager / agentic-context-management ports on this bump (they overlap `TurnBasedSessionManager`; decisions.md bars a bare swap) β€” scope a separate compat review. #2636 (non-ASCII) still live in 1.44.0 β€” add a known-limitation comment; a second bump follows once #2661/#2653 merge. - -### [2026-06-19] Bump `bedrock-agentcore` 1.9.1 β†’ 1.15.0 + adopt `async_mode` -- **Source**: research/2026-06-19.md β–Έ Top 5 #4 β€” bedrock-agentcore v1.15.0 (June 17); 6 minors behind. **Supersedes** the [2026-06-12] "1.9.1 β†’ 1.14.1" item and consolidates the [2026-05-22] bump + async_mode items. The #482 SSE-disconnect-deadlock fix lives in the 1.14.x line. -- **Surface**: `backend/pyproject.toml` + `uv.lock`; `AgentCoreMemoryConfig` construction (`async_mode`); inference-api `/invocations` SSE worker (the #482 deadlock path) -- **Effort Γ— Impact**: L-M Γ— M-H -- **Subtracts**: yes β€” folds the deferred [2026-05-22] "#482 SSE-disconnect deadlock guard" into a dep bump (upstream fix instead of a hand-rolled guard); `async_mode` retires the latent #452 event-loop-blocking mode; consolidates 2–3 queue items -- **Unlocks**: interactive-shell API access; bearer-token integration; A2A cap prerequisite for the first A2A server PR -- **Status**: open β€” bundle as the "dep hygiene" PR after the Nightly is green; verify the #482 fix exercises our SSE-disconnect path. +- **Subtracts**: no β€” hygiene; the dep-bump gate +- **Status**: open β€” **#518 (in 1.0.2) repointed the test/install paths but nightly STILL failed Jun 29–30** (research/2026-07-03.md): a different stage (the ephemeral deploy/teardown per the `fix/nightly` work) is implicated. **Live note**: Jul 1–3 nightly is green on `main` β€” likely just-fixed; confirm the `develop` nightly (last develop run 2026-06-03) is covered before trusting it as the dep-bump gate. Consolidates the [2026-06-12] nightly item. Reviewed reviews/2026-07-03.md β–Έ Proposal #9. ### [2026-06-19] Ship the interactive context-breakdown badge (Cursor + LibreChat convergence) - **Source**: research/2026-06-19.md β–Έ Top 5 #5 β€” LibreChat v0.8.7-rc1 real-time context gauge + Cursor Context Usage Report (2026-06-05) + internal PR #433. **Reinforces** the [2026-06-05] "make the context-breakdown badge interactive" item with a second independent product datapoint. @@ -42,82 +81,14 @@ Items added by `kaizen-research`, consumed by `kaizen-review-prep`. - **Effort Γ— Impact**: M Γ— M - **Subtracts**: no β€” addition; lands on a surface we shipped and reuses `contextBreakdown` already on the final `metadata` event - **Unlocks**: user-facing context-cost transparency + an actionable "what's eating context / how to trim it" follow-up -- **Status**: open β€” presentation-layer only (no backend change). Now validated by two products. Consider folding into / superseding the [2026-06-05] item at review. - -### [2026-06-12] Add Claude Fable 5 to model settings + audit model-ID string matching β€” ⚠️ WITHDRAW (Fable 5 revoked on Bedrock) -- **Source**: research/2026-06-19.md β–Έ Retirement candidates β€” Fable 5 + Mythos 5 were **revoked on Bedrock for all users (US gov directive, June 12–13)**, three days after their June 9 GA. The "add Fable 5 / consider as default" core of the original [2026-06-12] item is dead until/unless the directive lifts. -- **Surface**: n/a (withdrawal) -- **Effort Γ— Impact**: β€” Γ— β€” -- **Subtracts**: yes β€” removes a queued addition that external availability killed -- **Status**: open β€” **recommend review-prep mark the [2026-06-12] Fable 5 item RESOLVED (Decline/superseded)**. Minor residual merit: the `claude-opus-4` capability-gate string-match audit is still worth doing for future-proofing, but decoupled from Fable 5. Opus 4.8 stays the default/floor. - -### [2026-06-12] Bump Strands 1.40 β†’ 1.43 β€” supersedes [2026-06-05] keystone; closes #2635 + context_manager="auto" + A2A isolation fix -- **Source**: research/2026-06-12.md β–Έ Top 5 #1 β€” Strands v1.43.0 released June 12, 2026. **Supersedes** [2026-06-05] "Strands 1.40 β†’ 1.42 bump" β€” target advances one more minor; no additional blast radius. Also closes the [2026-06-05] "#2635 guard" queue item. -- **Surface**: backend (`pyproject.toml`/`uv.lock` β€” `strands-agents==1.40.0` β†’ `==1.43.0`; agent invocation in `inference_api`; `BedrockModel`/`CacheConfig`; SSE `limit_*` stop-reason) -- **Effort Γ— Impact**: M Γ— H -- **Subtracts**: yes β€” library-native `Limits` retires the hand-rolled runaway guardrail; `cache_tools_ttl` retires hand-rolled TTL; #2635 defensive guard resolves as part of the bump; three queue items collapse into one PR -- **Unlocks**: native per-turn cost ceiling; 1h prompt caching; accurate context attribution on tool-heavy turns -- **Status**: open β€” prerequisite: confirm Nightly CI is green (see [2026-06-12] nightly investigation item) before landing. #2636 (non-ASCII) still live in 1.43.0 β€” add a known-limitation comment, a second bump will follow once PR #2661 merges. - -### [2026-06-12] Add Claude Fable 5 to model settings + audit model-ID string matching -- **Source**: research/2026-06-12.md β–Έ Top 5 #2 β€” Claude Fable 5 GA June 9 (https://aws.amazon.com/about-aws/whats-new/2026/06/claude-fable-5-aws/). Naming convention shift (`-fable-`/`-mythos-` suffixes vs. `claude-opus-4.N`) is a live breakage risk. -- **Surface**: frontend (`model-settings.html`, `model-settings.ts` β€” add `claude-fable-5` to dropdown) + backend (grep `claude-opus-4` in capability gates: prompt-caching beta header, fine-grained tool-streaming beta header; admin model catalog) -- **Effort Γ— Impact**: L-M Γ— H -- **Subtracts**: partial β€” Fable 5 at $10/$50/M may replace Opus 4.8 as default once benchmarked; no hard retirement yet -- **Unlocks**: top-of-range Anthropic model on Bedrock at materially lower cost; model-list parity for end users -- **Status**: open β€” use the `claude-api` skill to confirm exact Bedrock IDs before committing; verify context window + caching API support on Bedrock model card before flipping to default - -### [2026-06-12] Investigate + triage Nightly Build & Test (7 consecutive failures June 5–12) -- **Source**: research/2026-06-12.md β–Έ Internal Audit β€” CI failures. Same pattern resolved via PR #290 in May; root cause unknown this time. -- **Surface**: CI β€” `.github/workflows/` nightly workflow + backend test suite -- **Effort Γ— Impact**: L Γ— H -- **Subtracts**: no β€” hygiene; prerequisite for trusting the Strands 1.43 keystone bump and any other dep changes -- **Status**: open β€” time-sensitive; run `gh run view --log-failed`; classify flaky-vs-regression; quarantine or file. Do not land dep bumps on an untrusted suite. - -### [2026-06-12] Bump `bedrock-agentcore` 1.9.1 β†’ 1.14.1 + adopt `async_mode` + note A2A cap prerequisite -- **Source**: research/2026-06-12.md β–Έ Top 5 #4 β€” bedrock-agentcore v1.14.1 (June 11). **Consolidates** [2026-05-22] "Bump bedrock-agentcore 1.9.1 β†’ 1.11.0" and [2026-05-22] "Re-bump 1.9.1 β†’ 1.11.0 + async_mode" open items (which were already 4+ minors behind; now 5). -- **Surface**: `backend/pyproject.toml` + `uv.lock`; `AgentCoreMemoryConfig` construction (`async_mode` adoption) -- **Effort Γ— Impact**: L Γ— M -- **Subtracts**: `async_mode` adoption retires the latent #452 event-loop-blocking failure mode; two queue items consolidate into one -- **Unlocks**: interactive shell API access; A2A cap fix is a hard prerequisite for the first A2A server PR -- **Status**: open β€” can bundle with the starlette CVE bump (#5 below) as a single "dep hygiene" PR - -### [2026-06-12] Bump `starlette` 1.0.0 β†’ 1.0.1 to close CVE-2026-48710 -- **Source**: research/2026-06-12.md β–Έ Top 5 #5 β€” FastMCP v3.4.1 (June 5) surfaced CVE-2026-48710 affecting starlette < 1.0.1. Our `pyproject.toml` pins `starlette==1.0.0`. -- **Surface**: `backend/pyproject.toml` β€” 1-line pin bump -- **Effort Γ— Impact**: L Γ— M -- **Subtracts**: no β€” 1-line security fix; the existing comment says the pin was already security-motivated -- **Status**: open β€” bundle with bedrock-agentcore bump (#4 above) as a single dep-hygiene PR; also flag to MCP server repos to bump FastMCP to β‰₯3.4.1 - -### [2026-06-05] Strands 1.40 β†’ 1.42 bump β€” unblocks `Limits` (cost caps) + `cache_tools_ttl` (#269 caching) -- **Source**: research/2026-06-05.md β–Έ Top 5 #1 β€” Strands v1.42.0 (June 1). **Consolidates** the queued 2026-05-22 "Strands 1.40β†’1.41 + caching #269" item AND the 2026-05-29 #2 "Adopt Strands `Limits`" item β€” both were gated on 1.42, which is now out. Treat as one keystone bump, not two. -- **Surface**: backend (`pyproject.toml`/`uv.lock`, agent invocation in `inference_api`, `BedrockModel`/`CacheConfig`, SSE `limit_*` stop-reason) + infrastructure (CloudWatch Bedrock-spend alarm) -- **Effort Γ— Impact**: M Γ— H -- **Subtracts**: yes β€” adopts library-native `Limits` (retires the hand-rolled runaway guardrail) + `cache_tools_ttl` (retires the hand-rolled TTL); two queued items collapse into one bump -- **Unlocks**: native per-turn cost ceiling (`limit_*` stop_reason) + end-to-end 1h prompt caching β†’ lower input-token cost, surfaced in the admin "Cache Savings" card -- **Status**: open β€” 1.42 is released (no longer gated). Blast radius to audit first: `strands-agents-tools` 0.5β†’0.8 (possible breaking tool-interface changes) + `starlette` 1.2.1 (FastAPI 0.136.x transitive compat) - -### [2026-06-05] Guard the context-attribution path against Strands `count_tokens` toolResult=0 bug (#2635) -- **Source**: research/2026-06-05.md β–Έ Top 5 #2 β€” Strands issue #2635 (open) + internal PR #428–433 (context-attribution feature, shipped this window) -- **Surface**: backend (`CountTokensBedrockModel`, the `contextBreakdown` hook/coordinator channel, the compaction trigger) -- **Effort Γ— Impact**: L-M Γ— M-H -- **Subtracts**: no β€” defensive; protects the freshly-shipped context-breakdown badge -- **Status**: open β€” time-sensitive; confirm native Bedrock CountTokens is used for all turns (incl. JSON toolResults) and the heuristic path #2635 affects is never hit; add a regression test asserting a non-zero count for a turn with a JSON toolResult - -### [2026-06-05] Make the context-breakdown badge interactive (Cursor Context Usage Report pattern) -- **Source**: research/2026-06-05.md β–Έ Top 5 #3 β€” Cursor "Context Usage Report" (https://cursor.com/changelog/canvas-improvements) + internal PR #433 -- **Surface**: frontend (context-breakdown badge component + Artifacts docked panel) -- **Effort Γ— Impact**: M Γ— M -- **Subtracts**: no β€” addition; justified because it lands on a surface we shipped last week and reuses `contextBreakdown` data already on the final metadata event -- **Unlocks**: user-facing context-cost transparency + an actionable "what's eating context / how to trim it" follow-up -- **Status**: open β€” presentation-layer work; no new backend (data already on the wire) +- **Status**: open β€” presentation-layer only (no backend change). Consolidated the superseded [2026-06-05] Cursor-only entry into this item. Lower priority than the [2026-07-03] reliability/model cluster. Reviewed reviews/2026-07-03.md β–Έ below-cap (defer 1 week). ### [2026-06-05] Bump `docling` past the 2.81.0 content-sniffing defect β†’ close #405 (`.txt` uploads fail) - **Source**: research/2026-06-05.md β–Έ Top 5 #4 β€” docling 2.97.0 (June 3) + internal issue #405 - **Surface**: backend (document-ingestion docling dep pin) - **Effort Γ— Impact**: L Γ— M - **Subtracts**: yes β€” library-native bump closes an open user-facing bug; no custom workaround needed -- **Status**: open β€” cleanest subtraction of the week; bump off 2.81.x, verify `.txt` upload, close #405 +- **Status**: open β€” **#405 still open ~5 weeks; `requirements.lock` still pins `docling==2.81.0` (latest 2.109.0).** Cleanest subtraction; bump off 2.81.x, verify `.txt` upload, close #405. Reviewed reviews/2026-07-03.md β–Έ Proposal #6. ### [2026-06-05] De-risk #419 (admin-managed Gateway target registration) against the new AWS auth-code-flow + BYO-secrets references - **Source**: research/2026-06-05.md β–Έ Top 5 #5 β€” AWS "secure OAuth auth-code flow with Gateway + MCP clients" + AgentCore Identity BYO Secrets Manager (both June 1) + internal issue #419 @@ -157,26 +128,12 @@ Items added by `kaizen-research`, consumed by `kaizen-review-prep`. - **Subtracts**: no β€” defensive; protects the shared event loop from being wedged by one user's request - **Status**: open β€” #399 already filed; kaizen value is the broader class-of-bug sweep (pairs with the queued SDK #482 guard) -### [2026-05-22] Defensive guard against SDK #482 SSE-disconnect runtime deadlock -- **Source**: research/2026-05-22.md β–Έ Top 5 #2 β€” AgentCore SDK issue #482 -- **Surface**: backend (`inference-api` streaming worker β€” the `/invocations` SSE handler) -- **Effort Γ— Impact**: M Γ— H -- **Subtracts**: no β€” defensive; silent 78s+ microVM stall on mid-stream client disconnect -- **Status**: open - -### [2026-05-22] Bump `bedrock-agentcore` 1.9.1 β†’ 1.11.0 -- **Source**: research/2026-05-22.md β–Έ Top 5 #3 β€” SDK v1.10.0/v1.11.0 releases -- **Surface**: backend (`pyproject.toml`, `uv.lock`) -- **Effort Γ— Impact**: L Γ— M -- **Subtracts**: possibly β€” v1.10.0 header-forwarding may retire a custom `X-Amzn-Custom-` header workaround (audit during bump) -- **Status**: open - ### [2026-05-22] Opus 4.7 `temperature`-omission guard - **Source**: research/2026-05-22.md β–Έ Top 5 #4 β€” ref-repo commit `9385454` - **Surface**: backend (provider-translation chokepoint β€” same site as `_shape_thinking_value` / #329 / #331) - **Effort Γ— Impact**: L Γ— M - **Subtracts**: no β€” defensive; Opus 4.7 rejects `temperature` on extended-thinking turns -- **Status**: open +- **Status**: open β€” **subsumed by the [2026-07-03] model-settings per-model temperature-suppression guard** (same `to_bedrock_config` chokepoint; Sonnet 5 has the same rejection). Ship as one guard covering both. Reviewed reviews/2026-07-03.md β–Έ Proposal #3. ### [2026-05-15] Wire per-tool `duration_ms` into `tool_result` SSE - **Source**: research/2026-05-15.md β–Έ Top 5 #5 β€” Claude Code 2.1.141 hook pattern @@ -230,21 +187,36 @@ Items added by `kaizen-research`, consumed by `kaizen-review-prep`. - **Subtracts**: no β€” additive but pattern-validated across Linear/ChatGPT/Cursor - **Status**: open β€” deferred 4 weeks in reviews/2026-05-15.md (revisit 2026-06-12). Earns its keep when an A2A construct lands. -### [2026-05-22] Re-bump `bedrock-agentcore` 1.9.1 β†’ 1.11.0 + adopt `async_mode` -- **Source**: reviews/2026-05-22.md β–Έ Proposal #2 β€” re-evaluation of the `async_mode`/#452 risk the 2026-05-15 review explicitly deferred "to the 2026-05-22 review". -- **Surface**: backend (`backend/pyproject.toml`, `backend/uv.lock`, `AgentCoreMemoryConfig` construction) -- **Effort Γ— Impact**: L-M Γ— M-H -- **Subtracts**: no β€” dep bump; adopting `async_mode` retires the latent #452 event-loop-blocking failure mode -- **Status**: open β€” surfaced in reviews/2026-05-22.md β–Έ Proposal #2 (Ship); no decision logged yet. Lag re-opened to 2 releases the week after #337 closed it. +## Resolved -### [2026-05-22] Fast PR-gate for the deterministic `supply_chain` + `architecture` test subset -- **Source**: reviews/2026-05-22.md β–Έ Proposal #6 β€” root-cause of the Proposal #1 friction (policy violation merged clean because PR-merge CI runs no pytest). -- **Surface**: CI β€” new lightweight job in the PR workflow -- **Effort Γ— Impact**: L Γ— M -- **Subtracts**: no β€” addition; converts a recurring post-merge friction class into a pre-merge block. Scoped to two deterministic dirs to avoid reopening the "no full pytest in PR CI" decision. -- **Status**: open β€” surfaced in reviews/2026-05-22.md β–Έ Proposal #6 (Ship scoped, or Defer 2 weeks); no decision logged yet. +### [2026-06-19] Bump Strands 1.40 β†’ 1.44 + [2026-06-12] 1.43 + [2026-06-05] 1.42 + [2026-06-05] #2635 guard β†’ RESOLVED β€” superseded by the [2026-07-03] 1.45 keystone +- **Decision**: Superseded β€” all four consolidated into the open [2026-07-03] "Strands 1.40 β†’ 1.45 + hook ordering" item. The #2635 count-tokens guard folds into the bump. +- **Reviewed-in**: reviews/2026-07-03.md β–Έ Proposal #2. -## Resolved +### [2026-06-19] Bump `bedrock-agentcore` 1.9.1 β†’ 1.15.0 + [2026-06-12] 1.14.1 + [2026-05-22] 1.11.0 (Γ—2) + [2026-05-22] #482 hand-written guard β†’ RESOLVED β€” superseded by the [2026-07-03] 1.17.0 bump +- **Decision**: Superseded β€” all consolidated into the open [2026-07-03] "bedrock-agentcore 1.9.1 β†’ 1.17.0" item. Per research/2026-07-03.md the #482 fix is now **upstream in 1.17.0** (PR #563), so the queued hand-written guard converts to "bump the pin" β€” a library-native subtraction. +- **Reviewed-in**: reviews/2026-07-03.md β–Έ Proposal #1. + +### [2026-06-12] Add Claude Fable 5 to model settings (+ the [2026-06-19] WITHDRAW) β†’ RESOLVED β€” un-withdrawn, folded into the [2026-07-03] model-settings refresh +- **Decision**: Superseded β€” **NOT declined.** Fable 5 was revoked on Bedrock mid-June (forcing the withdrawal) and **reinstated Jul 1**. The reinstatement + Sonnet 5 GA are consolidated into the open [2026-07-03] "Model-settings refresh: reinstate Fable 5 + add Sonnet 5" item (US inference profile only; Global unstable). +- **Reviewed-in**: reviews/2026-07-03.md β–Έ Proposal #3. + +### [2026-06-12] Investigate + triage Nightly Build & Test (7 failures) β†’ RESOLVED β€” consolidated +- **Decision**: Superseded β€” consolidated into the open [2026-06-19] nightly item (still open; #518 was incomplete, see that item). +- **Reviewed-in**: reviews/2026-07-03.md β–Έ Proposal #9. + +### [2026-06-12] Bump `starlette` 1.0.0 β†’ 1.0.1 (CVE-2026-48710) β†’ RESOLVED β€” shipped +- **Decision**: Resolved β€” `backend/pyproject.toml` now pins `starlette==1.3.1` (past the CVE floor) via PR #487 (June 18, "remediate 22 HIGH Dependabot findings"). Confirmed in research/2026-06-19.md's version-pin table. +- **Reviewed-in**: reviews/2026-07-03.md β–Έ What Shipped. + +### [2026-06-05] Make the context-breakdown badge interactive (Cursor) β†’ RESOLVED β€” consolidated +- **Decision**: Superseded β€” consolidated into the open [2026-06-19] "interactive context-breakdown badge (Cursor + LibreChat convergence)" item. +- **Reviewed-in**: reviews/2026-07-03.md β–Έ below-cap. + +### [2026-05-22] Fast PR-gate for the deterministic test subset β†’ RESOLVED β€” shipped (broader than proposed) +- **Decision**: Resolved β€” satisfied by **PR #490** (June 18, "ci: add pull_request test gate"). +- **Reasoning**: #490 added `.github/workflows/ci.yml` on `pull_request β†’ [develop, main]` with three parallel jobs β€” `test-backend` (`uv run pytest tests/`), `test-frontend` (vitest), `test-infra` (jest), SHA-pinned, `ubuntu-24.04`. This is **broader** than the proposed `supply_chain`+`architecture` subset: it runs the full backend pytest suite on PRs. **Premise change**: the "backend pytest isn't in CI" line (still repeated in research/2026-07-03.md) is now stale β€” backend pytest *is* a PR gate as of #490. +- **Reviewed-in**: reviews/2026-07-03.md β–Έ Friction + What Shipped. ### [2026-05-29] Adopt Strands `Limits` for per-invocation cost/turn caps β†’ RESOLVED β€” superseded (folded into the 2026-06-05 Strands 1.42 keystone) - **Decision**: Superseded β€” consolidated into the [2026-06-05] "Strands 1.40 β†’ 1.42 keystone bump" Open item. diff --git a/docs/kaizen/reviews/2026-07-03.md b/docs/kaizen/reviews/2026-07-03.md new file mode 100644 index 00000000..70f816ce --- /dev/null +++ b/docs/kaizen/reviews/2026-07-03.md @@ -0,0 +1,204 @@ +# Kaizen Review β€” Friday, July 3, 2026 +> Prepared 9:00am MT. Review window: June 5 – July 3, 2026 (**28 days β€” catch-up**). +> Source: research/2026-07-03.md (fresh, this morning) + review-queue.md (24 open items incl. 5 new this morning) + this window's repo activity. +> ⚠️ **Catch-up review.** No review ran for 2026-06-12 or 2026-06-19 β€” the review half of the loop went dark ~4 weeks while the product shipped four releases. This consolidates the whole backlog against the fresh research. + +## Week in Review + +The fortnight's story is that **both of our biggest open items got upstream answers**, and both are reliability/subtraction wins rather than routine currency. `bedrock-agentcore` **1.17.0 (Jul 2) fixes the SSE streaming-bridge deadlock (#482)** β€” a silent, process-wide container hang in exactly the SSE-over-Runtime pattern our inference-api runs, which keeps `/ping` green while starving every concurrent session; we sit on **1.9.1 and are exposed today**, and the upstream fix *retires* the hand-written guard we had queued. In parallel, **Strands 1.45 ships optional hook ordering** β€” a direct lever on our known "approval hook can't see through the tool-fold" hole β€” plus `Limits`, `cache_tools_ttl`, and `context_manager="auto"`. On models, the surface that looked frozen two weeks ago has three live moves: **Fable 5 was reinstated on Bedrock (Jul 1)** reversing the mid-June revocation, **Sonnet 5 went GA (Jun 30)** at a promotional $2/$10 through Aug 31, and Opus 4.8 reached GovCloud. Meanwhile the product shipped hard β€” four releases (1.0.0β†’1.0.3), the export-targets feature, a Mantle provider, and a large security/CI cluster that *included* the #490 PR pytest gate and the #518 nightly-path fix. Two loose threads: the **Nightly is still not confidently green** (research found it red through Jun 30 even after #518; a live check shows Jul 1–3 green on `main` β€” likely just-fixed, unconfirmed on `develop`), and **kaizen PRs have had zero comments for 6+ weeks** β€” no POC findings, no logged decisions. + +## Friction β€” the week's signal + +### Repeated patterns (β‰₯2 occurrences) +- **Hygiene ships when it wears a security/release label; the kaizen-tagged dep bumps don't** (6+ occurrences) β€” the Dependabot/CodeQL cluster (#487, #488, #489, #520, #521, #526), the #490 PR test-gate, deploy serialization (#525), and the #518 nightly fix all landed. But the dep-lag items the loop has recommended *Ship* since May β€” Strands, bedrock-agentcore, docling β€” are **all still at their May pins**, now drifted further (Strands 1.40β†’**1.45**, agentcore 1.9.1β†’**1.17**, docling 2.81β†’**2.109**). The team can land maintenance; it lands with a security label, not a kaizen one. + - *Hypothesis*: the kaizen bumps lacked a forcing function. That changed this week β€” the agentcore bump is now a live reliability fix (#482), not hygiene. + - *Candidate fix*: ship the **agentcore 1.17 bump first** as an incident-prevention PR (it's a subtraction + a fix), then the Strands 1.45 keystone. Both have concrete forcing functions now. +- **The review loop stopped closing** (meta, ~4 weeks) β€” no review for 06-12 or 06-19; **zero comments on every kaizen PR** (#493, #475, #447) for the sixth+ consecutive week. No POC findings to fold; ranking is EffortΓ—Impact only, no POC boost. The input half (research) kept running and merging; the decision half evaporated. + - *Candidate fix*: this catch-up review + a deliberate 2-item ship to restart cadence. If the loop can't close decisions, shrink it. + +### One-offs worth watching +- **`#490` quietly obsoleted a standing premise** β€” it added `.github/workflows/ci.yml` running `uv run pytest tests/` (+ vitest + jest) on every PR. **Backend pytest IS a PR gate now** β€” the "pytest isn't in CI / local green is the only gate" line (still repeated in this morning's research) is stale. The scoped PR-gate queue item is resolved by it. +- **Nightly on `main` vs `develop`** β€” the Jul 1–3 greens are on `main`; the develop-branch nightly last ran 2026-06-03. Research found #518 didn't fully fix the Jun 29–30 runs (a different stage β€” the ephemeral deploy/teardown β€” was implicated). Net: encouraging but unconfirmed; confirm the develop nightly before trusting it as the dep-bump gate. + +### Silence that matters +- **`duration_ms` per-tool timing** β€” carried since 2026-05-15 across every review, never shipped, never declined; context-attribution shipped without it. Sixth cycle. Forced to a decision in Retirement Candidates. +- **`oauth_required` SSE audit** β€” deferred since 2026-05-10 (revisit was 2026-05-24), flagged overdue three times, still undecided (~6 weeks). Now has *new context*: the RC's SEP-2322 Elicitation redesign is a spec-blessed pattern for exactly this flow. + +## Proposals β€” ranked + +### 1. Bump `bedrock-agentcore` 1.9.1 β†’ 1.17.0 β€” closes the SSE-deadlock (#482), retires a queued guard +- **Source**: research/2026-07-03.md β–Έ Top 5 #1 β€” https://github.com/aws/bedrock-agentcore-sdk-python/pull/563 (issue #482); 8 minors behind. +- **Surface area**: backend (`backend/pyproject.toml` + `uv.lock`; the inference-api `/invocations` SSE streaming worker; full local pytest suite). +- **Change**: bump the pin to 1.17.0; verify the asyncβ†’sync streaming-bridge fix (`put_nowait` + disconnect stop-event + source `aclose()`) is active on our `/invocations` path; adopt `async_mode` (retires the latent #452 blocking mode); drop any planned hand-written guard. +- **Subtracts**: **yes** β€” retires the queued [2026-05-22] "defensive guard against #482" work item (the fix is now upstream) and consolidates 3+ superseded agentcore bump entries. +- **Unlocks**: interactive-shell API; bearer-token integration; the A2A cap prerequisite. +- **Effort**: Med Β· **Impact**: High +- **POC findings**: not POCed. +- **Ship means**: one dep-hygiene PR bumping the pin + `async_mode`; smoke-test Memory/identity in dev; exercise an SSE-disconnect against the runtime. +- **Decline means**: we stay exposed to a silent, cross-session container hang that reports healthy β€” the worst failure class for a runtime β€” until users on unrelated sessions report stalls. +- **Recommendation**: **Ship first** β€” the rare item that is simultaneously a subtraction, a reliability fix for active exposure, and cheap. The one to land before the next incident. + +### 2. Bump Strands 1.40 β†’ 1.45 + adopt hook ordering; audit `cache_tools_ttl` / `context_manager` / `Limits` +- **Source**: research/2026-07-03.md β–Έ Top 5 #2 β€” Strands 1.41–1.45 (now the `harness-sdk` monorepo); 5 minors behind. **Supersedes** the queued [2026-06-19] 1.44 keystone. +- **Surface area**: backend (`agents/main_agent/` hooks + `BedrockModel` config + compaction; `to_bedrock_config`; `CountTokensBedrockModel`; SSE `limit_*` stop-reason). +- **Change**: bump to 1.45; adopt **optional hook ordering (#2559)** to deterministically sequence the OAuth-consent + tool-approval `BeforeToolCall` hooks through the tool-fold; adopt `Limits`. **Audit-then-decide** on `cache_tools_ttl` (can it replace custom cache-point plumbing?) and `context_manager="auto"` (can it feed our `compaction` SSE/checkpoint contract? β€” decisions.md 2026-05-18 bars a bare swap). Only breaking change 1.41β†’1.45 is Mistral (N/A). +- **Subtracts**: **yes (candidate)** β€” `Limits` retires the hand-rolled runaway guardrail; `cache_tools_ttl` may retire custom cache-point code; collapses all superseded 1.42/1.43/1.44 keystone entries + the #2635 guard into one PR. +- **Unlocks**: per-invocation token/cost caps; deterministic hook ordering (the enabler for Proposal #5's approval fix); 1h prompt caching. +- **Effort**: Med-High Β· **Impact**: High +- **POC findings**: not POCed. +- **Ship means**: spike-branch bump β†’ hook-ordering adoption β†’ `Limits` β†’ the two audits; full local pytest; watch compaction + count_tokens/context-attribution. +- **Decline means**: five-minor lag widens; the approval-fold hole stays unaddressable without hook ordering. +- **Recommendation**: **Ship** β€” after #1. The hook-ordering primitive is what makes Proposal #5 tractable. + +### 3. Model-settings refresh: reinstate Fable 5 + add Sonnet 5 (with a temperature-suppression guard) +- **Source**: research/2026-07-03.md β–Έ Top 5 #3 β€” Fable 5 reinstated Jul 1; Sonnet 5 GA Jun 30 ($2/$10 promo through Aug 31); ref-repo `NO_TEMPERATURE_MODELS` (commit 35bc3a9). **Un-withdraws** the [2026-06-12] Fable item revoked mid-June. +- **Surface area**: cross-cutting β€” inference-api model config + model-settings admin surface; `to_bedrock_config` param injection; `CountTokensBedrockModel` de-prefix (Bedrock `CountTokens` rejects `us.*` profile ids); frontend model picker. +- **Change**: re-add `us.anthropic.claude-fable-5` (US inference profile only β€” Global unstable); add Sonnet 5; add a **per-model temperature-suppression guard** (Sonnet 5 rejects `temperature` on ConverseStream). The guard also subsumes the queued [2026-05-22] Opus-4.7 temperature-omission item β€” same `to_bedrock_config` chokepoint. +- **Subtracts**: no β€” addition; justified: reverses a forced withdrawal and adds a materially cheaper capable tier. +- **Unlocks**: Fable 5 as a harness tier above Opus 4.8; Sonnet 5 as a cheaper default/agent tier (~33% under steady-state through Aug 31). +- **Effort**: Med Β· **Impact**: Med-High +- **POC findings**: not POCed. +- **Ship means**: PR adding both model ids + the temperature guard + de-prefix for any new profile id; verify context window / caching support on the model cards. +- **Decline means**: the picker stays Opus-only; we forgo the Sonnet 5 promo economics; a future Sonnet 5 add without the guard hard-fails every turn. +- **Recommendation**: **Ship** β€” the change Phil notices first as a user; the guard is a prerequisite, not optional. + +### 4. Wire configurable Bedrock Guardrails (issue #480) β€” decide in-agent vs. gateway-level in one pass +- **Source**: research/2026-07-03.md β–Έ Top 5 #5 + the [2026-06-19] #480 item β€” AgentCore Policy now enforces Guardrails at the gateway layer (https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-guardrails.html) alongside the Strands-native `guardrail_id` path. +- **Surface area**: backend (`inference_api` `BedrockModel` construction β€” `guardrail_id`/`version`, Strands already supports them) + infrastructure (optional `CDK_GUARDRAIL_ID`/`CDK_GUARDRAIL_VERSION` env vars, mirroring `CDK_ARTIFACTS_ENABLED`; and/or the PlatformStack Gateway construct + AgentCore Policy). +- **Change**: wire the in-agent optional-env-var path (zero-cost when unset), **and in the same design pass evaluate** whether one gateway-level policy (blanketing every MCP target, model-independent) is preferable to or complements per-model `guardrail_id`. Fold research #5 into this decision rather than running two tracks. +- **Subtracts**: no β€” addition (in-agent) / potential (one gateway policy vs. per-tool complexity). +- **Unlocks**: FERPA duty-of-care β€” proactive self-harm/crisis-language monitoring + staff alerting Claude's reactive layer doesn't surface; the gateway variant is model-independent enforcement the agent can't reason around. +- **Effort**: Low-Med (in-agent) / Med (gateway eval) Β· **Impact**: High +- **POC findings**: not POCed. +- **Ship means**: PR threading the two env vars into `BedrockModel` + a short ADR-note on in-agent-vs-gateway; verify guardrail *resource* region availability + SSE streaming-mode compatibility. +- **Decline means**: the filed compliance requirement (#480) stays unmet. +- **Recommendation**: **Ship** β€” strongest capability-unlock; a filed issue with two library-native paths. Rank with #1/#3 as this week's pick if bandwidth allows a third. + +### 5. Tool-approval as a first-class SSE/part state + `tool_result` source provenance +- **Source**: research/2026-07-03.md β–Έ Top 5 #4 β€” assistant-ui `eve@0.0.2` + Vercel AI SDK human-in-the-loop + NN/g State of UX 2026 (transparency is the top trust driver). +- **Surface area**: frontend + backend (tool-approval `BeforeToolCall` hook; `tool_use`/`tool_result` SSE contract; frontend tool-call card + signal store; reuses `beginContinuationStreaming`). +- **Change**: model approve/deny/denied as explicit states on the tool-use SSE pair (showing input args), render a dedicated denied state instead of a synthetic error, and auto-resume once *all* approvals in a turn resolve (the AI SDK's multi-tool condition). Separately, surface `serverName`/RAG-source provenance ("data sources used") on every `tool_result` card β€” low-lift, we already carry `serverName`/`icon` on `ui_resource`. +- **Subtracts**: **yes** β€” replaces ad-hoc synthetic-error approval handling with explicit lifecycle states. +- **Unlocks**: closes the known "approval hook can't see through the tool-fold" hole (pairs with #2's hook ordering); a top NN/g trust surface. +- **Effort**: Med Β· **Impact**: Med +- **POC findings**: not POCed. +- **Ship means**: a backend SSE-contract change + frontend card/store work; gate the auto-resume behind #2's deterministic hook ordering. +- **Decline means**: approvals stay ad-hoc; the fold hole persists; no source provenance. +- **Recommendation**: **Defer 1–2 weeks / sequence after #2** β€” genuinely valuable and directly on our known gap, but the clean version depends on the Strands hook-ordering bump landing first. + +### 6. Bump `docling` past 2.81.0 β†’ close #405 (`.txt` uploads fail) +- **Source**: review-queue.md [2026-06-05] β€” issue **#405 still open**; `requirements.lock` still pins `docling==2.81.0` (latest 2.109.0). +- **Surface area**: backend (`backend/src/apis/app_api/documents/ingestion/requirements.lock` + `requirements.txt`; cross-check `Dockerfile.rag-ingestion`). +- **Change**: bump off 2.81.x; verify `.txt` uploads succeed; close #405. +- **Subtracts**: **yes** β€” library-native bump closes an open user-facing bug. +- **Effort**: Low Β· **Impact**: Med +- **POC findings**: not POCed. +- **Ship means**: bump the lock pin, run ingestion tests, verify `.txt`, close #405. +- **Decline means**: `.txt` uploads stay rejected as "File format not allowed" (~5 weeks open). +- **Recommendation**: **Ship** β€” cleanest subtraction; ideal filler in the dep-hygiene PR. + +### 7. Wire #443 security helpers into web-sources routes β†’ close #399 + #404 +- **Source**: review-queue.md [2026-05-29] + reviews/2026-06-05.md β–Έ Proposal #2 β€” helpers shipped in #443 (June 4); **#399 and #404 both still open** ~5 weeks. +- **Surface area**: backend (web-sources crawler routes β€” swap the ownership-only guard for the shared viewer-editor check; route the outbound fetch through `validate_external_url`; bound crawl concurrency + offload blocking HTTP/parse to a thread pool). +- **Change**: adopt the shipped `apis.shared.security` helpers at the two call sites β€” fixes #404 (editors blocked on shared assistants, contradicts #113) and the SSRF half of #399; add the concurrency bound for the DoS half. +- **Subtracts**: **yes** β€” retires the bespoke ownership-only guard + hand-rolled URL checks. +- **Effort**: Low-Med Β· **Impact**: Med-High +- **POC findings**: not POCed. +- **Ship means**: one PR migrating the routes onto `apis.shared.security` + the semaphore/thread-pool fix; close #399 and #404. +- **Decline means**: shipped helpers sit unused; editors stay locked out; the DoS/SSRF vector stays live. +- **Recommendation**: **Ship** β€” precondition met over a month ago; cheapest path to closing two open issues. + +### 8. Align MCP Apps capability advertisement to `io.modelcontextprotocol/ui` β€” RC is now official +- **Source**: research/2026-07-03.md β€” **MCP Apps (SEP-1865) is now an OFFICIAL extension in the 2026-07-28 RC.** The [2026-05-29] item's freeze date is now **~3.5 weeks out**. +- **Surface area**: backend (inference-api `initialize` capability advertisement β€” currently `experimental.ui`; the `ui_resource`/`ui_tool_input_partial` SSE path). +- **Change**: advertise `io.modelcontextprotocol/ui`; reconcile the RC's "UI templates declared upfront for prefetch" model against our early-mount header-shell; diff for any tool-list-prefetch shape change. +- **Subtracts**: **yes** β€” retires the pre-standard `experimental.ui` identifier. +- **Effort**: Low-Med Β· **Impact**: Med +- **POC findings**: not POCed. +- **Ship means**: PR migrating the identifier + a draft-reconciliation note. +- **Decline means**: identifier drift; spec-conformant peers may not negotiate our UI extension post-RC. +- **Recommendation**: **Ship before 2026-07-28** or consciously accept drift β€” cheap, subtractive, and the RC clock is now concrete. + +### 9. Fix the Nightly for real β€” #518 was incomplete +- **Source**: research/2026-07-03.md β–Έ Internal Audit β€” the [2026-06-19] nightly item stays open: nightly failed daily through Jun 30 *including after* #518's path-repoint; a different stage (ephemeral deploy/teardown) is implicated. +- **Surface area**: CI β€” `nightly-deploy-pipeline.yml` (the stage after the test/install repoint). +- **Change**: pull the Jun 30 job logs (`gh run view 28436894310 --log-failed`), identify the failing step by name, patch that stage. **Live note**: Jul 1–3 nightly is green on `main` β€” confirm whether a later commit already fixed it and whether `develop` is covered before spending effort. +- **Subtracts**: no β€” hygiene; the dep-bump gate. +- **Effort**: Low Β· **Impact**: High +- **POC findings**: not POCed. +- **Ship means**: a log-dive; if the Jul 1–3 greens confirm it's fixed on develop too, close the item instead. +- **Decline means**: the dep bumps (#1/#2) land on a suite whose green status is unconfirmed on develop. +- **Recommendation**: **Confirm-or-fix** β€” 20-minute log-dive; likely already resolving, but verify develop coverage before trusting it. + +### 10. Compaction summary prompt: preserve standing/sensitive user instructions +- **Source**: review-queue.md [2026-05-29] β€” Claude Code compaction-prompt pattern. +- **Surface area**: backend (`TurnBasedSessionManager` summarization prompt). +- **Change**: add a clause instructing the summarizer to preserve standing/sensitive user instructions rather than summarize them away. (Adjacent: research flags that subagents/compaction should inherit the turn's thinking/beta config β€” a summarizer at a weaker config is a quiet quality regression; worth checking in the same PR.) +- **Subtracts**: no β€” one-clause defensive change; dovetails with the `compaction` SSE event. +- **Effort**: Low Β· **Impact**: Med +- **POC findings**: not POCed. +- **Ship means**: one-clause prompt edit + a regression assertion. +- **Decline means**: long sessions can silently drop standing user constraints on compaction. +- **Recommendation**: **Ship** β€” cheapest item; ideal bundle filler. + +> **Below the cap, deferred with a note:** +> - **Triage inference-api deploy #288** ([2026-05-15], L-MΓ—M-H) β€” *Re-scope then decide.* Gates whether #1/#2 reach the runtime; heavy deploy/CI churn landed this window (releases, #525 serialization) β€” confirm the root cause survived before building a fix. +> - **Migrate Opus 4.7 β†’ 4.8** ([2026-05-29], MΓ—H) β€” *Verify then decide.* Likely partially moot (Opus 4.8 appears to be the harness model already); Fable/Sonnet (#3) are now the live model-settings work. Confirm the current default before ranking. +> - **Interactive context-breakdown badge** ([2026-06-19], MΓ—M) β€” *Defer 1 week.* Presentation-only, validated by Cursor+LibreChat; lower urgency than the reliability/model cluster. +> - **De-risk #419 admin Gateway target registration** ([2026-06-05], HΓ—H) β€” *Defer* β€” ADR-worthy; pair with the BYO-filesystem architectural decision, don't open standalone. + +## Carried Over From Prior Reviews + +- **`oauth_required` SSE audit** (deferred 2026-05-10 until 2026-05-24) β€” **~6 weeks overdue.** *New context*: the 2026-07-28 RC's **SEP-2322 Elicitation redesign** (Multi Round-Trip Requests: `InputRequiredResult` + encoded state + retry, no held-open stream) is a spec-blessed pattern for our `oauth_required` / interrupt-resume flow. *Recommendation*: **decide** β€” either a scoped audit (now informed by SEP-2322) or a Decline to `decisions.md`. It must not carry a fourth review silently. +- **AgentCore Runtime BYO filesystem (S3/EFS)** (deferred 2026-05-15 until **2026-06-12**) β€” **3 weeks overdue.** Adjacent primitives keep accumulating (Strands native memory-port, AgentCore Managed KB GA, `strands-agents/shell` sandboxed exec). *Recommendation*: keep deferred, fold all into a **single "agent workspace / code-exec" ADR** when picked up. Revisit 2026-08-01. +- **Named A2A agent participants in the chat UI** (deferred 2026-05-15 until **2026-06-12**) β€” **3 weeks overdue.** Precondition (an A2A *server* construct) still unmet. assistant-ui `PartGroups` (this week) is the grouping primitive it would use. *Recommendation*: **Defer** until an A2A server construct lands; #1's agentcore bump unlocks the A2A cap prerequisite. + +## Retirement Candidates + +- **`duration_ms` tool-timing into `tool_result` SSE β€” DROP.** Carried since 2026-05-15 across every review, never actioned, never top-3; context-attribution shipped without it. Sixth cycle. *Recommendation*: drop to `decisions.md` ("deferred indefinitely β€” repeatedly out-prioritized; context-attribution shipped without it"). +- **Queue duplicates β€” consolidated this pass** (moved Open β†’ Resolved): the superseded Strands 1.42/1.43/1.44 keystone entries and the agentcore 1.11/1.14.1/1.15 entries (all folded into the two [2026-07-03] bumps); the [2026-05-22] #482 hand-written guard (fix now upstream in 1.17); the [2026-06-12] Fable withdraw + add entries (un-withdrawn by the [2026-07-03] reinstatement); the [2026-06-05] Cursor-only badge (folded into the [2026-06-19] badge); the shipped starlette CVE (#487); and the PR-gate item (resolved by #490). See queue update. +- **No dormant skill flagged** β€” this morning's research reviewed the skill inventory and found none dormant, *walking back* the 06-19 flag on `angualar-best-practices`/`frontend-design`. The typo'd `angualar-best-practices` (185 days) is still worth a rename, but it's **not** a confirmed retirement β€” verify usage before acting. + +## Risks Acknowledged But Not Acted On + +- **We are exposed to the AgentCore SDK #482 deadlock today** β€” https://github.com/aws/bedrock-agentcore-sdk-python/pull/563 β€” a client abandoning an SSE stream can hang the container for *all* sessions while `/ping` stays green. β€” **Address now** (= Proposal #1). +- **Sonnet 5 `temperature` rejection** β€” https://github.com/aws-samples/sample-strands-agent-with-agentcore/commit/35bc3a9 β€” adding Sonnet 5 without a per-model temperature guard fails every turn on ConverseStream. β€” **Address as part of Proposal #3.** +- **Nightly green is unconfirmed on `develop`** β€” research found #518 incomplete (red through Jun 30); live check shows Jul 1–3 green on `main` only. β€” **Address now** (Proposal #9: confirm develop coverage before trusting it as the dep-bump gate). +- **`AgentCoreMemorySessionManager` silently drops history on eventually-consistent `ListEvents` (#564)** β€” https://github.com/aws/bedrock-agentcore-sdk-python/issues/564 β€” our continuity rides the agent cache (Memory is write-only per `project_session_restore_writeonly_memory.md`), so likely shielded. β€” **Watch** β€” confirm our restore path doesn't depend on filtered `ListEvents` when the agentcore bump lands. +- **Review loop lapsed 4 weeks; zero POC/decision comments 6+ weeks** β€” the loop degrades to a write-only research log. β€” **Address now** (restart cadence with a deliberate 2-item ship). +- **Angular 21.2.17 vs 22.x major** β€” carried watch item; the gap keeps widening. β€” **Watch** β€” schedule a migration spike; don't bump reactively. + +## What Shipped This Week (28-day window) + +- **Releases 1.0.0 β†’ 1.0.3** (#506, #513, #516, #523, #529) β€” *the platform reached 1.0; four tagged releases.* +- **Export-targets / "Save to…" feature** (#507–#511) β€” *deterministic conversation export to connected apps (Google Drive adapter, folder picker, admin mapping).* +- **Amazon Bedrock Mantle provider** (#479) β€” *net-new model provider.* +- **Skills-mode + `SKILLS_ENABLED` gate** (#474, #476, #486, #497) β€” *user skills surface + chat-mode policy, then the whole feature gated off (default) for a release.* +- **Security / CodeQL / Dependabot remediation** (#487–#489, #520, #521, #526) β€” *22 HIGH Dependabot findings + CodeQL; starlette β†’ 1.3.1 (resolves the queued CVE item).* +- **CI hardening** β€” *#490 PR-time pytest gate (resolves the queued PR-gate item + obsoletes the "pytest not in CI" premise), #525 deploy serialization, #524 dead-workflow pruning, #518 nightly script-path fix.* +- **MCP Apps fixes** (#503–#505) + **external cross-account Route53** (#512) + **IAM/tool-card fixes** (#495, #501, #530, #532). + +## Take + +Both of our biggest open items got upstream answers this fortnight, which is the healthiest possible signal: the system is moving *with* the ecosystem, not patching around it. The single change that matters most is the **`bedrock-agentcore` 1.17.0 bump** β€” it deletes a guard we'd have written, closes a silent cross-session hang we're exposed to *today*, and is cheap; it's the one to ship before the next incident, independent of any nightly gate. Phil would notice **Proposal #3** first as a user β€” Fable 5 back in the picker, a cheaper Sonnet 5 tier β€” and that's the visible win to pair with it. The uncomfortable truth underneath is process, not code: the review loop went silent for a month while the product shipped four releases and a real security/CI cluster, so hygiene *does* land here β€” just never under a kaizen label. Ship **#1 + #3** this week, bundle **#6 (docling)** into the dep PR, and restart the weekly cadence, because a four-week silent gap is how a kaizen loop quietly dies. + +--- + +## Review Protocol (for Phil) + +1. Read Friction (2 min) β€” headline: "agentcore #482 deadlock fix is a subtraction we're exposed to today; Fable 5 is back + Sonnet 5 is live; review loop lapsed 4 weeks." +2. Scan Proposals β€” mark βœ… Ship / ❌ Decline / ⏸ Defer (4-6 min). **10 proposals**; recommendation: **ship #1 (agentcore/#482) + #3 (Fable/Sonnet)**, bundle #6 (docling), sequence #5 after #2, defer the rest with dates. +3. Retirement Candidates β€” drop `duration_ms` (6th cycle); the skill flags are *not* reconfirmed β€” don't retire (1 min). +4. Carried Over β€” `oauth_required` (decide, now SEP-2322-informed); BYO-filesystem + A2A both 3 weeks overdue (fold-into-ADR / defer) (2 min). +5. Risks β€” #482 exposure is the address-now item; confirm the develop nightly (1-2 min). +6. Pick 1-3 to ship. Restart cadence. + +Target: 12-15 minutes. + +## Post-review (for Phil β€” separate PRs) + +- βœ… Ship items β†’ individual feature PRs over the week. Decision logged here; implementation lives elsewhere. +- ❌ Decline items β†’ appended to `docs/kaizen/decisions.md` with reason so future research doesn't re-propose. +- ⏸ Defer items β†’ kept open in `review-queue.md` with a revisit date; surface again when due. + +This skill produces the agenda. Implementation never happens here. diff --git a/docs/kaizen/scoping/2026-07-06-managed-harness-build-vs-adopt.md b/docs/kaizen/scoping/2026-07-06-managed-harness-build-vs-adopt.md new file mode 100644 index 00000000..455a0493 --- /dev/null +++ b/docs/kaizen/scoping/2026-07-06-managed-harness-build-vs-adopt.md @@ -0,0 +1,59 @@ +# Build vs. adopt: AWS AgentCore managed **Harness** for the headless/scheduled lane + +**Date:** 2026-07-06 Β· **Status:** spike proposed (not started) Β· **Trigger:** while dogfooding scheduled runs, Phil asked whether we use the AWS AgentCore *Harness* feature (we don't β€” we use the lower-level Runtime). + +## TL;DR + +The AWS **managed Harness** (GA, [devguide/harness.html](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/harness.html)) is a config-not-code Strands orchestration loop running *inside* Runtime. We currently use the layer **below** it β€” AgentCore **Runtime** (bring-your-own container = our `inference-api`, our own loop). Our internal `apis/shared/harness/` (`run_agent_headless`) is a **naming collision**, not the AWS product. + +**Recommendation:** do **not** replace the interactive chat stack β€” it's differentiated by exactly the extension points the managed Harness forbids (`Hooks ❌`, `Choice of framework ❌`, non-agent-loop patterns ❌). **Do spike** the managed Harness as the backing for the **headless / scheduled / proactive lane** (the F1/F3 primitives we just shipped), where it's a real fit and would hand us managed memory + versioned endpoints + Step Functions composition for free. The **export-to-code** escape hatch means a "yes" is not a lock-in bet. + +## What the managed Harness is + +A managed agent loop where model / tools / skills / memory / limits are **configuration**. Runs in an isolated Firecracker microVM per session with filesystem + shell, auto observability, immutable versions + named endpoints, execution limits, context-truncation strategies, and an `InvokeHarness` Step Functions state. Powered by Strands. Inbound auth is **SigV4 or OAuth/JWT** (custom JWT authorizer β†’ OIDC discovery URL + `allowedClients`); per-user identity threading (and token-vault on-behalf-of) requires the **OAuth inbound path**. + +## Where it fits us (feature-grid reading, harness-vs-runtime) + +| Lane | Verdict | Why | +|---|---|---| +| **Interactive chat** (`inference-api`) | **Keep building (build)** | Depends on `Hooks` (OAuth-consent, tool-approval, RBAC tool-fold, quota, context-attribution), a custom loop (compaction/`session_title`/interrupted-turn/concurrent streaming), MCP Apps UI hosting, and our rich SSE contract β€” none configurable in the managed Harness. | +| **Headless / scheduled / proactive** (`run_agent_headless` + dispatcher/worker) | **Spike (adopt candidate)** | New, config-shaped, doesn't need MCP Apps UI or the full SSE vocabulary; would inherit managed memory + versioned endpoints + Step Functions. Maps directly onto the **F1 Harness** fundamental. | + +## Pros β€” what adopting unlocks (gaps we hand-build or lack) + +- **Managed memory that reads in cloud** β€” fixes the "AgentCore Memory is write-only in cloud" degradation and hands us long-term memory (semantic / summarization / user-preference / episodic, `actorId`-scoped) as config: a large chunk of the **F5** gap. +- **Model flexibility + mid-session provider switch** (Bedrock/OpenAI/Gemini/LiteLLM) by config. +- **Immutable versions + named endpoints + instant rollback** β€” ops maturity we approximate with ECR tags + `update-function-code` (the exact fragility that bit the scheduled-runs deploy). +- **`InvokeHarness` Step Functions state** β€” clean multi-step composition for proactive/scheduled pipelines (F2/F3 spine, managed). +- **Auto unified observability** (X-Ray/CloudWatch) β€” app logs weren't flowing cleanly to the runtime log group. +- **Execution limits + truncation strategies** as config vs our custom compaction. +- **Low lock-in escape hatch** β€” `agentcore export harness` generates a normal Strands Runtime agent we own and can self-host (Lambda/ECS/K8s). Adopt-to-prototype, export-when-stuck is viable. +- **Our identity model survives** β€” OAuth-inbound threads the end-user JWT and reads user-scoped tokens from the **same AgentCore Identity token vault** we already use; our headless path already mints a Cognito bearer. + +## Cons β€” what it can't do that we rely on + +- **`Hooks ❌`** β€” our OAuth-consent / tool-approval / RBAC-fold / quota / context-attribution enforcement is hook-based. +- **`Choice of agent framework ❌` / custom loop** β€” our loop's compaction, `session_title`, interrupted-turn, per-session concurrent streaming aren't configurable. +- **MCP Apps (SEP-1865) UI hosting** β€” not a Harness concept; it streams plain Converse `toolUse` frames. +- **Our SSE event contract** β€” the SPA depends on our vocabulary; full adopt = rewrite the streaming contract. +- **RBAC-resolved per-user tool sets + quota/cost** β€” Harness offers `allowedTools` globs (per-invoke) + Gateway Cedar policies; coarser than our per-user RBAC + quota tiers + cost rollups. +- **Newly GA** (past training cutoff) β€” treat maturity / pricing / quotas as spike unknowns. + +## The spike β€” three questions decide it + +Scope: prototype an `InvokeHarness`-backed path for one real schedule and answer: + +1. **RBAC β†’ `allowedTools`.** Does our per-user RBAC-resolved tool set reduce cleanly to Harness `allowedTools` glob patterns (+ Gateway Cedar policies) per invocation, without the hook-based enforcement? +2. **Per-user connector tokens.** Do our vaulted 3LO connector tokens resolve through Harness **OAuth-inbound + AgentCore Identity** (on-behalf-of), acting as the schedule owner, exactly as `run_agent_headless` does today? +3. **Acceptable losses on the headless path.** Is giving up MCP Apps UI + our SSE contract fine *for headless runs specifically* (hypothesis: yes β€” a scheduled run delivers a session, not an interactive App frame)? + +If all three clear: managed memory + ops maturity on the proactive lane **without touching interactive chat**, with export as the exit. + +## Relationship to the primitives plan + +This is the "**AgentCore Harness β€” tracked as an external-pattern scan**" line in `agentic-platform-primitives.md:10` coming due. It maps onto **F1** (headless entrypoint) and de-risks **F5** (memory). It does **not** change the "Harness owns run, Registry owns catalog+govern" framing β€” it just asks whether AWS's managed Harness can *be* our run engine on the proactive lane. + +## Refs + +- Overview: `devguide/harness.html` Β· vs Runtime: `harness-vs-runtime.html` Β· security/auth: `harness-security.html` Β· tools: `harness-tools.html` Β· skills: `harness-skills.html` Β· memory: `harness-memory.html` Β· export: `harness-export.html` +- Internal: `docs/specs/agentic-platform-primitives.md`, `docs/specs/scheduled-agent-runs.md`, `docs/specs/harness-entrypoint-spike-findings.md` diff --git a/docs/kaizen/scoping/2026-07-06-managed-harness-spike-findings.md b/docs/kaizen/scoping/2026-07-06-managed-harness-spike-findings.md new file mode 100644 index 00000000..21b860d2 --- /dev/null +++ b/docs/kaizen/scoping/2026-07-06-managed-harness-spike-findings.md @@ -0,0 +1,173 @@ +# Managed-Harness spike β€” findings (three gating questions answered) + +**Date:** 2026-07-06 Β· **Status:** spike complete (analysis) Β· **Brief:** `docs/kaizen/scoping/2026-07-06-managed-harness-build-vs-adopt.md` +**Method:** answered from (a) the now-GA AWS AgentCore **managed Harness** docs β€” authoritative on the product's capabilities, which are past training cutoff β€” and (b) our own code, which is authoritative on what the headless lane actually requires. Cross-checked against the F1 entrypoint spike (`docs/specs/harness-entrypoint-spike-findings.md`, proven live in dev-ai 2026-07-05). +**Not done:** a live `create-harness` / `InvokeHarness` deployment in dev-ai. Two of the three questions are fully closed by docs + already-proven code; the third has exactly one residual that *needs* a live probe, flagged below. That probe β€” not a full harness stand-up β€” is the only thing gating a decision. + +--- + +## Verdict at a glance + +| # | Question | Verdict | +|---|---|---| +| 1 | RBAC β†’ `allowedTools` (+ Cedar), per-invoke, without hook enforcement | **Qualified YES** β€” set membership reduces cleanly (we already snapshot it statically); three non-membership gates must relocate, none blocking for headless | +| 2 | Per-user connector tokens via OAuth-inbound + Identity vault, as schedule owner | **YES on the mechanism** (it *is* our primitive). Residual **probed live 2026-07-06 β†’ GO-with-boundary:** `customParameters` pinning is present & honored (config persists verbatim; forwarded into the same `GetResourceOauth2Token` call), but the managed **Gateway** 3LO exchange is blocked by a `ResourceOauth2ReturnUrl` wiring gap β†’ keep 3LO connectors on our own `get_token_for_user`. See **Q2 probe result** below. | +| 3 | Acceptable to lose MCP Apps UI + our SSE contract on headless | **YES** β€” the losses land entirely on interactive affordances a scheduled run has no consumer for | + +**Net:** all three clear for the headless/scheduled lane. Green-light a scoped `InvokeHarness` prototype whose one job is to close the Q2 residual; everything else is already answered. + +--- + +## Q1 β€” RBAC β†’ `allowedTools` (+ Gateway Cedar) + +**Does our per-user RBAC-resolved tool set reduce cleanly to Harness `allowedTools` globs per invocation, without hook-based enforcement?** + +### Yes for the part that matters β€” and we already produce the input + +Two facts make this cleaner than the brief feared: + +1. **`allowedTools` is settable per-invocation.** The Harness accepts `tools` / `allowedTools` overrides on a single `InvokeHarness` call (`agentcore invoke --tools …`; `client.invoke_harness(tools=…)`), not just at harness-create. So a dispatcher can compute a per-owner, per-schedule tool scope and pass it at invoke time. Supported globs cover our id shapes: `@server/tool`, `@server/glob` (`@git/read_*`), `@*/tool` across servers, plain builtin names. +2. **We already resolve the set statically, at the right boundary.** RBAC resolution is a pure function of static attributes (`AppRoleService.resolve_user_permissions` β†’ union of role `tools`, wildcard, quota tier) and it's applied at the **app-api** security boundary, not inside the agent loop: `filter_requested_tools()` (`apis/shared/rbac/service.py:217`, "narrow-never-grant"). Schedules **already snapshot the narrowed `enabled_tools`** at creation (`apis/app_api/schedules/routes.py:75–98`). The dispatcher therefore already holds the exact static allow-list; emitting `allowedTools` globs from it is a mechanical idβ†’glob translation (`gateway____` β†’ `@/`; `linear::*` β†’ `@linear/*`). + +Gateway **Cedar policies** then cover the conditional / argument-level gating that globs can't express ("who may call which tool, under which conditions, with which arguments") β€” a strict *superset* of what our up-front list filter does today. + +### What does NOT reduce to `allowedTools` β€” and where each goes instead + +The managed Harness has **no hook seam** (`Hooks ❌`). Three of our enforcement points are call-time hooks, not list membership. None blocks headless, but each must relocate: + +| Gate (our hook) | Reduces to `allowedTools`? | Disposition on the headless lane | +|---|---|---| +| RBAC tool-fold | **Yes** β€” pure static set | Emit as `allowedTools` glob per invoke (dispatcher already has the snapshot) | +| OAuth-consent (`BeforeToolCallEvent`) | No β€” token-vault check at call time | **Relocated, not lost:** the Harness's OAuth-inbound + Identity outbound *is* the on-behalf-of exchange (see Q2). Happy path is automatic; a missing/unconsented token fails the exchange β†’ map to our `paused_reauth` / `oauth_required` status. A headless run can't pop consent interactively anyway β€” same constraint as today. | +| Tool-approval HITL (`MCPExternalApprovalHook`) | No β€” needs a human decision | **Exclude approval-gated tools from the schedule snapshot** (least-surprise; matches punch-list item 7 in the F1 findings). Harness *does* offer `inline_function` (`stopReason: tool_use` β†’ client returns result) if we ever want async-approval schedules, but default = exclude. | +| Quota / cost | No β€” per-user runtime state, not per-tool | **Move to the dispatcher:** a pre-invoke quota gate (our governance floor F6a `check_input` seam already exists) + post-run cost rollup from the Harness `metadata`/usage output. Harness "execution limits" are per-harness/per-invoke config β€” coarser than our per-user tiers, so we keep owning quota. | + +> ⚠️ **Trust-boundary note the docs are emphatic about:** *all* `InvokeHarness` input is trusted β€” `allowedTools` "scopes LLM tool selection only," it is **not** a security boundary against the caller, and it does **not** gate `InvokeAgentRuntimeCommand` (direct command exec, separate IAM action β€” simply don't grant `bedrock-agentcore:InvokeAgentRuntimeCommand`). This is fine for us: on the headless lane **we are the caller** and we compute `allowedTools` from RBAC before invoking. The security boundary stays where it already is β€” the app-api dispatcher β€” exactly as `/runs/now` and `/schedules` enforce it today. We must not forward untrusted end-user input straight into `InvokeHarness` (`skills`/`model`/`additionalParams` are all caller-overridable per-invoke). + +**Q1 conclusion:** the RBAC *set* reduces cleanly and we already compute it in the right place. Cedar strengthens arg-level gating. The three non-membership gates are either automatic (OAuth via Identity), an acceptable exclusion (approval), or a dispatcher responsibility we already own (quota/cost). **Clears.** + +--- + +## Q2 β€” Per-user connector tokens (OAuth-inbound + AgentCore Identity, as schedule owner) + +**Do our vaulted 3LO tokens resolve through Harness OAuth-inbound + Identity on-behalf-of, acting as the owner, exactly as `run_agent_headless` does today?** + +### Yes β€” it is literally the same primitive, on an authorizer we already run + +The AWS security doc is unambiguous: + +- **OAuth-inbound path threads the end-user identity** through the agent "so downstream tools can call APIs with scoped user credentials instead of a shared service account." Gateway tools take an `outboundAuth.oauth` credential provider + scopes and the vault performs the on-behalf-of exchange. +- **SigV4 does NOT propagate per-user identity** β€” "per-user credential scoping … only available when callers authenticate with a Bearer JWT via the OAuth inbound path. SigV4 support for per-user identity is planned for a future release." So the harness **must** be configured OAuth-inbound. That aligns exactly with what we already do. + +Our side already matches, on three counts: + +1. **Same authorizer, verbatim.** Our Runtime is configured with `customJwtAuthorizer { discoveryUrl: , allowedClients: [BFF app client] }` (`infrastructure/lib/constructs/inference-api/inference-agentcore-construct.ts:275`). The Harness `customJWTAuthorizer` takes the identical shape. A 1:1 port. +2. **Same minted owner token.** The F1 spike already mints a real Cognito **access token for the owning user** (refresh-token grant from the BFF session / headless-grant record) and proved it threads three layers deep as the user. That is exactly the Bearer the Harness OAuth-inbound path wants; the JWT's `sub` = owner, so the run acts *as* the owner. +3. **Same outbound exchange.** `AgentCoreIdentityService.get_token_for_user` (`apis/shared/oauth/agentcore_identity.py:168`) already does `USER_FEDERATION` / `GetWorkloadAccessTokenForUserId` β†’ `GetResourceOauth2Token`, user-scoped. The F1 spike proved this unattended (minted the owner's google-drive token from the vault and listed real Drive files). The managed Harness's "AgentCore Identity outbound" is this same call, moved behind Gateway config. + +### The one residual that needs a live probe β€” `customParameters` pinning + +Our vault has a documented gotcha ([[project_agentcore_custom_parameters_vault_key]]): a token retrieval must send the **same `customParameters`** the consent flow used, or the vault key misses and it falsely reports consent-required. In our own code we pass `custom_parameters` explicitly into `get_token`. In the managed Harness, the outbound OAuth is **configured on the Gateway tool** (`credentialProviderName` + `scopes`) and the Harness/Gateway performs the exchange β€” so we lose direct call-site control over `customParameters`. + +- Providers with **no** `customParameters` (the common case β€” plain `scopes`): expected clean. +- Providers that **need** `customParameters` to hit the right vault key: **open risk.** Either Gateway outbound-auth config exposes a way to pin them, or those providers can't be driven through a Harness-managed exchange and stay on our own `get_token_for_user` path. + +**This is the single thing worth a live `InvokeHarness` probe** β€” configure one Gateway OAuth tool for a `customParameters`-sensitive provider and confirm the owner's vaulted token resolves. Everything else in Q2 is already proven. + +**Q2 conclusion:** mechanism confirmed and already ours; must configure OAuth-inbound (SigV4 can't do per-user); one live-probe on `customParameters` pinning before trusting it for vault-key-sensitive connectors. **Clears, with a named probe.** + +--- + +## Q3 β€” Acceptable losses on the headless path (MCP Apps UI + our SSE contract) + +**Hypothesis: yes β€” a scheduled run delivers a session, not an interactive App frame.** Confirmed. + +- **MCP Apps UI (SEP-1865)** is an interactive-frame concept β€” it only means anything when a human is watching the stream live and the SPA can mount the App iframe. A scheduled run has **no live viewer at emission time**. A tool that would emit a `ui_resource` still executes as a plain tool; it just renders no frame. Zero functional loss on headless. +- **Our SSE event vocabulary** is consumed **server-side** on this lane, never by the SPA. Our runner already drains the stream into a structured `RunResult` (`apis/shared/harness/sse.py` `InvocationStreamAccumulator`). The managed Harness returns a Bedrock **Converse-shaped** stream (`contentBlockStart/Delta`, `toolUse`, `stopReason`, `metadata`). Adopting it swaps our accumulator for a Converse-stream accumulator producing the **same `RunResult`**. The SPA never sees either stream β€” it loads the **delivered session** (persisted messages + title + metadata row), which the F1 spike proved the runtime turn already materializes. So the SSE-contract loss is invisible on the headless lane. + +Two affordances to carry over deliberately (both already in the F1 design): + +- **`oauth_required` as a first-class status** β€” a headless run can't pop consent; the scheduler pauses (`paused_reauth`) and surfaces the URL. Must confirm the Harness/Gateway exchange *fails legibly* (returns/streams something we can map) rather than silently erroring when a vaulted token is missing. Pairs with the Q2 probe. +- **Tool-approval** β€” no approver on a schedule β†’ excluded (Q1), not a streaming concern. + +**Q3 conclusion:** the losses are real but land entirely on interactive affordances a scheduled run has no consumer for. **Clears.** + +--- + +## Recommendation + +**Green-light a narrowly scoped `InvokeHarness` prototype** whose sole gating purpose is to close the **Q2 `customParameters` residual** (one Gateway OAuth tool, one `customParameters`-sensitive provider, owner-minted Bearer, confirm the vaulted token resolves and that a *missing* token fails legibly for `paused_reauth`). Q1 and Q3 are answered; Q1's non-membership gates have clear homes (dispatcher for quota/cost, exclusion for approval, Identity for consent). + +If the probe clears, the payoff is the brief's thesis intact: **managed memory (reads in cloud β†’ dents F5) + immutable versions/endpoints + `InvokeHarness` Step Functions composition on the proactive lane, without touching interactive chat**, with `agentcore export harness` as the low-lock-in exit. Keep the interactive `inference-api` stack exactly where it is β€” it depends on the hook seam, custom loop, MCP Apps hosting, and SSE contract the managed Harness forbids. + +### Refs +- AWS: `devguide/harness.html`, `harness-tools.html` (`allowedTools` globs, per-invoke override, inline_function, trust boundary), `harness-security.html` (OAuth-inbound vs SigV4 per-user identity, Cedar on Gateway, outbound OAuth2 credential provider). +- Internal: `docs/specs/harness-entrypoint-spike-findings.md` (F1, proven live), `apis/shared/oauth/agentcore_identity.py:168`, `apis/shared/rbac/service.py:217`, `apis/app_api/schedules/routes.py:75`, `infrastructure/lib/constructs/inference-api/inference-agentcore-construct.ts:275`. + +--- + +## Q2 probe result β€” `customParameters` pinning (live, dev-ai 2026-07-06) + +**Verdict: GO-with-boundary.** The pinning mechanism the residual asked about **exists and is honored** β€” confirmed live. But a *second*, newly-discovered boundary means vault-key-sensitive 3LO connectors should keep running through **our own `get_token_for_user` call-site path** (already proven by F1), not the Harness-managed Gateway exchange, until AWS's return-URL wiring gap is resolved. This *sharpens and reinforces* the original recommendation rather than changing it. + +**Method:** Product is real & GA (**AgentCore managed Harness, GA 2026-06-18, AWS NY Summit** β€” past training cutoff, so validated live). Called the real APIs via `boto3 1.43.9` (the backend `agentcore` extra; the system `aws` CLI 2.22.12 predates the service). Reused infra to minimise footprint: the **runtime execution role** (`dev-boisestateai-v2-agentcore-runtime-role`, trust already allows `bedrock-agentcore.amazonaws.com`), the **existing dev Gateway**, the real **`google-drive` Identity provider**, and a **purpose-minted headless-grant** owner (`18419330-…`, the F1-proven holder of a consented `google-drive` vault token). No new IAM role, no gateway-target changes. + +### What was confirmed LIVE + +1. **`CreateHarness` accepts our exact `customJWTAuthorizer`** β€” a 1:1 port of `inference-agentcore-construct.ts:275` (`discoveryUrl` = Cognito pool, `allowedClients=[BFF client]`). Harness reached `READY`. (Aside: managed memory is provisioned **by default** β€” a `managedMemoryConfiguration` appears unbidden; relevant to the brief's F5 "reads in cloud" thesis. Defaults observed: model `global.anthropic.claude-sonnet-4-6`, `allowedTools:["*"]`, sliding-window-150 truncation, `idleRuntimeSessionTimeout=900s`, `maxLifetime=28800s`, `maxIterations=75`.) +2. **`outboundAuth.oauth.customParameters` is a first-class, honored config field.** botocore contract exposes it (map, required-alongside `providerArn`+`scopes`, at **both** create-time and per-invoke `tools`). `GetHarness` **round-trips it verbatim** β€” tested with the 2-key `{prompt,access_type}` and the 3-key `{hd,prompt,access_type}` maps, persisted exactly, never dropped. **β†’ We CAN pin the same `customParameters` our consent flow uses.** This is the core residual question, answered YES. +3. **OAuth-inbound works end-to-end.** Minted the owner's Cognito access token (F1 `CognitoRefreshBearerAuth` refresh-grant), `POST /harnesses/invoke` with `Authorization: Bearer …` β†’ **HTTP 200, harness ran as the owner.** +4. **The Harness-managed exchange IS our primitive.** When the agent loads the gateway OAuth tool, the runtime calls **`GetResourceOauth2Token`** β€” the *exact* vault API `apis/shared/oauth/agentcore_identity.py:168` `get_token_for_user` and F1 already use β€” forwarding the configured `outboundAuth`. So the on-behalf-of exchange is the same call, relocated behind Gateway config (Q2's mechanism claim, confirmed at the wire). +5. **Failures surface LEGIBLY (Q3 step 5 / `paused_reauth`).** A failed exchange arrives as a **typed `runtimeClientError` exception event** in the InvokeHarness stream carrying structured JSON (`{"message": "… GetResourceOauth2Token … "}`) β€” mappable to our `oauth_required` β†’ scheduler `paused_reauth`. Not a silent error. + +### The boundary discovered live (why not a clean GO) + +I could **not** observe a clean *positive* token-resolution through the managed Gateway exchange. Every owner-scoped 3LO (`AUTHORIZATION_CODE`) attempt failed at gateway-tool init with: + +> `ValidationException … GetResourceOauth2Token … You must provide a ResourceOauth2ReturnUrl to proceed with this flow` + +I supplied the return URL through **every** documented channel and the error persisted identically: +- per-invoke `tools[].outboundAuth.oauth.defaultReturnUrl`, +- create-time (persisted) `defaultReturnUrl` (confirmed via `GetHarness` round-trip), +- the `OAuth2CallbackUrl` request header (the channel Runtime uses), +- registering the URL as `AllowedResourceOauth2ReturnUrl` on the harness's auto-created workload identity via `UpdateWorkloadIdentity` (the mechanism `oauth2-authorization-url-session-binding.html` prescribes). + +**Conclusion:** in this GA build the managed **Gateway** 3LO exchange does not source the `resourceOauth2ReturnUrl` from `defaultReturnUrl`/header/workload registration for the gateway-MCP-client-init flow β€” an undocumented-required-channel or a genuine gap. Because the flow errored *before* any cache lookup, I could not run the intended discriminator (correct `{hd,prompt,access_type}` β†’ resolves vs. `hd`-omitted β†’ consent-required). A related **unknown remains unreached past this blocker:** cross-workload token visibility β€” F1's token was consented under our **platform** workload identity, whereas the harness runs under its **own** auto-created workload identity; whether a platform-consented token is even visible to the harness workload (absent `scope-credential-provider-access`) is untested. + +### Recommendation + +- **GO** to adopt the managed Harness on the **headless/scheduled lane** (Q1+Q3 already clear; this probe adds live proof of the customJWTAuthorizer port, OAuth-inbound, and legible failure surfacing). +- **Boundary:** drive `customParameters`-sensitive (and, conservatively, *all* 3LO) connectors through **our own `get_token_for_user`** inside the run β€” the path F1 already proved unattended β€” **not** the Harness-managed Gateway `outboundAuth.oauth` exchange, until (a) the `ResourceOauth2ReturnUrl` wiring is resolved with AWS and (b) cross-workload token visibility is confirmed. Zero loss vs. today: we keep the call-site control the vault-key gotcha ([[project_agentcore_custom_parameters_vault_key]]) requires. Plain-scopes / non-3LO providers are unaffected. +- **Static-pin sub-finding stands:** our `customParameters` are admin-static per-connector (`custom_parameters_for`, `oauth/models.py`), so a static Harness config *can* carry byte-identical values β€” the pinning is expressible; only the exchange plumbing blocks it today. + +**Follow-ups worth a short AWS-support / re-probe pass:** β‘  correct channel for `ResourceOauth2ReturnUrl` on a managed Gateway 3LO exchange (or confirm the gap); β‘‘ harness-workload vs platform-workload token visibility (`scope-credential-provider-access`). **Pricing:** no separate harness charge β€” you pay only for the underlying AgentCore capabilities used (`harness.html`); note managed memory is on by default (an extra Memory cost dimension per harness). + +**Teardown:** probe harness `q2probe_cparams` deleted (its runtime + managed memory + auto-created workload identity `harness_q2probe_cparams-*` are removed with it); the reused runtime role, dev Gateway, and its `arxiv`/`policy-search` targets were never modified. + +--- + +## `resourceOauth2ReturnUrl` β€” the parameter shape (added 2026-07-07) + +The blocker above was empirical ("supplied it four ways, all failed"). The API doc for the field now gives the **mechanism-level** reason, and it *confirms* the boundary decision rather than reopening it. + +**Shape (from the AWS API reference):** + +> **`resourceOauth2ReturnUrl`** β€” The callback URL to redirect to after the OAuth 2.0 token retrieval is complete. **This URL must be one of the provided URLs configured for the workload identity.** +> Type: String Β· Length 1–2048 Β· Pattern `\w+:(\/?\/?)[^\s]+` Β· **Required: No** + +Three things fall out of this, and each is decision-relevant: + +1. **It is a direct request parameter of `GetResourceOauth2Token` itself** β€” not a harness/gateway config field. So on **our own** `get_token_for_user` call-site (`apis/shared/oauth/agentcore_identity.py:168`) we *can* pass it; on the **Harness-managed Gateway** path the Gateway is the caller of `GetResourceOauth2Token`, and β€” as the probe found β€” it exposes **no seam to inject this parameter** into that internal call (`defaultReturnUrl`, the `OAuth2CallbackUrl` header, and workload-identity registration all failed to thread through). This upgrades the boundary from "undocumented channel" to a **structural** one: the value belongs on a call we don't make on the managed path. **β†’ strengthens "keep 3LO on our own path."** +2. **`Required: No`** β€” it is only needed when the flow must actually redirect the user for a *fresh* `AUTHORIZATION_CODE` consent. An **already-consented / cached** vault token resolves without it. This is exactly why F1's pre-consented `google-drive` token worked unattended and why plain-scopes/2LO providers are unaffected β€” the return URL matters *only* at initial consent, which on our lane happens through the SPA/BFF, not inside the run. +3. **"must be one of the provided URLs configured for the workload identity"** β€” the allow-list linkage the probe attempted (`AllowedResourceOauth2ReturnUrl` via `UpdateWorkloadIdentity`) is the *right* registration mechanism, but it must be registered on **the workload identity that actually performs the exchange**. On the managed path that is the harness's **own** auto-created `harness_-*` identity β€” which ties directly into the still-unreached **cross-workload visibility** residual (a token consented under our *platform* workload identity is registered against a *different* identity than the one the harness exchange runs under). + +### harness-security.html cross-check (2026-07-07) + +Reading the harness **security** page confirmed two supporting facts and surfaced one new documentary signal: + +- **SigV4 cannot carry per-user identity β€” verbatim:** *"When callers authenticate with SigV4 (AWS IAM), the harness does not propagate per-user identity into downstream tool calls … user-scoped OAuth token storage and on-behalf-of token exchange … only available when callers authenticate with a Bearer JWT via the OAuth inbound path. SigV4 support for per-user identity is planned for a future release."* Confirms the OAuth-inbound requirement (Q2) in AWS's own words. +- **The intended pattern is on-behalf-of *exchange*, not consent *origination*.** The page describes threading an inbound user JWT into a downstream token exchange; it documents **no** interactive-consent origination from inside a run. That is consistent with `Required: No` above β€” a headless run is expected to exchange an *already-consented* token, not to start a redirect. Our decision (originate consent on our own SPA/BFF path, let the harness only exchange) is *aligned with the design*, not a workaround. +- **New documentary signal β€” the harness runs under its own workload identity.** The page's "OAuth2 credential provider (OAuth-protected Gateway)" IAM policy scopes `bedrock-agentcore:GetResourceOauth2Token` to `…/workload-identity/harness_-*`. This is written confirmation of the cross-workload residual: the harness's exchange authority is namespaced to its **own** identity, distinct from our platform workload identity where existing tokens were consented. Whether a platform-consented token is visible across that boundary (via `scope-credential-provider-access`) remains the **one** thing to test before any managed-Gateway 3LO adoption. +- **`resourceOauth2ReturnUrl` appears nowhere on the security page** β€” no 3LO return-URL / callback documentation at all. Consistent with the probe's read that the correct channel on the managed-Gateway path is undocumented (or absent) in this GA build. + +**Net:** the return-URL shape and the security-page cross-check **do not change the GO-with-boundary verdict** β€” they re-label the open question. The blocker is best understood as *structural* (the return-URL param lives on a call the managed path makes internally, against the harness's own workload identity), and the sharpest next test is **cross-workload token visibility**, not chasing the return-URL channel. Follow-up β‘  above is refined accordingly: confirm with AWS whether a managed-Gateway 3LO exchange can ever accept a caller-supplied `resourceOauth2ReturnUrl`; if not, the "our own `get_token_for_user`" boundary is permanent-by-design, not a stopgap. diff --git a/docs/specs/agent-designer.md b/docs/specs/agent-designer.md new file mode 100644 index 00000000..f130ef75 --- /dev/null +++ b/docs/specs/agent-designer.md @@ -0,0 +1,242 @@ +# Agent Designer β€” a unified authoring surface for composed Agents + +**Status:** Draft / proposal (2026-07-07) +**Author:** (drafted with Claude) +**Targets branch:** `develop` +**Supersedes:** the "extend the Assistant editor to bind a Memory Space" assumption in +[`user-markdown-memory.md`](user-markdown-memory.md) Β§B1. +**Related:** agentic-platform primitives epic F5 (memory) + F6 (registry/governance) +([`agentic-platform-primitives.md`](agentic-platform-primitives.md)); admin Skills + RBAC + tool +binding ([`admin-skills-rbac-tool-binding.md`](admin-skills-rbac-tool-binding.md)); managed-Harness +adopt-with-boundary spike ([`docs/kaizen/`](../kaizen/)); assistant sharing / collaborative editing +(issue #113); model access control (`apis/app_api/admin/services/model_access.py`); Memory Spaces +(`apis/shared/memory/`). + +## Summary + +Give users a single, first-class surface β€” the **Agent Designer** (the **Agent Harness Editor**) β€” +where they **compose an Agent** from the platform's governed primitives: system instructions, a +**model**, knowledge bases, tools, skills, **Memory Spaces**, and whatever primitives come next. The +user only ever sees the primitives (and models) **their role enables**. + +This is the UX capstone of the agentic-primitives epic. We have spent several phases shipping +**bindable primitives** β€” tools, skills, KBs, and now Memory Spaces β€” without ever building the thing +that **binds** them. Today "an agent" is approximated by two overlapping, partial records: +**Assistants** (instructions + one KB + sharing) and **Skills** (instructions + reference files + +bound tools). Neither composes the full primitive set, and Memory Spaces wanting to be "the 4th +bindable primitive" has no unified record to be the 4th binding *of*. The Agent Designer is that +record and that surface. + +The Agent **replaces the term and feature "Assistant."** The Designer is built as a **new page, +separate from the existing Assistants editor**; Assistants are retired gracefully once the Designer +reaches parity. + +--- + +## Terminology (industry-aligned, and internally precise) + +A "harness," in common usage and in AgentCore's own API (`CreateHarness`), is the **runtime** β€” the +scaffolding that runs the agent loop, dispatches tools, manages context. It is *not* the design +canvas. We keep the terms crisp: + +| Term | Role | +|------|------| +| **Agent** | The product concept (replaces "Assistant"): persona + model + bound capabilities. What a user creates and runs. | +| **Agent Harness** | The configured, runnable **definition** the runtime executes β€” bindings + run params. Matches AgentCore's `CreateHarness` artifact. | +| **Agent Designer** / **Agent Harness Editor** | The authoring UI. You *edit the harness definition*; the Harness (runtime) executes it. | +| **Registry** | The palette/catalog of what is bindable, filtered by role. | + +Our existing spec already names Workstream B "Agent / **Harness** consumption" β€” so "harness = the +runtime that consumes bindings and runs the loop" is already house usage. The Designer edits what the +harness runs. + +## The three roles (why "binding" needs its own layer) + +"Designing an agent" decomposes into three distinct jobs we have been conflating: + +- **The palette** β€” *what can I bind?* Enumerate available tools / skills / KBs / memory spaces / + models, filtered by role. **This is the Registry.** +- **The canvas** β€” *the composed agent itself:* instructions + model + bound primitives. A **record**. + Neither Harness nor Registry. +- **The engine** β€” *resolve the bindings and run the loop.* **This is the Harness** (inference-api + today). + +Binding is **authored on the canvas, governed by the Registry, resolved by the Harness.** The middle +layer β€” the Agent record with a uniform binding model β€” is the thing that has been missing. + +--- + +## Core design decisions + +### D1 β€” Own the Agent contract; federate AWS, don't build on it + +We do **not** build the composition surface on **AgentCore Registry**. Our bindable set is broader +than AgentCore natively catalogs (it indexes AgentCore-native runtimes/gateway tools; our palette +includes **Memory Spaces, app-level Skills, our KBs/vector indexes**). Forcing those into AWS's schema +is a poor fit and a lock-in to a moving target. This mirrors the **managed-Harness spike finding** +(*adopt-with-boundary*: customParameters pinning worked, but the managed Gateway 3LO path was broken, +so we kept our own token path). + +**We own a primitive-agnostic `Agent` contract and an internal catalog.** AgentCore Registry (and the +managed Harness) become **optional federated sources** behind the catalog later β€” the same "dynamic +discovery tier" idea already in the tool-search strategy. Optionality without lock-in. + +### D2 β€” Evolve the assistant store into the Agent store (no parallel table) + +The Agent **UI** is new; the Agent **data** evolves the existing assistant store (the `rag-assistants` +table) in place, adding the binding structure. Rationale: sharing/collab already works there, and a +parallel table with dual-write is migration risk we don't need. An existing Assistant **is** an Agent +whose bindings are `{ instructions, knowledge_base }`. A compatibility read-mapping presents legacy +assistants as Agents until backfilled; the new Designer writes the full shape. + +### D3 β€” Uniform binding model; the model is a governed *single-select*, not a binding + +Every capability is "just another binding," so future primitives slot in without bespoke fields. But +the **model** is singular and required (an agent runs on exactly one) β€” it stays a distinct field, not +a member of the additive `bindings[]` array (a required singleton in an array is how you get +"saved with 0 or 2 models" bugs). It flows through the **same catalog + RBAC machinery** for +discovery. + +### D4 β€” RBAC = compose existing per-primitive access, don't rebuild it + +Each primitive already owns its access rule; the catalog API's whole job is to **union and filter**: + +| Primitive | Existing access check | +|-----------|-----------------------| +| Model | `ModelAccessService.can_access_model` (`allowed_app_roles` per `ManagedModel`) | +| Tool | tool RBAC / cohort capabilities (`apis/app_api/tools/`) | +| Skill | admin-skills RBAC (`accessible_skill_ids`) | +| Knowledge base | assistant KB access | +| Memory Space | `MemorySpaceService.resolve_permission` (identity-based) | + +Five confirmations that we invent **no new access system** β€” we compose five that exist. + +### D5 β€” Design-time filter, run-time re-resolution, block-on-missing (v1) + +Filtering the picker is authoring UX. Because Agents are **shared**, every gated capability must also +be **re-resolved at run time against the *invoking* user**, not the author. If Alice (Opus access) +builds an Agent on Opus and shares it with Bob (no Opus), the Harness resolves `modelConfig` against +*Bob's* grants at invocation. **v1 policy: block with a clear message** when an invoker lacks a +capability the Agent requires (model, memory space, or restricted tool); **downgrade** is a later +opt-in. One rule for all gated bindings. + +### D6 β€” Ship consumption first; the Designer follows + +The memory-consumption payoff (Workstream B: an Agent reads/writes a bound space) depends only on the +**binding model + harness resolution**, not on the full Designer. We ship a **thin vertical slice** +first (one Agent, memory binding, harness reads it), then the broad Designer β€” protecting value +delivery and de-risking a multi-month UI build. + +--- + +## Data model + +``` +Agent { + agentId # evolves assistantId; legacy ids remain valid + ownerId + name, description, instructions + emoji?, starters[], tags[] # carried from Assistant + modelConfig: { modelId, params } # required, single-select, RBAC-gated (D3) + bindings: Binding[] # plural, optional (D3) + visibility, sharedWith: ShareEntry[] # reuse assistant sharing + collab (#113) + status, usageCount, createdAt, updatedAt +} + +Binding { + kind: 'knowledge_base' | 'tool' | 'skill' | 'memory_space' | # D3/D4 + ref: + config: { ... } # kind-specific: memory_space β†’ { access, alwaysLoad }; tool β†’ { enabledTools[] }; … +} +``` + +**Back-compat mapping (D2):** a legacy Assistant reads as +`{ modelConfig: , bindings: [ {kind:'knowledge_base', ref: vectorIndexId} ] }` plus +its instructions β€” no data loss, no flag day. + +## APIs / contracts + +- **Agent CRUD** β€” `/agents/*` (evolves `/assistants/*`; the assistants routes remain as a + compatibility alias until deprecation). SPA-facing, `Depends(get_current_user_from_session)`. +- **Bindable-primitives catalog** β€” `GET /agents/bindable?kind=model|tool|skill|knowledge_base|memory_space` + β†’ RBAC-filtered list for the caller. **The palette and the RBAC enforcement point (D4).** Fans out to + each primitive's existing list+access service and returns the union, filtered. AgentCore Registry + becomes one more source here later (D1). +- **Binding resolution** β€” the **Harness** (inference-api) reads an Agent's `bindings` + `modelConfig` + at invocation, **re-resolves each against the invoking user (D5)**, and hydrates: memory index + injection + `memory_*` tools (Workstream B), model selection, KB retrieval, tool exposure. + +Cross-package contract: backend route shapes define the API; the SPA's Agent + Binding TS interfaces +must match; SSE + binding contracts are coordinated changes. + +--- + +## Frontend β€” the Agent Designer + +A **new page**, separate from the Assistants editor. Layout: persona/instructions + a **model picker** +and a set of **binding pickers** (KBs, tools, skills, Memory Spaces), each populated from +`GET /agents/bindable?kind=…` so **the user only sees what their role enables (D4)**. Reuses the +`redesign-tokens` list-page idiom and `@angular/cdk/dialog` conventions; reuses the assistant +share/collab component for Agent sharing. "Agent Harness" terminology where it clarifies the runnable +definition. + +## Migration / Assistant deprecation + +1. Evolve the store (D2) + ship the Agent contract with the compat mapping. +2. Build the Designer to parity, then past it (tools/skills/memory binding old Assistants never had). +3. Render legacy Assistants as Agents; redirect the Assistants editor to the Designer. +4. Deprecate the "Assistant" term across UI + docs; retire the old editor. + +No big-bang: legacy ids and the compat mapping keep everything running throughout. + +--- + +## Phasing + +``` +Phase 0 This spec β€” contracts, term map, AWS-federation decision βœ… done (#590) +Phase 1 Agent record + uniform binding model + compat mapping (back-compat) βœ… done (#591, #592, + flag plumbing) +Phase 2 Bindable-primitives catalog API (Registry-lite, RBAC-composed) ← the palette (next) +Phase 3 Harness resolution: memory index injection + memory_* tools + model ← Workstream B payoff (thin slice, D6) +Phase 4 Agent Designer page (Agent Harness Editor) ← the headline UI, on P1–P2 contracts +Phase 5 Assistant deprecation + migration +Later Federate AgentCore Registry / managed Harness as catalog+run backends (D1) +``` + +**Phase 1 status (implemented).** The Agent contract (`modelConfig` + uniform `bindings[]`), +the D2 compat mapping (legacy Assistant β†’ Agent, KB binding synthesized on read), design-time +`binding_validation` composing the existing per-primitive RBAC checks (D4), and the governed +`/agents/*` surface (dark behind `AGENTS_API_ENABLED`, default off) all landed. Two Phase-1 +refinements vs this spec's sketch: (a) `knowledge_base` bindings are **synthesized on read but +rejected on write** β€” the KB index isn't user-configurable and the agent id doesn't exist at +create time, so an author-settable KB binding is meaningless until F4; (b) `modelConfig` is +**optional in storage** (absent = resolve the model exactly as today) and required only at +Designer write-time β€” no legacy assistant carries a model, so a "required singleton" invariant +would force a fabricated backfill. `tool`/`skill` binding kinds are enum-valid but **inert** +(stored, not resolved) until Phase 2/3. **The live Oliver dogfood (D6) is gated on Phase 3 +harness resolution + Memory Spaces being deployed to the target environment** β€” the binding is +storable now but nothing consumes it at invocation yet. + +**Relationship to Workstream B (memory spec):** B's binding (`memorySpaces` declarative config) now +lands as a `memory_space` **Binding** on the Agent's uniform model rather than a bespoke field. B1 = +Phase 1's memory binding kind; B2/B3 (read/write tools + index injection) = Phase 3's harness +resolution. The memory epic's Β§B1 "extend the Assistant" line is superseded by this spec. + +## Non-goals (v1) + +- No AgentCore Registry as source of truth (federated source later β€” D1). +- No **downgrade** on missing capability (block-only v1 β€” D5). +- No open plugin-registration framework for primitives yet (concrete five first; generalize on the + 2nd novel primitive β€” D3). +- No headless/A2A authoring front-end yet β€” the Agent contract is the shared substrate those lanes + can target later, but the Designer targets the interactive lane first. + +## Open questions + +- **Skill vs. Agent reconciliation** β€” Skills are a second proto-canvas (instructions + reference + files + bound tools). Do Skills become a *reusable binding bundle* you drop onto an Agent, or fold + into the Agent entity entirely? Deferred, but the Designer forces the question. +- **Per-invoker capability preview** β€” should a sharee see *why* an Agent is (or isn't) fully runnable + for them before invoking (a "you lack: Opus, space X" surface), per D5? +- **Model params governance** β€” `modelConfig.params` (temperature, max tokens, reasoning effort) are + free within an allowed model; do any params themselves warrant role-gating? diff --git a/docs/specs/agentic-platform-primitives.md b/docs/specs/agentic-platform-primitives.md new file mode 100644 index 00000000..e190d2e6 --- /dev/null +++ b/docs/specs/agentic-platform-primitives.md @@ -0,0 +1,126 @@ +# Agentic Platform Primitives β€” Enablement Plan + +**Status:** Draft / strategy + handoff (for implementation by Fable) +**Author:** Phil Merrell (drafted with Claude) +**Date:** 2026-07-05 +**Targets branch:** `develop` +**Supersedes framing of:** `docs/specs/scheduled-agent-runs.md` (was "Oliver") β€” that doc is now the *detailed design for one primitive*, not the deliverable +**Related explorations this rolls into:** +- **AgentCore Registry** β€” catalog + discovery + governance seam (`docs/specs/tool-search-token-bloat-strategy.md`, `project_skills_registry_tool_binding`) +- **AgentCore Harness** β€” the agent-execution loop (`agents/main_agent/`), tracked as an external-pattern scan; here it becomes a *headless run entrypoint* +- Per-user markdown memory (`docs/specs/user-markdown-memory.md`), Admin Skills + RBAC (`docs/specs/admin-skills-rbac-tool-binding.md`), KB sync (`docs/specs/assistant-kb-sync.md`) + +--- + +## 0. Reframe (read first) + +We are **not** shipping a chief-of-staff assistant. We are enabling the **primitive layer** underneath it, so that "Oliver" β€” and a wide range of other non-coding use cases β€” becomes **configuration on top of platform building blocks, not a bespoke feature**. + +The test for every item below is: *is this a clean, reusable primitive that any assistant / skill / schedule / agent could depend on, or is it welded to one feature?* Where a primitive is missing or welded, that's the work. + +Two in-flight explorations are the natural home for this, and they carve the space cleanly: + +- **Harness** = *how an agent turn runs*. Today a turn only runs from a live, browser-facing chat request that streams SSE. The keystone fundamental is to externalize the loop into a **trigger-agnostic, headless run entrypoint** β€” "run agent as user U with input P, return a structured result" β€” that chat, schedules, A2A, and webhooks all share. Everything proactive rides this. +- **Registry** = *how agents/tools/skills/KBs are cataloged, discovered, and governed*. It's the unifying catalog seam over every primitive, and the place audit + policy hooks attach. Deferred today, but the repository seams already exist. + +Oliver is demoted to **one validation use case among several** (Β§5). Build the primitives; prove them with a few use cases; open them to anyone. + +--- + +## 1. Primitive maturity & gap ledger + +| Primitive | Where it lives | Maturity | Reusable today? | Gap to a clean primitive | +|---|---|---|---|---| +| **Tools** (multi-protocol catalog, Gateway, per-tool enablement) | `apis/shared/tools/`, `apis/app_api/admin/tools/` | High | **Yes** | Token bloat at scale β†’ dynamic discovery (Registry) | +| **Skills** (instructions + reference files + bound tools + progressive disclosure) | `apis/shared/skills/`, `apis/app_api/*/skills/` | High | Yes, but **dark behind `SKILLS_ENABLED`** | Un-defer; cross-source tool binding (binds local tools only today) | +| **Assistants** (persona + RAG + sharing/visibility) | `apis/shared/assistants/`, `apis/app_api/assistants/` | High | **Yes** | No per-assistant tool binding (only skills have it) | +| **RBAC** (roles β†’ tools/models/skills grants, inheritance, cache) | `apis/shared/rbac/` | High | **Yes** β€” the uniform gate | Extend the grant vocabulary to new primitives (schedules, KBs) | +| **Quota + Cost** (tiers, soft/hard limits, per-token attribution) | `apis/shared/quota.py`, `apis/shared/costs/` | High | **Yes** (per-user) | No org/team rollup | +| **KB / RAG** (ingestion β†’ S3 vectors β†’ search, sync/re-index) | `apis/app_api/documents/`, `kb_sync/`, `constructs/rag*` | High engine, **welded to Assistants** | **No** β€” KB is a subordinate of an assistant (`AST#{id}/DOC#{id}`) | KB as a **first-class entity** any assistant/skill/schedule can attach | +| **Memory** (AgentCore Memory; user markdown) | `agents/main_agent/session/`, `apis/app_api/memory/`, `docs/specs/user-markdown-memory.md` | **Low** | **No** β€” AgentCore Memory is **write-only in cloud**; markdown memory is **spec-only, unbuilt** | Build the read/write user-memory primitive + user-facing CRUD | +| **Scheduled tasks** | β€” | **Missing** | No | Net-new; `sync_policies` is the template | +| **Delivery / events** | `agents/main_agent/streaming/` (SSE) | **SSE-only** | **No** β€” nothing durable off the live socket | Async result spine: run-record + notification/inbox (+ optional email/webhook) | +| **Governance: audit / guardrails / data-class / policy** | β€” (RBAC + quota exist; the rest don't) | **Missing** | No | Audit log, Bedrock Guardrails, PII/FERPA classification, declarative policy β€” attach at the Registry seam | +| **Registry** (catalog + discovery + governance) | seams: `SkillCatalogRepository`, `ToolCatalogRepository` | **Deferred**, seams present | Not yet | Stand up as the catalog backing; add audit/policy hooks | +| **Harness** (agent run loop) | `agents/main_agent/` (inference-api `/invocations`) | Runs; **not externalized** | **No** β€” only reachable via live chat | A **headless, trigger-agnostic run entrypoint** | + +**Read of the ledger:** the *knowledge and tool* primitives are strong; the *proactive, governed, multi-trigger* primitives are the hole. Three clusters are missing or welded: **(a) headless execution + delivery**, **(b) first-class KB + memory**, **(c) catalog + governance**. Those are the fundamentals. + +## 2. The missing fundamentals β€” address now + +Ranked by how much each unblocks. F1 is the keystone; without it, scheduled tasks / proactive agents / A2A-server are all separately-hacked instead of one shared seam. + +**F1 β€” Headless, trigger-agnostic agent-run entrypoint (the Harness fundamental).** +One internal primitive: *run a turn as user U, with input P and a resolved config (assistant/skill/tools/model), without a live browser session, and return a structured result (final message + tool trace + cost).* Today the loop is only reachable via cookie/Bearer chat that streams SSE to a browser; the two hard sub-parts (proven feasible by KB-sync's unattended per-user OAuth path) are **(1) authenticating an unattended caller via workload identity + explicit `user_id`**, and **(2) consuming the run server-side instead of relaying SSE to a browser**. *Unblocks:* scheduled tasks, proactive monitoring, A2A-server, webhooks, "regenerate this" background jobs, eval harnesses. **This is the single highest-leverage build.** Overlaps: this *is* the Harness exploration made concrete. ⚠️ If we expose it as A2A, `CLAUDE.md` is explicit: the first A2A server's `capabilities` **must** include `streaming=True` or clients hang ~40 min. + +**F2 β€” Async result + delivery spine.** +A headless run needs somewhere to land and a way to be noticed. Minimum viable: a **run-record** + materialize the result as a session (`ensure_session_metadata_exists` + `update_session_title` already exist) + a lightweight **notification/inbox** row. *Unblocks:* every async producer β€” scheduled tasks, KB-sync-complete, long artifacts, future webhooks/email. Today delivery is ephemeral SSE only; a disconnect loses it. Overlaps: pairs with F1; the event schema is a Registry-adjacent contract. + +**F3 β€” Scheduled trigger.** +A thin scheduler (EventBridge rate β†’ dispatcher β†’ F1) modeled on `sync_policies`: sparse "due" GSI, conditional re-arm, `paused_error`, runaway guards. It is *just one trigger* on F1 β€” deliberately thin. Detailed design in `docs/specs/scheduled-agent-runs.md`. *Unblocks:* briefings, digests, periodic re-summarize, monitoring. + +**F4 β€” KB as a first-class primitive.** +Decouple the (strong) RAG engine from Assistants: a `knowledge_base_id` independent of `assistant_id`, with its own CRUD/permissions/lifecycle, attachable by assistants **and** skills **and** scheduled runs. *Unblocks:* KB-grounded skills, a scheduled "re-summarize this corpus" agent, shared org KBs. Reuse: the ingestion/vector/sync machinery is already general; the change is the ownership model, not the pipeline. + +**F5 β€” Memory primitive.** +Finish `user-markdown-memory.md` (S3 store + DynamoDB manifest + read/write tools + consolidation) and add user-facing `/memory` CRUD. AgentCore Memory stays the write-only summary sink; the markdown layer is the read-time source of truth. *Unblocks:* any persistent, personalized agent (the thing that makes a "chief of staff" more than a prompt). Independent of F1–F3 β€” parallelizable. + +**F6 β€” Governance floor + Registry-backed catalog.** *Split into two slices, decided (Β§6-2) to sequence independently:* +- **F6a β€” Governance floor (pulled forward, foundational).** **Audit log**, a **Bedrock Guardrails** hook at inference, and a **PII/FERPA data-classification checkpoint** on what an unattended run may read/emit. This is a *foundation* concern, not a late add-on: the moment F1 runs unattended **as a user**, it touches user data with no human in the loop β€” the governance floor must exist *before* that ships. Keep it **backend-invisible** wherever possible (audit + inference-time guardrails + classification), so it adds safety without UX friction. +- **F6b β€” Registry catalog + dynamic discovery (deferred).** Swap `*CatalogRepository` behind a Registry source (already the seam) for org-wide discovery/governance and to answer tool-search token bloat (index-only discovery). Per `tool-search-token-bloat-strategy.md`, spike this **after** a Tier-1 Gateway-search measurement β€” so F6b follows, while F6a leads. + +*Unblocks:* a regulated-data story for unattended agents (F6a); org-wide discovery + governance across tools/skills/KBs/agents/schedules (F6b). Overlaps: this *is* the Registry exploration, with its governance slice de-coupled and promoted. + +## 3. Where the two explorations overlap the primitive work + +| Primitive / fundamental | Harness (execution) | Registry (catalog + governance) | +|---|---|---| +| F1 headless run entrypoint | **Is the Harness work** | run-config resolved from the catalog | +| F2 async result / delivery | run-record is a Harness output | event/result schema is a Registry contract | +| F3 scheduled trigger | a trigger *on* the Harness | schedules are catalog entries (discoverable, governed) | +| F4 first-class KB | a run can attach a KB headlessly | KBs become catalog resources | +| F5 memory | memory is loaded inside the Harness run | β€” | +| F6 catalog + governance | Harness enforces policy at run time | **Is the Registry work** | +| Tools token bloat | Harness promotes matched schemas | Registry is the dynamic-discovery index | + +Net: **Harness owns "run," Registry owns "catalog + govern."** Every primitive plugs into one or both. That's the unification Phil asked for β€” this plan is the Harness/Registry roadmap expressed as concrete primitives. + +## 4. Sequenced plan (fundamentals first, use-cases last) + +- **Phase A β€” Execution spine (F1 + minimal F2) + governance floor (F6a).** Headless run entrypoint + run-record + session materialization, **with** the audit-log + guardrails + data-classification floor wired in from the start (because A is the first time we run unattended *as a user* β€” the floor can't be retrofitted after user data is already flowing). **Validation:** a "Run now" action that executes a prompt as a user off the live socket, passes the governance checkpoint, and lands a result. This is the spike that de-risks everything. +- **Phase B β€” Scheduled trigger (F3).** Scheduler on top of A. **Validation:** a schedule fires unattended, is governed, and delivers. +- **Phase C β€” Knowledge & memory (F4 βˆ₯ F5).** Independent, parallelizable; each stands alone. +- **Phase D β€” Registry catalog + dynamic discovery (F6b).** Registry-backed catalog; also resolves tool-search token bloat. Follows the Tier-1 Gateway-search measurement. +- **Cross-cutting:** cohort gating via a new RBAC capability + a global kill-switch flag (house style), so each primitive ships to prod but scoped, then GAs by granting the capability to the default role β€” no redeploy. **UX bar:** the foundation buys flexibility, but user-facing surfaces stay minimal β€” governance is backend-invisible, scheduling is a few fields, defaults are sane. + +## 5. Validation use cases (Oliver is just one) + +The primitives are proven β€” and dogfooded β€” by pointing a few use cases at them: + +- **Oliver** β€” proactive chief-of-staff: scheduled morning briefing (F1+F2+F3), persona + reference files (Assistants + F4/F5), connector tools. The showcase, not the deliverable. +- **Scheduled corpus re-summarize** β€” a KB-grounded agent that digests a document set weekly (F3 + F4). +- **Weekly status digest** β€” summarize a user's own activity into a shareable update (F1 + F2). +- **Inbox / meeting-prep triage** β€” connector tools + memory on a cadence (F1 + F5 + tools). +- **Proactive monitoring** β€” a schedule that checks a condition and only notifies on change (F2 + F3). + +The point: once F1–F6 exist, each of these is **configuration**, not engineering β€” which is exactly the dogfooding flywheel. + +## 6. Decisions & open questions + +**Resolved (2026-07-05 β€” build to these):** + +1. βœ… **Harness surface (F1) = minimal internal function, A2A-ready.** Build `run_agent_headless(...)` as an internal primitive first; keep the seam clean so an A2A server / runtime endpoint can front it later without a rewrite. If that A2A surface lands, its `capabilities` **must** include `streaming=True` (`CLAUDE.md`). +2. βœ… **Governance is foundational, split from discovery.** Pull the **F6a governance floor** (audit / guardrails / PII-classification) forward into Phase A β€” it gates the first unattended-as-user run and can't be retrofitted. Keep **F6b Registry discovery** deferred to Phase D (after the Tier-1 Gateway-search measurement). Prioritize a flexible foundation; keep user-facing surfaces minimal and governance backend-invisible. + +**Resolved at the spike decision gate (2026-07-05, post-`harness-entrypoint-spike-findings.md`):** + +3. βœ… **Act-as-user auth policy** *(new β€” surfaced by the spike).* The preferred workload-token front-door path is **provably impossible** (gateway JWT authorizer can't parse the opaque workload blob; SigV4 refused once a JWT authorizer is configured). Chosen path: **platform mints a per-owner Cognito access token** so every downstream layer sees a genuine user token. This means the platform can act as a user unattended for the token's backing lifetime. **Decision:** accept for Phase A, **but gate it behind an explicit "headless-grant" record** (own consent + revocation + lifecycle), not a silent replay of the BFF session β€” with a documented "must have logged in within N days for the platform to act as you" policy (default β‰ˆ the BFF 30-day cap). This is the FERPA-defensible form. +4. βœ… **F6a floor = role/auth-based, not content-scrubbing** *(revised 2026-07-05).* A headless run executes **as the owner, with the owner's RBAC**, and delivers **only to the owner's own session list** β€” so it crosses no new access boundary and introduces no new recipient. Governance is therefore the controls we already have: **RBAC (run-as-user)** + the **grant lifecycle** (consent/revocation/N-days β€” the real FERPA control) + **quota/runaway guards** + a **fail-closed audit** floor. A PII/content-classification pass adds nothing for the deliver-to-self model, so it is **not** a scheduled-runs prerequisite; `check_input`/`classify_output` stay **dormant seams**, relevant only if a run ever delivers to a *different recipient* (e.g. the deferred email feature), governed there. (Supersedes the earlier "PII checkpoint required before scheduled runs" draft.) +5. βœ… **KB decoupling (F4) β€” defer.** Let scheduled runs **borrow an assistant's KB** for now; first-class KB is not on the Phase A critical path (the spike confirms F1 doesn't need it). Revisit in Phase C. +6. βœ… **Sequencing β€” proactive-spine-first confirmed.** F1β†’F2β†’F3 leads; knowledge/memory (F4/F5) proceed in parallel. + +--- + +## Appendix β€” what changed from the Oliver framing + +The prior draft (`scheduled-agent-runs.md`, nΓ©e "Oliver β€” Proactive Chief-of-Staff") specified a *feature*. This doc reframes it as a *primitive-enablement plan*: the scheduling engine is one fundamental (F3) on top of a bigger keystone (F1, the headless Harness entrypoint), the assistant persona is one validation use case (Β§5), and the whole effort is expressed as the concrete roadmap for the Harness and Registry explorations. Deliver the building blocks; let Oliver β€” and everything like it β€” fall out as configuration. diff --git a/docs/specs/assistant-kb-sync.md b/docs/specs/assistant-kb-sync.md new file mode 100644 index 00000000..80c9c057 --- /dev/null +++ b/docs/specs/assistant-kb-sync.md @@ -0,0 +1,432 @@ +# Assistant Knowledge Base Sync (Scheduled Re-Index) + +Status: DRAFT β€” design spec, not yet built +Audience: backend + infra + frontend +Related: `docs/specs/` siblings; provenance fields added in the file-source import work + +## 1. Problem + +Assistant knowledge bases are indexed once at import time. Web content and Google +Drive files drift out of date, and today the only remedy is manual re-import. +Users creating or editing an Assistant should be able to mark a content source +as **synced**: refetched and reindexed on an interval they choose. + +The dominant design constraint is **preventing runaway background work**: + +- No sync may outlive its assistant, its document, or its source. +- No sync should keep burning embeddings for an assistant nobody uses. +- No sync should retry a permanently-broken source (deleted Drive file, dead + site, revoked OAuth grant) forever. +- No unchanged content should ever be re-chunked or re-embedded. + +Every mechanism below is chosen so that the *default failure mode is silence*, +not repetition. + +## 2. Current state (what we build on) + +| Piece | Where | Relevance | +|---|---|---| +| Document provenance | `backend/src/apis/app_api/documents/models.py:13-27` β€” `source_connector_id`, `source_adapter_key`, `source_file_id`, `source_etag`, `imported_by_user_id` | Captured at import explicitly to enable re-index; sync consumes these | +| Ingestion pipeline | S3 `ObjectCreated` on `assistants/` prefix β†’ rag-ingestion Lambda (`apis/app_api/documents/ingestion/handler.py`) β†’ Docling chunk β†’ Titan embed β†’ S3 Vectors keys `{doc_id}#{i}` | Re-staging a fetched file to the **same S3 key** re-runs the whole pipeline unchanged | +| Web crawls | `CrawlJob` at `PK=AST#{id}, SK=CRAWL#{crawl_id}`, bounded settings (depth ≀3, pages ≀100, concurrency ≀5) | Re-crawl = re-run with stored settings; bounds already enforced | +| Drive adapter | `apis/app_api/file_sources/adapters/google_drive.py`, tokens via AgentCore Identity vault | `download(file_id)` is the refetch primitive | +| Deletion lifecycle | Soft-delete + TTL + fire-and-forget cleanup (`documents/services/cleanup_service.py`) | Sync must join this cascade | +| Scheduling infra | **None.** No EventBridge, SQS, Step Functions, or cron Lambdas anywhere in `infrastructure/lib/constructs/` | Trigger mechanism is green-field | + +## 3. Design overview + +Three pieces, one trigger: + +``` +EventBridge rate(15 minutes) ──► Sync Dispatcher (Lambda, small) + β”‚ query DueSyncIndex (next_sync_at <= now) + β”‚ per policy: verify liveness, apply guards + β–Ό + Sync Worker (async-invoked Lambda, rag-ingestion-class image) + β”‚ Drive: metadata check β†’ conditional download β†’ stage to same S3 key + β”‚ Web: conditional re-crawl β†’ diff page set + β–Ό + Existing S3-event ingestion pipeline (unchanged) +``` + +A new **SyncPolicy** record is the single source of truth for "this source +resyncs." There are no per-source schedules, no timers, no self-perpetuating +jobs. The *only* thing that ever initiates sync work is the dispatcher reading +`DueSyncIndex` β€” delete the record and the sync ceases to exist. This +one-trigger architecture is the primary runaway defense: there is nothing to +orphan except a DynamoDB item, and that item is inert data. + +### Why a sweeper, not per-source EventBridge Scheduler entries + +Per-source schedules (EventBridge Scheduler one-per-policy) look natural but +are exactly the runaway shape we fear: cloud-side timer objects whose lifecycle +must be kept in perfect distributed agreement with app-side records, with +orphans firing forever when a delete path misses one. A single `rate(15 min)` +rule + a due-time GSI keeps all state in the table we already own, inside the +assistant's partition, covered by the existing delete cascade. Interval +granularity of ~15 minutes is far finer than any interval we offer (Β§5). + +## 4. Data model + +### SyncPolicy record (new item type, existing assistants table) + +``` +PK = AST#{assistant_id} +SK = SYNCPOL#{policy_id} policy_id: syn-{12-hex} +``` + +```python +class SyncPolicy(BaseModel): + policy_id: str + assistant_id: str + source_type: Literal["web_crawl", "drive_file"] + # web_crawl: crawl_id of the CrawlJob whose settings we re-run + # drive_file: document_id of the imported doc (provenance lives there) + source_ref: str + interval: Literal["daily", "weekly", "monthly"] # bounded enum, no cron + state: Literal["active", "paused_error", "paused_inactive", + "paused_reauth", "paused_user"] + next_sync_at: str # ISO 8601; drives DueSyncIndex + last_sync_at: Optional[str] + last_result: Optional[Literal["changed", "unchanged", "failed", "skipped"]] + consecutive_failures: int = 0 + consecutive_not_found: int = 0 # source-gone counter (distinct from transient failure) + created_by_user_id: str # whose credentials background fetches use + created_at: str + updated_at: str +``` + +### DueSyncIndex (new GSI on assistants table, GSI4) + +``` +GSI4_PK = "SYNCDUE" (constant; single logical partition β€” fine at our scale, + shard to SYNCDUE#{0..N} later if ever needed) +GSI4_SK = {next_sync_at}#{policy_id} +``` + +GSI4 attributes are **only present while `state == "active"`** β€” pausing a +policy removes it from the index (sparse GSI), so the dispatcher physically +cannot see paused work. Paused β‰  filtered-at-query-time; paused = invisible. + +### Document additions + +```python +content_hash: Optional[str] # sha256 of last-ingested raw bytes +last_synced_at: Optional[str] +sync_policy_id: Optional[str] # back-pointer for UI badges +``` + +### Assistant additions + +```python +last_used_at: Optional[str] # bumped (throttled, ≀1 write/day) on chat use +``` + +## 5. Scheduling + +- **Trigger**: one EventBridge rule, `rate(15 minutes)`, on a new small + dispatcher Lambda (`backend/src/lambdas/kb-sync-dispatcher/`). CDK construct + under `infrastructure/lib/constructs/rag-ingestion/` alongside the existing + ingestion construct. +- **Intervals offered**: Daily / Weekly / Monthly (plus "Manual only" = no + policy). Enum, not cron β€” the floor (24 h) is a hard server-side bound, and + a bounded enum can't express "every minute." +- **Dispatcher tick**: + 1. Query `DueSyncIndex` for `GSI4_SK <= now`, limit **20 per tick** (global + throughput cap; backlog just waits for the next tick β€” degradation is + lateness, never amplification). + 2. For each due policy, run the liveness + guard checks (Β§7). Guards that + fail transition the policy state (which removes it from the GSI) β€” they + do not reschedule-and-retry. + 3. **Re-arm before work**: set `next_sync_at = now + interval` *first*, then + async-invoke the worker. A crashed worker means one missed sync, not a + hot loop; a double-fired dispatcher tick is idempotent because the + re-arm is a conditional update on the old `next_sync_at`. +- **Fan-out**: direct async Lambda invoke of the worker, one per policy, ≀20 + per tick. No SQS for v1 (nothing else in the stack uses it); if scale ever + demands it, the dispatcherβ†’worker seam is where a queue slots in. + +## 6. Execution (worker) + +Worker is a Lambda sharing the rag-ingestion image class (needs the +file-source adapters and crawler; lives with them in +`apis/app_api` packaging like the existing ingestion handler β€” same import- +boundary posture as today's ingestion Lambda). + +### 6.1 Drive file (`drive_file`) + +1. Load document; resolve provider token for `created_by_user_id` from the + AgentCore Identity vault. Verified call chain (no live user session + needed): `GetWorkloadAccessTokenForUserId(workloadName, userId)` β€” pure + IAM/SigV4 authorization, no user JWT β€” then + `GetResourceOauth2Token(USER_FEDERATION)`; when a valid refresh token is + vaulted, AgentCore documented-behavior skips federation and returns an + access token non-interactively. Reuse + `apis.shared.oauth.agentcore_identity.AgentCoreIdentityClient` verbatim + (its mint fallback is exactly this path), and + `custom_parameters_for(provider_type, ..., force_authentication=True)` β€” + customParameters are part of the vault key; a mismatched map falsely + reports consent-required. +2. **Metadata-first change detection**: Drive `files.get` with + `fields=id,mimeType,trashed,modifiedTime,version,md5Checksum,size`. + - **Binary files**: compare `md5Checksum` (exact content identity). + - **Native Docs/Sheets** (exported to .docx/.xlsx): `md5Checksum`, + `sha256Checksum`, and `headRevisionId` are **not populated** for + editor files β€” use `modifiedTime` as the primary signal (`version` is + a strict superset but bumps on comments/metadata β†’ false-positive + downloads). + - `trashed == true` is a **200, not an error**: treat as + `last_result=skipped` (grace state β€” recoverable from trash; do not + delete our copy, do not count as a failure). + - If unchanged vs `source_etag` β†’ record `last_result=unchanged`, done. + No download, no embed. +3. If changed: `adapter.download(source_file_id)` β†’ compare sha256 to + `content_hash` (export formats can differ byte-wise even when content + didn't; hash is the second gate). If equal β†’ `unchanged`, done. +4. Stage bytes to the **existing S3 key** for the document β†’ S3 event β†’ + existing ingestion pipeline re-chunks and re-embeds, overwriting vectors + `{doc_id}#{0..n}`. +5. **Shrinkage cleanup**: before staging, snapshot old `chunk_count`; after + ingestion completes with new count `m < n`, delete vectors + `{doc_id}#{m..n-1}`. (Without this, in-place overwrite strands stale + tail chunks β€” the one real gap in "reuse the pipeline as-is." Implemented + as: worker stashes `previous_chunk_count` on the document record; the + ingestion handler's completion step deletes the tail if present.) +6. Update `source_etag`, `content_hash`, `last_synced_at`, `last_result`. + +### 6.2 Web crawl (`web_crawl`) + +1. Load the referenced `CrawlJob`; re-run the crawler with its **stored + settings** (bounds already capped: ≀3 depth, ≀100 pages, ≀5 concurrency). +2. Per fetched page, conditional GET (`If-None-Match`/`If-Modified-Since`) + where the server supports it; otherwise fetch + content-hash compare. + Only changed pages are re-staged/re-embedded. +3. Diff the page set against existing docs for that crawl root: + - **New URLs** β†’ new documents (respecting the max_pages cap β€” the cap + applies to the crawl total, so a synced crawl can never grow past it). + - **Missing URLs** β†’ increment a per-doc `consecutive_misses`; delete the + document (existing soft-delete + cleanup cascade) only after missing in + **2 consecutive** sync runs. A transient outage must not vaporize a + knowledge base. +4. Record results on the policy. + +### 6.3 Concurrency & re-entrancy + +A policy is skipped if its previous run hasn't finished: the worker sets +`sync_run_started_at` on the policy at start and clears it at end; the +dispatcher skips policies with a fresh (<2 h) run-start stamp. Older stamps +are treated as crashed runs and cleared. Combined with the per-tick cap of 20 +and per-crawl concurrency of ≀5, worst-case parallel fetch pressure is bounded +and known. + +## 7. Runaway-process safeguards (the point of this spec) + +Layered, so any single failure of discipline is caught by another layer: + +1. **Single trigger, inert state.** Only the dispatcher initiates work, only + by reading the GSI. There are no timers, queues, or schedules to orphan. + Deleting the policy record is total and instantaneous revocation. + +2. **Lifecycle tie via liveness check.** Every dispatch re-verifies, in order: + assistant exists β†’ source document / crawl job exists (and isn't + `deleting`) β†’ policy state is `active`. Any miss β‡’ the policy is + **hard-deleted on the spot** (self-healing against any delete path that + forgot the cascade). Additionally, the assistant delete cascade and the + document delete path both delete associated `SYNCPOL#` records eagerly β€” + the liveness check is the backstop, not the mechanism. + +3. **Inactivity auto-pause.** If `assistant.last_used_at` is older than + **30 days** (config: `KB_SYNC_INACTIVITY_PAUSE_DAYS`), the dispatcher + transitions the policy to `paused_inactive` instead of running it. This is + the direct answer to "reindexing content that isn't being used." + **Auto-resume**: the app-api path that bumps `last_used_at` also flips any + `paused_inactive` policies back to `active` with `next_sync_at = now` β€” + the first person to use a dormant assistant gets a fresh index within a + tick, and nobody has to know a pause happened. + +4. **Failure circuit breaker.** Failures back off exponentially + (`interval Γ— 2^consecutive_failures`, capped at 30 days). At + **5 consecutive failures** the policy transitions to `paused_error` + (out of the GSI, no further attempts) and the UI shows a badge with the + last error; resume is an explicit user action. + +5. **Source-gone fast path.** Drive returns **404 `notFound`** both when a + file is permanently deleted and when the user merely lost access β€” + deliberately indistinguishable (anti-enumeration). So: 404 `notFound` + increments `consecutive_not_found`; at **2**, the policy goes to + `paused_error` with reason "source no longer accessible" (worded as + *accessible*, not *deleted* β€” we cannot know which). We never delete the + already-indexed content on 404; the user decides. `trashed=true` (a 200) + is a grace state, not a strike. Discriminate Drive errors on + `error.errors[0].reason`, **never on bare HTTP status**: 403/429 with + `usageLimits` domain (`rateLimitExceeded`, `userRateLimitExceeded`) are + transient β†’ backoff-retry, not strikes; 403 `domainPolicy` (Workspace + admin disabled Drive apps) pauses connector-wide, not per-policy. + +6. **Auth-gone fast path.** Two distinct signals, both β‡’ immediate + `paused_reauth`: + - **Vault says re-consent**: `GetResourceOauth2Token` returns **HTTP 200 + with an `authorizationUrl` instead of an `accessToken`** (not an + exception) β€” surfaces as `TokenResult.requires_consent` in our wrapper. + Covers refresh-token expiry/revocation AgentCore can detect. The worker + must reuse the existing `_ShortCircuitPoller` pattern or the SDK will + sit in its consent poll loop. + - **Provider-side revocation AgentCore can't see**: the vault can return + a token Google then rejects with 401 at the Drive API (documented + AgentCore limitation) β€” our adapter already maps this to + `FileSourceAuthError`. + Never retried on a timer β€” only a fresh consent resumes it: the + `complete_consent()` success path in app-api + (`apis/app_api/connectors/routes.py:379`, right after + `complete_resource_token_auth`) flips `paused_reauth` β†’ `active` for that + `(user, provider)`'s policies. Infra-class failures + (`WorkloadTokenUnavailableError`, `AccessDeniedException`, + `ThrottlingException`) are operator errors: alert via metrics, retry with + backoff, do **not** pause the user's policy. + +7. **Change detection before cost.** Metadata check β†’ conditional GET β†’ + content hash, in that order. The expensive tail (Docling + Titan embed) + runs only when bytes actually changed. An unchanged corpus on a daily + sync costs one metadata call per source per day. + +8. **Bounded configuration surface.** Interval is an enum with a 24 h floor; + crawl settings are the already-capped stored settings; **≀ 10 sync + policies per assistant** (config: `KB_SYNC_MAX_POLICIES_PER_ASSISTANT`); + dispatcher processes ≀ 20 policies/tick globally. Every knob has a + ceiling; no user input can express unbounded work. + +9. **Observability.** Worker emits CloudWatch metrics: + `KBSync/RunsStarted`, `RunsChanged`, `RunsUnchanged`, `RunsFailed`, + `PoliciesPaused` (by reason), `EmbeddingChunksWritten`. One alarm: + `RunsFailed` sustained high, and an anomaly alarm on + `EmbeddingChunksWritten` (the "we are somehow re-embedding the world" + tripwire). Each policy keeps `last_result` + timestamps so the UI never + needs CloudWatch. + +10. **Global kill switch.** `KB_SYNC_ENABLED` env var on the dispatcher + (default true). Flipping it off stops all sync activity in ≀ 15 minutes + with zero data changes β€” policies simply go stale until re-enabled. + +## 8. API & UX + +### API (app-api, all `Depends(get_current_user_from_session)`, owner-gated) + +``` +POST /assistants/{id}/sync-policies {source_type, source_ref, interval} +PATCH /assistants/{id}/sync-policies/{policy_id} {interval? , state?} # pause/resume/change interval +DELETE /assistants/{id}/sync-policies/{policy_id} +GET /assistants/{id}/sync-policies +POST /assistants/{id}/sync-policies/{policy_id}/run-now # manual trigger; rate-limited 1/10min/policy +``` + +`run-now` sets `next_sync_at = now` (state must be `active` or resumable) and +lets the normal dispatcher path pick it up β€” even manual sync flows through +the single trigger, preserving every guard. + +### Assistant form UX + +Per content-source row (Drive file, web crawl root): + +- **"Keep in sync" control**: `Manual only β–Ύ | Daily | Weekly | Monthly` + (Manual only = default; selecting an interval creates the policy). +- **Status line** under the row: `Synced 2h ago Β· next Tue` / + `Paused β€” source not found` / `Paused β€” reconnect Google Drive` / + `Paused β€” assistant inactive (resumes on next use)`. +- **Sync now** button (calls `run-now`), disabled while a run is in flight. +- Paused-error rows get the existing error-badge treatment from document + status rows. + +Device-uploaded files show no sync control (no source of truth to refetch). + +## 9. Resolved questions (research pass, 2026-07-03) + +All five former open questions are resolved; answers below are grounded in +AWS AgentCore Identity docs, Google Drive API docs, and code inspection. + +1. **Offline token retrieval β€” RESOLVED: feasible with existing machinery.** + `bedrock-agentcore:GetWorkloadAccessTokenForUserId` takes only + `{workloadName, userId}` and is authorized purely by IAM/SigV4 β€” no user + JWT. With that workload token, `GetResourceOauth2Token(USER_FEDERATION)` + returns the stored token non-interactively when a refresh token is + vaulted ("If a valid refresh token is stored, AgentCore skips the user + federation flow and directly returns a new access token" β€” identity + devguide). Our `apis.shared.oauth.agentcore_identity` mint fallback IS + this path and is reusable from the sync Lambda as-is. + **Worker Lambda requirements**: + - IAM: `bedrock-agentcore:GetWorkloadAccessTokenForUserId` + + `bedrock-agentcore:GetResourceOauth2Token` (mirror the app-api + `AgentCoreWorkloadIdentityAccess` statement in + `infrastructure/lib/constructs/app-api/app-api-iam-grants.ts`, minus + provider-CRUD and `CompleteResourceTokenAuth`). Optionally constrain + with the `bedrock-agentcore:userid` condition key. + - Env: `AGENTCORE_RUNTIME_WORKLOAD_NAME` = the shared platform workload + identity (same SSM value app-api/inference-api use β€” a different + workload sees an empty vault) and `AGENTCORE_LOCAL_OAUTH_CALLBACK_URL` + (our wrapper requires it even for pure reads). + - Must pass the exact consent-time `customParameters` + (`custom_parameters_for(..., force_authentication=True)`) and the exact + Cognito `user_id` β€” both are vault-key components. + - **Operational caveat**: Google OAuth clients in *Testing* publishing + status issue 7-day refresh tokens β€” a background sync would hit + `paused_reauth` weekly until the client is Published/verified. Also: + refresh tokens die after 6 months of non-use and can be silently + evicted past ~50 live tokens per user per client. + +2. **Drive loss-of-access semantics β€” RESOLVED (design Β§7.5 updated).** + 404 `notFound` deliberately conflates "deleted" with "unshared from this + user" β€” no API call distinguishes them. Owner-trashed files are a 200 + with `trashed=true` (content still readable) β†’ grace state. Change + detection must be per-file-type: `md5Checksum` for binaries; + `modifiedTime` for native editor files (checksums/headRevisionId are not + populated for them). Error discrimination is by `reason` string, never + bare HTTP status (rate-limit 403s look like permission 403s otherwise). + +3. **Shrinkage-cleanup stash β€” RESOLVED: safe.** After the initial + `put_item` at creation, the ingestion pipeline only ever issues targeted + `UpdateExpression` writes on known fields (status, updatedAt, chunkCount, + vectorStoreId, error fields) β€” `ingestion/status.py:21-108`. A + `previous_chunk_count` attribute stashed by the worker survives + ingestion untouched. Final `chunk_count` is written at `mark_embedding` + (`handler.py:288`); `mark_complete` doesn't touch it β€” so the worker must + snapshot the old count *before* staging the new S3 object. Also + confirmed: overwriting a complete document's S3 key simply re-triggers + ingestion with no reprocessing guard in the way β€” the reuse-the-pipeline + plan works with zero pipeline changes beyond the tail-delete hook. + +4. **Whose policies pause on reauth β€” DECIDED: keyed to + `created_by_user_id`, acceptable for v1 with an escape hatch.** Any + editor can delete a paused policy and recreate it (or re-import the + file), which re-keys it to their own credentials. No credential + "takeover" PATCH in v1. The resume hook attaches at + `complete_consent()` success in `apis/app_api/connectors/routes.py:379`. + +5. **Shared assistants β€” DECIDED, with one spec correction.** SHARE records + already carry `permission: viewer|editor`, and editors can already + upload/import documents (`documents/routes.py:44-60` + `_require_edit_permission`) β€” sync-policy CRUD uses the same helper, so + **editors manage sync policies**, consistent with the document surface. + Sync always runs on the policy creator's credentials (same posture as + import's `imported_by_user_id` today). **Correction**: `usage_count` is + never incremented anywhere and no `last_used_at` exists β€” the inactivity + pause needs a net-new bump: app-api, on the chat path where a session + resolves its assistant, throttled to ≀1 write/day per assistant, bumping + `last_used_at` and flipping any `paused_inactive` policies back to + `active` (Β§7.3). Any user's use counts, not just the owner's. + +## 10. Suggested PR breakdown + +1. **PR-1 data**: SyncPolicy model + repository, GSI4, document/assistant + field additions, delete-cascade integration, unit tests. +2. **PR-2 infra**: EventBridge rule + dispatcher Lambda construct + worker + Lambda construct, kill switch, metrics/alarms. (platform.yml deploy) +3. **PR-3 worker**: Drive-file sync path incl. change detection + shrinkage + cleanup + all pause transitions. (backend.yml deploy) +4. **PR-4 worker**: web re-crawl path incl. page-set diff + 2-miss deletion. +5. **PR-5 API**: sync-policy CRUD (owner + editor via + `_require_edit_permission`) + run-now + reauth resume hook in + `complete_consent()` + **net-new** `last_used_at` bump (throttled) on the + app-api chat path with the `paused_inactive` auto-resume. +6. **PR-6 frontend**: assistant-form sync controls + status lines + badges. + +PR-1..2 are inert (nothing schedules until a policy exists and the flag is +on); each subsequent PR is independently shippable behind `KB_SYNC_ENABLED`. diff --git a/docs/specs/harness-entrypoint-spike-brief.md b/docs/specs/harness-entrypoint-spike-brief.md new file mode 100644 index 00000000..5af1b798 --- /dev/null +++ b/docs/specs/harness-entrypoint-spike-brief.md @@ -0,0 +1,63 @@ +# Spike Brief β€” Headless Agent-Run Entrypoint (F1) + Phase A Design + +**For:** Fable 5 +**Status:** Handoff brief β€” spike, not production +**Author:** Phil Merrell (drafted with Claude) +**Date:** 2026-07-05 +**Parents:** `docs/specs/agentic-platform-primitives.md` (F1, F2-minimal, F6a) Β· `docs/specs/scheduled-agent-runs.md` Β§4.2 (the risk) Β· `reference_dev_ai_aws_data` (dev-ai account) + +--- + +## Why you're doing a spike first + +The whole primitive layer (scheduled runs, proactive agents, A2A) rests on **one** capability that doesn't exist yet and is genuinely uncertain: **running an agent turn as a specific user, with no live browser session, and capturing the result.** Everything else in the plan is CRUD and pattern-following around this. So before any production code, prove the two hard unknowns and let them force the Phase A design. Over-invest *here*; economize everywhere after. + +**Do not build:** the scheduler, the SPA, full Bedrock Guardrails, or the schedule data model. Those are Phase A/B proper. This spike de-risks F1 and produces the design others build against. + +## Single success criterion + +> Given `(user_id, prompt)` and **no live session**, a headless caller runs an agent turn **as that user**, the turn uses **at least one of that user's authorized connector tools** (e.g. lists a Google Drive file), a **governance checkpoint** sees the run, and the result lands as a **retrievable session** the user can open. + +If you demonstrate that end-to-end **in dev-ai**, the spike succeeds. A localhost demo does **not** count β€” see the boundary note below. + +## Critical boundary β€” must run in dev-ai, not localhost + +The entire risk lives in the **AgentCore Runtime gateway auth**. Localhost (`:8001`) bypasses the runtime gateway entirely, so a local success proves nothing about the real question. Run against **dev-ai** (acct `490617140655`, `us-west-2`, prefix `dev-boisestateai-v2`; active runtime id via SSM `/dev-boisestateai-v2/inference-api/runtime-id`). Pick a test user who has a connector already authorized so the connector-token leg is real. (dev-ai SSO login gotcha: use Safari with `--no-browser`, not Brave β€” see `project_dev_ai_sso_login_gotcha`.) + +## Unknown 1 β€” Unattended auth to the runtime (the core risk) + +**Question:** Can a headless caller invoke the runtime `/invocations` *as a user* without a live Cognito token, such that (a) the agent loads that user's context and (b) it can retrieve that user's vaulted connector OAuth tokens? + +- **Try first (decided approach):** invoke the runtime with the **platform workload identity**, passing `user_id` explicitly in the payload. This is exactly how the **KB-sync worker** already does unattended per-user work (mints via `GetWorkloadAccessTokenForUserId`, reads the user's vaulted OAuth without a session). Reuse that path. +- **Fallback if the runtime rejects a workload credential directly:** app-api mints a short-lived per-owner token that the caller relays. Only fall back if you *prove* the first path can't work β€” document why. +- **Reuse:** `apis/shared/oauth/agentcore_identity.py` (workload identity, `AGENTCORE_RUNTIME_WORKLOAD_NAME`), KB-sync IAM statements in `infrastructure/lib/constructs/kb-sync/kb-sync-construct.ts` (~152–184: `GetWorkloadAccessTokenForUserId`, `GetResourceOauth2Token`, secret read), runtime URL builder + request shape in `apis/app_api/chat/proxy_routes.py` (~33) and `InvocationRequest` in `apis/inference_api/chat/models.py` (~81). +- **Watch:** AgentCore `customParameters` are part of the token-vault key β€” token retrieval must use the **same** customParameters as the consent flow or it falsely reports consent-required (`project_agentcore_custom_parameters_vault_key`). + +## Unknown 2 β€” Server-side SSE consumption + +**Question:** Can the headless caller consume the `/invocations` SSE stream to completion (nothing today reads it server-side; the app-api proxy only relays to a browser)? + +- **Build:** a small httpx SSE reader that drains to `message_stop`/`done`, extracts the **final assistant message**, and surfaces `stream_error`. Capture enough for a `RunResult` (see below): final text, tool-use trace, and cost/metadata if present. +- **Reuse:** event shapes in `agents/main_agent/streaming/event_formatter.py`; the SSE event table in `CLAUDE.md`. Mind the 300s runtime timeout (`proxy_routes.py`). + +## Design deliverables (the real point of the spike) + +Code proves feasibility; **these decisions are what Phase A builds on.** Produce a short design note answering: + +1. **`run_agent_headless(...)` interface.** The signature and a `RunResult` shape (final message, tool trace, cost, status, error, `session_id`). This is the internal primitive every trigger will call β€” keep it **A2A-ready** (clean seam a future A2A server / runtime endpoint can front without a rewrite). ⚠️ If ever exposed as A2A, `capabilities` must include `streaming=True` (`CLAUDE.md`) or clients hang ~40 min. +2. **Where it lives.** It is *not* an inference-api route (only `/invocations` + `/ping` are reachable in cloud β€” `CLAUDE.md`). Recommend the module boundary: shared invocation helper vs. an app-api-hosted background caller vs. a dedicated Lambda. Justify against the service-boundary rule (`feedback_service_boundaries`). +3. **Auth approach chosen**, with evidence β€” which of Unknown-1's paths, and why. +4. **Governance-floor hook points (F6a seam only β€” do not implement guardrails yet).** Where does a run (a) write an **audit record**, (b) pass a **guardrails hook**, (c) hit a **data-classification checkpoint** on what it may read/emit? Place the seams so Phase A can fill them without retrofitting. For the spike, writing **one minimal audit record** is enough to prove the seam exists. +5. **Delivery (minimal F2).** Confirm result materialization via `ensure_session_metadata_exists` + `update_session_title` (`apis/shared/sessions/metadata.py` ~739 / ~560). Note the run-record shape. + +## Out of scope for the spike + +Scheduler / EventBridge / dispatcher-worker (Phase B) Β· SPA (Phase A-UI) Β· full Bedrock Guardrails + PII classifier implementation (Phase A, seams only here) Β· schedule data model Β· cohort RBAC wiring Β· email delivery. + +## House rules (non-negotiable) + +- No new routes on **inference-api**. Β· SPA-facing app-api routes use `Depends(get_current_user_from_session)` (cookie), never bare Bearer. Β· Exact version pins; **no new packages without Phil's approval**. Β· Branch from `develop`; conventional commits. Β· Prove in **dev-ai**, not localhost. + +## Expected output + +A spike branch demonstrating the success criterion in dev-ai, **plus** a design note (the 5 deliverables above) and a clear **go / no-go + chosen-auth-path recommendation** for Phase A. If it's no-go on the preferred auth path, the fallback analysis *is* the valuable result β€” say so plainly with the evidence. diff --git a/docs/specs/harness-entrypoint-spike-findings.md b/docs/specs/harness-entrypoint-spike-findings.md new file mode 100644 index 00000000..0613b663 --- /dev/null +++ b/docs/specs/harness-entrypoint-spike-findings.md @@ -0,0 +1,131 @@ +# Spike Findings β€” Headless Agent-Run Entrypoint (F1) + +**Status:** Spike complete β€” success criterion met in dev-ai +**Author:** Fable 5 (spike executed 2026-07-05, dev-ai acct 490617140655, us-west-2) +**Brief:** `docs/specs/harness-entrypoint-spike-brief.md` Β· **Parents:** `docs/specs/agentic-platform-primitives.md` (F1/F2/F6a), `docs/specs/scheduled-agent-runs.md` Β§4.2 +**Spike branch:** `spike/harness-headless-entrypoint` + +--- + +## Verdict + +**GO for Phase A.** Chosen auth path: **platform-minted per-owner Cognito access token** (the brief's "fallback"), because the preferred path is *proven impossible* against the runtime gateway as deployed β€” see Unknown 1. Both hard unknowns are resolved with working code, and the success criterion ran end-to-end in dev-ai: + +> Headless caller + `(user_id, prompt)`, no live session β†’ agent turn ran **as the user** through the real AgentCore Runtime gateway β†’ the turn called `search_classes` (the user's per-user-authorized class-search MCP connector, which validated the minted token downstream) β†’ the **governance floor** wrote start/end audit records β†’ the result landed as a **retrievable session** in the user's conversation list (title, metadata row, 2 persisted message items). + +Evidence run: `run-8f10d164cff9` / session `headless-09a30f6ac87b4092` / user `18419330-70a1-7018-f8e3-9577b2e18455` (2026-07-05T21:32Z). Reproduce with: + +```bash +cd backend && uv sync --extra agentcore --extra dev +AWS_PROFILE=dev-ai uv run python scripts/spike_headless_run.py \ + --user-id \ + --prompt "Use the class search tool to find two 3-credit undergraduate COMM classes..." \ + --tools class_search --title "Headless Spike β€” Class Search" +``` + +One scope note: the criterion's example connector ("lists a Google Drive file") could not run *inside* the turn because dev-ai has **no cloud-reachable vault-3LO agent tool** β€” the only 3LO tool (`canvas_faculty`) points at `http://localhost:8026`. That is a catalog gap, not an auth gap. The vault leg was proven separately and unattended: the same headless caller minted the user's **google-drive** token from the AgentCore vault via the platform workload identity (`GetWorkloadAccessTokenForUserId` β†’ `get_token_for_user`, mirroring the consent flow's `customParameters` per the vault-key gotcha) and listed the user's actual Drive files. Connector auth is therefore proven on **both** protocols we have: forward-auth (in-run) and vault-3LO (out-of-run, identical mechanism to the in-run tool path). + +--- + +## Unknown 1 β€” Unattended auth to the runtime (RESOLVED: fallback path) + +The brief's decided approach β€” invoke `/invocations` with the **platform workload token** β€” **cannot work**, and not for a fixable configuration reason: + +| Probe (dev-ai, real gateway) | Result | +|---|---| +| Workload access token as `Authorization: Bearer` | **403** `{"message":"OAuth authorization failed: Failed to parse token"}`. The token from `GetWorkloadAccessTokenForUserId` is an **opaque encrypted blob** (`AgV4…`, 1864 chars, not 3-segment JWT). The gateway's JWT authorizer can't even parse it, let alone validate issuer/client. | +| SigV4 `invoke_agent_runtime` (IAM data plane) | **AccessDeniedException:** *"Authorization method mismatch. The agent is configured for a different authorization method…"* β€” once a `customJWTAuthorizer` is configured (ours: Cognito discovery URL, `allowedClients=[BFF app client]`, `inference-agentcore-construct.ts` ~274), IAM invocation is refused outright. | + +The brief's "try first" path conflated **two different trust boundaries**: the workload identity governs the *token vault* (connector OAuth, inside the run) β€” it was never a front-door credential. The front door only accepts a Cognito JWT for the allowed client. So the platform must mint a **real Cognito access token for the owning user**: + +- **Spike implementation (works today, zero infra change):** exchange the user's stored BFF refresh token (`bff-sessions` table) via `REFRESH_TOKEN_AUTH` + SECRET_HASH β€” the exact machinery `SessionRefreshMiddleware`/`CognitoRefreshClient` already run. Verified: mints a 1-hour token with the right `sub`; the pool does **not** rotate refresh tokens (CDK default), so the user's live browser sessions are untouched (`rotated_refresh=False` observed). +- The minted token then works **three layers deep**: gateway JWT authorizer β†’ container `get_current_user_trusted` (`sub` β†’ user_id, so memory/RBAC/quota/session all resolve to the right user) β†’ forwarded to forward-auth MCP servers, which validate `client_id == BFF client` (class-search accepted it). +- The **workload identity keeps its real job** unchanged: inside the run, connector tokens mint from the vault keyed by the `sub` of our bearer β€” no code change needed there. + +**Phase A hardening (recommended, not blocking):** the refresh-token grant inherits BFF session lifetime (30-day absolute cap, sliding TTL) and is discovered by a table **Scan** (no user_id GSI). Ship Phase A on this path but behind an explicit **headless-grant record**: when a user enables scheduled runs, store a purpose-minted refresh token (or pin a session row) with its own lifecycle + revocation, and add a `user_id` GSI. "You must have logged in within 30 days for the platform to act as you" is a defensible governance default β€” make it a documented product decision. A dedicated M2M app client + trusted `user_id` payload was considered and rejected for Phase A: it requires container-side confused-deputy handling (`sub` = client-id, trust the payload) plus CDK changes to `allowedClients`, and it *weakens* the story that every downstream check sees a genuine user token. + +## Unknown 2 β€” Server-side SSE consumption (RESOLVED: built and pinned to live shapes) + +`apis/shared/harness/sse.py` β€” an httpx-based reader (`iter_sse_events`) + `InvocationStreamAccumulator` that drains to `done`, yielding a `RunResult` with final message, tool trace, usage, title, `stream_error`, and `oauth_required`. Non-obvious wire facts discovered live (unit tests pin them): + +- The stream interleaves **typed events** with raw Strands passthrough (`event: event`); consume only the typed ones. +- `tool_use` arrives as `{"tool_use": {"tool_use_id", "name", "input"}}` where `input` is a **partial JSON string re-emitted repeatedly** as the model streams arguments β€” fold by id, keep the last parseable prefix. `tool_result` arrives **message-shaped** (`{"message": {"content": [{"toolResult": …}]}}`), not flat. (The flat `event_formatter` shapes also exist on other paths; both are handled.) +- Turn totals come on `metadata_summary` (cumulative), not the per-call `metadata` events β€” use the summary for cost attribution, per-call as fallback. +- A tool-use turn emits multiple `message_start/stop` cycles; "final message" = last completed non-empty assistant text. +- `session_title` may arrive mid-stream (or after `done`) and never carries the placeholder. +- 300s budget matches the proxy; `httpx.TimeoutException` β†’ `status="timeout"`. + +--- + +## Design deliverables + +### 1. `run_agent_headless(...)` + `RunResult` + +```python +async def run_agent_headless( + *, user_id: str, prompt: str, auth: BearerAuthStrategy, + session_id: str | None = None, run_id: str | None = None, title: str | None = None, + # run-config mirrors InvocationRequest β€” no new config type (decision #6): + model_id: str | None = None, rag_assistant_id: str | None = None, + enabled_tools: list[str] | None = None, agent_type: str | None = None, + inference_params: dict | None = None, + trigger: str = "manual", # audit dimension: schedule|run_now|a2a|… + invocations_base_url: str | None = None, # env INFERENCE_API_URL fallback + timeout_seconds: float = 300.0, + governance: GovernanceFloor | None = None, + on_event: OnEvent | None = None, # A2A streaming seam +) -> RunResult +``` + +`RunResult` (`apis/shared/harness/models.py`): `run_id, session_id, user_id, status ∈ {completed, error, timeout, oauth_required}, final_message, stop_reason, error, title, tool_trace[{tool_use_id, name, input, result_preview, is_error}], usage{usage,metrics}, oauth_required[{provider_id, authorization_url}], started_at, finished_at, events_seen` β€” `to_dict()` is JSON-clean so it can become a run-record item or A2A task artifact untranslated. + +**A2A-readiness:** the seam is `(user, input, config) β†’ structured result` **plus** `on_event`, which relays every typed SSE event live β€” an A2A server front maps that onto task status updates without rewriting the runner. ⚠️ Standing CLAUDE.md rule: if exposed as A2A, `capabilities` must include `streaming=True`. `oauth_required` is a first-class status because a headless run cannot pop a consent window β€” schedulers should pause (KB-sync `paused_reauth` analog) and surface the URL. + +### 2. Where it lives + +**`backend/src/apis/shared/harness/`** β€” a shared invocation *client*, not a route. Justification against the service-boundary rules: + +- It cannot be an inference-api route (only `/invocations` + `/ping` reachable in cloud) β€” and it doesn't need to be: the harness **calls** `/invocations`; the agent loop is unchanged. +- Its consumers span services: app-api ("Run now" route, PR-1 of scheduled-runs), the Phase-B dispatcher/worker Lambdas, a future A2A front. "Needed by more than one β†’ `apis.shared`" (CLAUDE.md). `tests/architecture/test_import_boundaries.py` passes. +- The dedicated-Lambda option is a *deployment* choice for Phase B (the worker imports this module), not a module-boundary choice β€” same code either way. +- One dedupe noted in-code: `build_invocations_url` is a copy of the proxy's resolver; Phase A should point `proxy_routes.py` at the shared copy. + +### 3. Auth approach chosen + +**Platform-minted per-owner Cognito access token** (`CognitoRefreshBearerAuth`, `apis/shared/harness/auth.py`), behind a `BearerAuthStrategy` protocol so Phase A can swap in the headless-grant record without touching the runner. Evidence and the two dead ends are in Unknown 1 above; the negative probes are reproducible via `--probe-workload-token --probe-sigv4` on the driver script. + +### 4. Governance floor (F6a) hook points + +`apis/shared/harness/governance.py` β€” every headless run passes four checkpoints, placed so Phase A fills them without touching runner control flow: + +1. **`on_run_start` β€” AUDIT (implemented).** Durable record *before* any token mint or model spend: `PK=USER#{user}, SK=RUN#{run_id}` in the sessions-metadata table β€” `trigger`, `promptSha256`, `promptChars`, `startedAt`, `status=started`. Invisible to all existing access paths (session listing queries `begins_with(SK,'S#ACTIVE#')`). Fail-closed: a run that can't be audited doesn't execute. +2. **`check_input` β€” guardrails seam (no-op).** Phase A: Bedrock `ApplyGuardrail(source=INPUT)`; blocked verdict raises before spend, leaving an audited failure. +3. **`classify_output` β€” data-classification seam (no-op).** Phase A: PII/FERPA checkpoint over `final_message` + tool-result previews, **before delivery**; may redact (mutate) or block (raise). +4. **`on_run_end` β€” AUDIT (implemented).** Outcome, stop reason, tool names, usage, finishedAt. Best-effort (a failed end-write can't destroy a delivered result; the start record pins existence). + +Phase A promotions: run-records to their own table (or documented SK family) with a "recent runs per user" GSI; add the caller's IAM identity to the start record; wire `trigger` from real callers. + +### 5. Delivery (minimal F2) + +Confirmed β€” and cheaper than the spec assumed: **the runtime turn itself already materializes almost everything** (`/invocations` pre-creates the session row via `ensure_session_metadata_exists`, persists user+assistant messages, generates + persists a Nova title, updates activity/costs). Verified post-run: session row `S#ACTIVE#…#headless-09a30f6ac87b4092` (status active, messageCount 1β†’, title), **2 message items**, session visible in the user's list. The harness adds: idempotent `ensure_session_metadata_exists` (belt-and-braces for error paths) + `update_session_title` when the caller passes an explicit title (e.g. *"Morning Briefing β€” Jul 6"*), which wins over the generated one. + +**Run-record shape** (the audit item; Phase A may split audit vs. delivery records): `runId, userId, sessionId, trigger, promptSha256, promptChars, status, stopReason, errorDetail, toolNames[], usage{...}, finalMessageChars, startedAt, finishedAt`. + +--- + +## Phase A punch list (from spike scars) + +1. Headless-grant record + `user_id` GSI (replace the BFF-table Scan); decide the "must have logged in within N days" policy explicitly. +2. Fill `check_input` / `classify_output`; promote run-records out of the sessions table. +3. "Run now" app-api route (cookie auth + RBAC capability) calling `run_agent_headless` β€” the PR-1 validation surface. +4. Dedupe `build_invocations_url` with the chat proxy. +5. Rotation-aware persistence in `CognitoRefreshBearerAuth` before anyone enables refresh-token rotation on the pool (currently warn-only). +6. Catalog gap, separate from Phase A: no cloud-reachable vault-3LO agent tool exists in dev-ai β€” deploy one (e.g. the canvas MCP server, or a Drive tool) so scheduled-run dogfooding exercises the 3LO path in-run. +7. `enabled_tools=None` means "all RBAC-allowed" β€” fine for "Run now", but schedules should snapshot an explicit tool set at creation (least surprise when the catalog changes under a sleeping schedule). + +## Files (spike branch) + +- `backend/src/apis/shared/harness/{__init__,models,sse,auth,governance,runner}.py` β€” the F1 primitive +- `backend/scripts/spike_headless_run.py` β€” dev-ai driver (positive proof + recorded negative probes) +- `backend/tests/apis/shared/test_harness_sse.py` β€” SSE shapes pinned from the live stream +- No new packages (httpx/boto3 already pinned); no spec files modified; no inference-api changes. diff --git a/docs/specs/interrupted-turn-context.md b/docs/specs/interrupted-turn-context.md new file mode 100644 index 00000000..91526232 --- /dev/null +++ b/docs/specs/interrupted-turn-context.md @@ -0,0 +1,124 @@ +# Interrupted-Turn Context + +**Status:** Backend implemented (this branch); frontend follow-ups scoped below +**Date:** 2026-07-03 +**Related:** max_tokens truncated-turn flow (`set_truncated_turn`), paused-turn / pending-interrupt flow (`set_paused_turn`), interrupt-resume streaming. + +## 1. Problem + +When an assistant response is interrupted before it completes β€” the user clicks **Stop**, the browser is refreshed, or the connection drops β€” the turn dies silently: + +- The **user message was already persisted** at turn start (Strands' MessageAddedEvent hook fires when the user turn is added, before the model streams). +- The **assistant turn is only persisted on natural completion** β€” `TurnBasedSessionManager.append_message` (`session/turn_based_session_manager.py:123`) persists each message *as it completes*; nothing persists a message still streaming. +- A raw teardown surfaces inside the coordinator generator as `asyncio.CancelledError` or `GeneratorExit` β€” both `BaseException` subclasses that slip past every `except Exception` in the stream path. + +**Result:** persisted history ends on a dangling user turn. On reload the user sees their message and nothing else. On the next turn the *model* sees malformed userβ†’user history, has no idea it was cut off, and β€” critically β€” cannot tell a deliberate Stop (strong feedback) from a dropped connection (no signal). + +## 2. Goals + +- Record that a turn was interrupted, durably, surviving refresh. +- Preserve the **in-flight partial assistant text** the user already saw. +- Give the **model** legible, reason-aware context on its next turn. +- Distinguish **`user_stopped`** (deliberate; don't barrel onward) from **`connection_lost`** (technical; user probably still wants the answer). The transport can't make this distinction β€” both look like a dead stream β€” so intent is captured out-of-band (Β§5). + +**Non-goals:** no bidirectional SSE channel; no changes to the max_tokens or paused-turn flows; no permanent synthetic "[interrupted]" system turn re-fed into context forever. + +## 3. Design decisions (the three contested calls) + +### 3.1 Third parallel marker β€” deliberately NOT unified + +We keep `lastTurnInterrupted*` as a third sibling of `lastTurnContinuable` (truncated) and `paused_turn`/`pending_interrupts`, rather than folding all three into a `lastTurnDisposition`. Rationale: + +- The three are **different shapes**, not three copies of one shape: paused carries a full resume snapshot + pending-interrupt list; truncated is a bare bool; interrupted is bool + reason + timestamp. A unified record still needs per-state variant payloads and per-state precedence β€” unification renames the problem. +- `lastTurnContinuable` and `paused_turn` are **live cross-package contracts** consumed by shipped SPA code; renaming them forces a coordinated frontend migration (CLAUDE.md contract rule) for zero user-visible value and real regression risk in two working flows. +- The lifecycles genuinely coincide (all cleared at next non-resume turn start), and that is already expressed by three adjacent calls in the invocations route β€” cheap to read, cheap to keep consistent. + +Revisit only if a fourth marker appears; then extract the shared "turn-end disposition" machinery once, with evidence. + +### 3.2 The partial IS persisted to Memory as a synthetic assistant message + +The alternative considered β€” display-only metadata (a `displayText`-style side channel) + purely described interruption β€” was rejected: + +- **Text-only is not a "lossy corner-cut"; it's the only valid choice.** A dangling `toolUse` block or an unsigned reasoning block *cannot* be replayed to Bedrock; any persistable partial is necessarily the text. The "reconstruction diverges from what the user saw" objection reduces to blocks that could never be persisted anyway. +- It **repairs role alternation in the model's actual history** β€” the whole point of the model-facing half. A display-only partial leaves userβ†’user in Memory and outsources the problem to SDK history-repair. +- It's the **established mechanism**: error paths already persist synthetic assistant turns via `persist_synthetic_messages` (`session/persistence.py:37`; call sites in `stream_coordinator.py`). Reload hydration then works through the normal Memory read path with zero new storage or merge logic. +- It's how the sibling flow works: max_tokens partials live verbatim and unannotated in history today, and age out via compaction. Interruption partials behave identically; the *reason* context is delivered separately (Β§6) because it isn't knowable at persist time. + +**Correctness constraint (fixed in this branch):** because `append_message` persists each completed message immediately (flush is a no-op β€” `turn_based_session_manager.py:949`), the coordinator accumulates **only the in-flight message's** text deltas: the accumulator resets at assistant `message_start` and clears at `message_stop`. Accumulating across the whole stream would duplicate mid-turn messages already committed to Memory. + +**Empty partial:** a placeholder assistant turn (`"[Response interrupted before any content was generated]"`) is persisted **only when the history tail is a user message** β€” that's the dangling-turn case the placeholder exists to repair. On continuation/resume teardowns (tail = assistant) nothing needs repair, so only the marker is written. + +### 3.3 Intent capture is primary; server-side detection is the backstop + +The original PR-1 framing ("server-side CancelledError is load-bearing, beacon comes later") is inverted: + +- **Cloud deliverability of the cancellation is unverified.** The chain is Browser β†’ CloudFront β†’ app-api BFF proxy (`app_api/chat/proxy_routes.py:148`, whose `stream_relay` `finally` closes the upstream httpx stream) β†’ **AgentCore Runtime data plane** β†’ inference-api container. Whether the data plane propagates a caller disconnect into the container's request task is undocumented. Local dev (`localhost:8001`) proves nothing about it. If it doesn't propagate, the container finishes the turn and persists the full response β€” losing nothing β€” but the server-side arm never fires. +- The server-side arm also only ever knows the **low-value reason** (`connection_lost`). The high-value signal β€” *the user chose to stop* β€” can only come from the client. + +So: the client stop signal is the authoritative carrier of `user_stopped`; the coordinator's teardown arm is a best-effort backstop that contributes the partial text and the `connection_lost` fallback. Both are shipped in this backend change; neither waits on the other. + +**`navigator.sendBeacon` is ruled out** β€” a correction to the earlier draft. App-api's `CSRFMiddleware` (`apis/shared/middleware/csrf.py:56`) rejects unsafe-method cookie requests without a matching `X-CSRF-Token` header, and `sendBeacon` cannot set headers. The SPA must use `fetch(url, { method: 'POST', keepalive: true, headers: { 'X-CSRF-Token': … } })` β€” keepalive survives page teardown and carries headers. Do **not** exempt the path from CSRF to accommodate sendBeacon. + +## 4. Reason taxonomy + +| Reason | Source | Signal | Next-turn note (Β§6) | +|---|---|---|---| +| `user_stopped` | `POST /sessions/{id}/interrupt` (client-attested only) | Strong β€” deliberate | "user stopped you; don't resume on your own" | +| `connection_lost` | Coordinator teardown arm (server-inferred only) | Weak β€” technical | "cut off by connection, not the user; continue if asked" | +| `unknown` | Unclassifiable writes | Weak | treated as `connection_lost` | + +The request model (`SessionInterruptRequest`, `apis/shared/sessions/models.py`) accepts **only** `user_stopped` from the client β€” `connection_lost` from a client would let it downgrade a deliberate stop. + +## 5. As built β€” backend (this branch) + +### Marker (`apis/shared/sessions/metadata.py`) + +- `set_interrupted_turn(session_id, user_id, reason, source)` (line 2207) β€” writes `lastTurnInterrupted` / `lastTurnInterruptReason` / `lastTurnInterruptedAt` on the session row. **Precedence, not ordering,** resolves the two racing writers: `user_stopped` writes unconditionally; `connection_lost`/`unknown` writes carry a `ConditionExpression` (`#ltr <> "user_stopped"`) so the fallback can never downgrade the settled reason regardless of arrival order. +- `clear_interrupted_turn(session_id, user_id) -> Optional[str]` (line 2294) β€” an atomic **pop**: `REMOVE` with `ReturnValues=UPDATED_OLD` returns the reason it cleared, so a single read+write both enforces the lifecycle *and* feeds the model note. Called at the start of every non-resume turn (beside `clear_truncated_turn` / `clear_paused_turn`, `inference_api/chat/routes.py:1043–1067`). + +`SessionMetadata` / `SessionMetadataResponse` (`apis/shared/sessions/models.py`) carry the three aliased optional fields, so the SPA's existing metadata reads surface them with no new endpoint. + +### Stop-signal endpoint (`apis/app_api/sessions/routes.py:603`) + +`POST /sessions/{session_id}/interrupt`, body `{"reason": "user_stopped"}` β†’ 204. Uses `Depends(get_current_user_from_session)` (app-api auth rule). Lives on **app-api**, not inference-api: the Runtime data plane only proxies `/invocations` + `/ping` (inference-api boundary rule). Ownership enforcement falls out of the user-scoped GSI lookup inside `set_interrupted_turn` β€” another user's session is a silent no-op. + +### Cancellation backstop (`agents/main_agent/streaming/stream_coordinator.py:922`) + +`except (asyncio.CancelledError, GeneratorExit)` around the stream loop β€” teardown surfaces as either, depending on whether the generator was at an inner `await` (cancellation) or a `yield` (`aclose()`). The arm calls `_persist_interruption` (line 1011) and **re-raises**; the MCP-broker `finally` still runs. + +`_persist_interruption`: +- persists the in-flight partial (or the gated placeholder, Β§3.2) assistant-only via `persist_synthetic_messages` β€” the user turn is already committed (canonical invariant in `session/persistence.py`); +- writes the `connection_lost` fallback marker; +- wraps both in `asyncio.shield(asyncio.ensure_future(...))` so the writes complete even as the request task unwinds mid-`await` β€” a bare await inside a cancellation handler would itself be cancelled; +- is best-effort throughout: failures log, never mask the original teardown exception. + +### Model-facing note (`apis/inference_api/chat/routes.py:499`, `:1676`) + +On the next non-resume, non-continuation turn, the popped reason drives `_build_interruption_note(reason)`, prepended to `final_message` at the same seam as the MCP Apps `pending_ctx_block`. Properties: + +- **Why next-turn, not persist-time:** the reason isn't knowable when the partial is persisted β€” the client signal races the backstop and precedence settles in DynamoDB. By the next turn the marker is authoritative. (This is also why annotating the persisted partial inline was rejected.) +- The prepend makes `message_will_be_modified` true, so the raw user text is stored as `displayText` β€” the note is **invisible to the user** but remains an honest part of the persisted user message in Memory, aging out via compaction like everything else. Cache-prefix-safe (appends to the newest message only). +- Continuation turns skip it structurally (Strands ignores the message; the model simply continues the persisted partial β€” which is exactly the right behavior for a `connection_lost` "Continue"). + +## 6. Known residual races (accepted) + +- **Stop lands but the container finishes anyway** (data plane didn't propagate): full response persists, marker says `user_stopped`. The note still conveys true intent ("the user clicked stop"); the reload chip may sit next to a complete response. Rare, honest, self-clearing on next turn. +- **Signal arrives after the next turn already popped the marker:** the stale marker is cleared at the turn after. Harmless. +- **Hard network loss drops the keepalive fetch:** correctly degrades to `connection_lost` via the backstop (when it fires) or to nothing (when the turn actually completed server-side β€” in which case nothing was lost). + +## 7. Frontend (shipped) + +1. **Stop signal:** `chat-http.service.ts` `cancelChatRequest` fires a best-effort `fetch(..., { keepalive: true })` to `POST {appApi}/sessions/{id}/interrupt` with `{reason:'user_stopped'}` and the `X-CSRF-Token` header (via `bffSession.csrfHeaders()`) *before* `abortRequest` β€” and reflects the interruption locally (`setLastTurnInterrupted(id, true, 'user_stopped')`) so the chip shows within the session without a reload. Fire-and-forget: a failed signal never blocks Stop teardown. +2. **Reload UX:** the interrupted flag + reason are per-session signals on `ChatStateService` (mirroring `lastTurnContinuable`), hydrated in `session.page.ts` from the merged Dynamo metadata (only ever set true, never clobbering a live true), and cleared on any new send / continuation / new streamed turn (beside every `setLastTurnContinuable(id,false)`). `message-actions.component.ts` renders the chip on the last message: + - `connection_lost` β†’ "Response interrupted" + a Continue button reusing the truncated-turn continuation path (`continueTruncatedTurn` works unchanged against the persisted partial). + - `user_stopped` β†’ "You stopped this response", **no** Continue. + - Gated (via `interruptedReasonFor`) on `lastTurnInterrupted && !isChatLoading && id === lastMessageId` β€” the last-message gate is the sanity check against the completed-anyway race in Β§6. A live max_tokens `canContinue` takes precedence over the interrupted chip. + +Specs: `message-actions.component.spec.ts` (both chip variants, Continue emit, max_tokens precedence), `chat-state.service.spec.ts` (set/clear + reason), `chat-http.service.spec.ts` (keepalive signal shape + local reflect + failure isolation), `chat-request.service.spec.ts` (clears on continuation). + +## 8. Test coverage (this branch) + +- `tests/shared/test_sessions_metadata.py` β€” set/pop lifecycle, pop returns reason, idempotent pop, `user_stopped` precedence in both arrival orders, missing-session no-op, response-model round trip. +- `tests/agents/main_agent/streaming/test_interrupted_turn_persistence.py` β€” CancelledError **and** GeneratorExit arms fire + re-raise; partial scoped to the in-flight message (completed mid-turn text excluded); assistant-only write; placeholder gated on user-tail; assistant-tail β†’ marker-only. +- `tests/routes/test_sessions.py::TestSignalTurnInterrupted` β€” 204 + recorded `user_stopped`/`client_signal`; 422 for non-client-attested reasons; 401 unauthenticated. +- `tests/apis/inference_api/test_interruption_note.py` β€” reason-appropriate guidance, `unknown` treated as technical drop. diff --git a/docs/specs/scheduled-agent-runs.md b/docs/specs/scheduled-agent-runs.md new file mode 100644 index 00000000..1b6ea547 --- /dev/null +++ b/docs/specs/scheduled-agent-runs.md @@ -0,0 +1,199 @@ +# Scheduled Agent Runs β€” Headless Run Entrypoint + Scheduled Trigger (Primitives F1 + F2 + F3) + +**Status:** Draft / handoff spec (for implementation by Fable) +**Author:** Phil Merrell (drafted with Claude) +**Date:** 2026-07-05 +**Targets branch:** `develop` (PRs target `develop`, never `main`) +**Parent plan:** `docs/specs/agentic-platform-primitives.md` β€” this is the detailed design for its fundamentals **F1** (headless, trigger-agnostic agent-run entrypoint / the Harness slice), **F2** (async result + delivery spine), and **F3** (scheduled trigger). Read the parent for how these fit the wider primitive layer. +**Related:** +- KB-sync scheduled re-index β€” the scheduling template (`apis/shared/sync_policies/`, `infrastructure/lib/constructs/kb-sync/`) +- Unattended per-user OAuth (`apis/shared/oauth/agentcore_identity.py`, `AGENTCORE_RUNTIME_WORKLOAD_NAME`) +- Session metadata (`apis/shared/sessions/metadata.py`) β€” `ensure_session_metadata_exists`, `update_session_title` +- Assistants / Skills / RBAC (`apis/shared/assistants/`, `docs/specs/admin-skills-rbac-tool-binding.md`) β€” the run-config the entrypoint resolves + the cohort gate +- Per-user markdown memory (`docs/specs/user-markdown-memory.md`) β€” loaded *inside* a run, tracked separately (F5) + +--- + +## 0. Instructions to Fable (read first) + +Build a **generic primitive**, not a feature. The deliverable is a **headless, trigger-agnostic way to run an agent turn** ("run as user U, input P, resolved config C β†’ structured result") plus a **scheduled trigger** on top of it and a **place for the result to land**. A chief-of-staff assistant ("Oliver"), briefings, digests, and monitoring are *validation use cases* (see the parent plan Β§5) β€” they must fall out as configuration, so keep every layer free of Oliver-specific assumptions. + +**Ship to production, but scoped.** Each layer reaches prod while usable only by a small **feedback cohort** first. Widening the cohort β€” ultimately to *anyone who wants it* β€” must be a config change (grant a capability), not a re-architecture. + +**Decisions already made (do not re-litigate; build to these):** + +1. **The keystone is the headless run entrypoint (F1), not the scheduler.** The scheduler is *one trigger* on it. Build "run an agent turn off the live socket and capture the result" first and generically; a schedule, an A2A call, a webhook, or a "Run now" button are all just callers. Do **not** couple the entrypoint to scheduling. +2. **Model the scheduler on `sync_policies`**, not from scratch. It already solves the hard parts: a sparse "due" GSI, a dispatcher/worker split, conditional re-arm to prevent double-dispatch, and `paused_error` state. Copy that shape. +3. **Unattended runs invoke the agent as the run's owner** using the platform workload identity (the same mechanism KB-sync's worker already uses to read a user's vaulted OAuth tokens without a live session). The agent must run *as the user* so it can read their memory and use their connector tokens. **This is the single biggest technical risk β€” spike it first (PR-2/Phase A), before building UI on top.** ⚠️ If the entrypoint is ever exposed as an A2A server, `CLAUDE.md` requires its `capabilities` include `streaming=True` or clients hang ~40 min. +4. **Delivery starts as a run-record + an auto-created session** in the user's conversation list (e.g. titled *"Morning Briefing β€” Jul 6"*), using `ensure_session_metadata_exists` + `update_session_title`. **No new notification system in v1.** A lightweight inbox row and email-via-connector (the agent emails the user its own result as its final tool call) are high-value follow-ons β€” note them, don't block on them. +5. **Cohort gating uses RBAC**, not a net-new allowlist table. Create a `scheduled-runs` capability granted by a beta `AppRole`; add testers to it. GA = grant the capability to the default/everyone role. A global `SCHEDULED_RUNS_ENABLED` kill switch wraps the feature per house style (copy the `CDK_KB_SYNC_ENABLED` empty-string ternary exactly). +6. **The run-config is resolved from existing primitives** (an assistant id, a skill, an explicit tool set, a model) β€” the entrypoint doesn't invent a new config type. A validation use case like Oliver is then just "a system-seeded `Assistant` + a schedule pointing at it," with no bespoke code. + +**House rules that bite here (from `CLAUDE.md` + prior art):** +- **Do not add routes to `inference-api`.** Only `POST /invocations` and `GET /ping` are reachable in cloud. All CRUD for schedules goes on **app-api**. The worker Lambda invokes the *runtime* `/invocations`, which is allowed. +- SPA-facing app-api routes use `Depends(get_current_user_from_session)` (cookie), never bare Bearer. +- Exact version pins; no new packages without explicit approval. +- Feature flags: **empty-string workflow-var gotcha** β€” `${{ vars.CDK_SCHEDULED_RUNS_ENABLED }}` is `""` when unset, which must resolve to the default, not "off". Copy the `CDK_KB_SYNC_ENABLED` ternary in `config.ts` exactly. + +**When you hit a genuine fork not covered here, stop and ask Phil.** The open questions are in Β§8. + +--- + +## 1. Summary + +Let the agent **act without a live session**. Instead of only responding when a browser is open, a user (or admin) can register a schedule β€” *"every weekday at 7am, run this prompt as me"* β€” and that run happens unattended, as the user, with the result waiting in their conversation list when they log in. A validation use case: a system-seeded chief-of-staff assistant ("Oliver") a user schedules a morning briefing against β€” but the engine knows nothing about Oliver. + +Three things are being built (parent-plan fundamentals in parentheses): + +- **A headless run entrypoint (F1)** β€” "run agent as user U, input P, resolved config C β†’ structured result," callable off the live socket by any trigger. The keystone. +- **A result + delivery spine (F2)** β€” a run-record plus result materialized as an auto-created session; a lightweight inbox/email as follow-ons. +- **A scheduled trigger (F3)** β€” user-owned schedules (`prompt + cadence + timezone + target config`), a dispatcher/worker pair that runs due schedules unattended. Modeled on `sync_policies`. + +All ship to prod, gated to a `scheduled_runs_beta` RBAC cohort, behind a `SCHEDULED_RUNS_ENABLED` kill switch, with a documented one-config-change path to GA. + +## 2. Goals + +- A user can **create, edit, pause, and delete** a scheduled prompt from the SPA. +- Due schedules run **unattended, as the owning user**, with the owner's memory and connector tokens available. +- Results are delivered **idempotently** as a titled session the user can open β€” no duplicate spam if the dispatcher retries. +- The engine is **generic**: not hard-coded to any persona or to "briefings." Any resolved config (assistant / skill / tools / model) + any prompt + any cadence. +- **Cohort-scoped in prod**, widenable to GA by granting a role β€” no redeploy, no schema change. +- **Safe by default**: per-schedule runaway guards (max runs/day), auto-pause on repeated failure (`paused_error`), and a global kill switch. + +## 3. Non-goals (v1) + +- A new in-app notification / inbox / push system. Delivery is session-list discoverability (+ optional connector email). +- Per-assistant tool-binding UI (a scheduled run uses the config's/owner's RBAC-granted tools). Parent-plan F4-adjacent. +- Full per-user markdown memory. A run leans on system prompt + reference files + existing session continuity; memory is its own spec (`user-markdown-memory.md`, parent-plan F5) and lands in parallel. +- Arbitrary cron expressions in the UI. v1 offers a **bounded cadence set** (daily / weekday / weekly at an hour + timezone); store a normalized `next_run_at` so the engine stays cron-agnostic. +- Sharing/scheduling on behalf of *other* users. A schedule runs only as its own owner. +- Multi-step / conditional workflows. One schedule = one prompt = one run. + +--- + +## 4. Architecture + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + SPA ── cookie ─▢│ app-api /scheduled-runs/* (CRUD) β”‚ + β”‚ β†’ ScheduledPromptService (shared) β”‚ + β”‚ β†’ DynamoDB (sparse DueScheduleIndex GSI) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² writes next_run_at + EventBridge rule (rate 5 min, β”‚ + enabled iff SCHEDULED_RUNS_ENABLED) ─┐ β”‚ + β–Ό β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Dispatcher Lambda β”‚ query DueScheduleIndex (next_run_at ≀ now) + β”‚ - runaway + kill-switch check β”‚ conditional re-arm (advance next_run_at) + β”‚ - async-invoke worker per due β”‚ β†’ InvocationType="Event" + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ (one per due schedule) + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Worker Lambda β”‚ + β”‚ 1. mint owner workload token (WLI + user_id) β”‚ ← agentcore_identity.py + β”‚ 2. POST runtime /invocations (as the user) β”‚ ← reuse InvocationRequest + β”‚ 3. consume SSE β†’ final assistant message β”‚ ← net-new: server-side SSE reader + β”‚ 4. ensure_session_metadata_exists + title β”‚ ← deliver as a session + β”‚ 5. (follow-on) agent emails result via connector β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### 4.1 Reuse map (copy these, don't invent) + +| Need | Reuse from | File | +|---|---|---| +| EventBridge rate rule, gated by `enabled` | KB-sync dispatcher rule | `infrastructure/lib/constructs/kb-sync/kb-sync-construct.ts` (~216) | +| Dispatcher β†’ worker async invoke | KB-sync dispatcher | `apis/app_api/kb_sync/dispatcher.py` (~93) `InvocationType="Event"` | +| Sparse "due" GSI + conditional re-arm | `SyncPolicy` | `apis/shared/sync_policies/models.py`, `service.py` (`list_due_policies`, `rearm_policy`, `set_policy_state`) | +| Unattended per-user OAuth / workload token | KB-sync worker | `apis/shared/oauth/agentcore_identity.py`, `AGENTCORE_RUNTIME_WORKLOAD_NAME` + `GetWorkloadAccessTokenForUserId` | +| Runtime `/invocations` URL builder + request shape | chat proxy | `apis/app_api/chat/proxy_routes.py` (~33), `InvocationRequest` in `apis/inference_api/chat/models.py` | +| Idempotent session create + title | sessions | `apis/shared/sessions/metadata.py` (`ensure_session_metadata_exists`, `update_session_title`) | +| Docker Lambda + bootstrap-stub + SSM name export | KB-sync build | `kb-sync-construct.ts`, `backend/Dockerfile.kb-sync`, `bootstrap-assets/kb-sync/` | +| Global flag threading + empty-string ternary | KB-sync flag | `infrastructure/lib/config.ts` (`kbSync.enabled`), `apis/shared/feature_flags.py` | +| Cohort gating via role grant | Admin Skills RBAC | `apis/shared/rbac/`, `docs/specs/admin-skills-rbac-tool-binding.md` | + +### 4.2 The hard part β€” unattended invocation (spike this first) + +KB-sync proves a Lambda can act *as a user* for **connector tokens** (workload identity β†’ `GetWorkloadAccessTokenForUserId` β†’ vaulted OAuth). It does **not** prove a Lambda can drive a full **agent turn** unattended. Two gaps to close in the PR-2 spike: + +1. **Auth to the runtime.** The runtime `/invocations` expects a bearer today (chat proxy relays the user's Cognito token). The worker has no live Cognito session. Validate invoking the runtime with the **platform workload credential**, passing `user_id` explicitly in the payload so the agent loads the right memory/session and mints the right connector tokens. If the runtime cannot accept a workload credential directly, fall back to app-api minting a short-lived owner token (see Β§8 Q1). +2. **Server-side SSE consumption.** No code today reads an `/invocations` SSE stream *inside* a Lambda β€” the app-api proxy only relays it to the browser. Build a small httpx SSE reader in the worker that drains the stream to `message_stop`/`done`, extracts the final assistant message (and surfaces `stream_error`). Net-new but contained. + +**Deliverable of the spike:** a worker that, given a `user_id` + prompt, produces a stored session with a real agent answer that used at least one of the user's connector tools. Everything else in this spec is CRUD and wiring around that proof. + +--- + +## 5. Data model + +New records, keyed under the owning user. Follow `SyncPolicy`'s inert-record + sparse-index discipline: **the row is data; delete = total revocation; no orphan timers.** + +``` +PK = USER#{user_id} +SK = SCHEDPROMPT#{schedule_id} # schedule_id = "sched-" + 12 hex + +Attributes: + scheduleId, userId + assistantId: str | None # target assistant's ast-id (None β†’ default agent) + label: str # "Morning Briefing" + promptText: str # what to run + cadence: "daily" | "weekday" | "weekly" + hourLocal: int (0–23) + weekday: int | None # for "weekly" + timezone: str # IANA, e.g. "America/Boise" + state: "active" | "paused" | "paused_error" + nextRunAt: str # ISO 8601 UTC β€” recomputed on each run/edit + lastRunAt, lastRunStatus, lastRunSessionId, lastError: optional + runsToday, runsTodayDate: runaway guard + deliverEmail: bool = false # v1.5 connector-email opt-in + createdAt, updatedAt + +GSI: DueScheduleIndex (sparse) + PK = "SCHEDDUE" # single hot-partition; fine at this scale (mirror GSI4) + SK = nextRunAt # dispatcher queries nextRunAt ≀ now, oldest first + only projected when state == "active" # pausing/deleting removes it from the index +``` + +Cadence β†’ `next_run_at` is computed in the service (timezone-aware) so the dispatcher stays a dumb "who's due" query. Never dispatch off a cron string. + +## 6. Cohort gating & rollout + +**Two independent controls:** + +- **`SCHEDULED_RUNS_ENABLED`** β€” global kill switch. CDK context `CDK_SCHEDULED_RUNS_ENABLED` β†’ `config.ts` (copy the `kbSync.enabled` ternary *exactly*, incl. the empty-stringβ†’default guard) β†’ threaded into app-api **and** inference-api env, and gates the EventBridge rule's `enabled`. Default: **ON** in the env where we're testing, so the feature *exists* in prod; access is limited by the cohort, not by hiding the feature. (If we want it fully dark in an env, `=false`.) +- **`scheduled_runs_beta` AppRole** β€” *who* can create/run schedules. Grants: the `scheduled-runs` capability (plus visibility of any validation assistant seeded for the cohort). Add testers by mapping a JWT group (`jwt_role_mappings: ["group/scheduled-runs-beta"]`) or direct grant. This reuses the mature RBAC resolution path β€” no net-new allowlist table. + +**Enforcement points:** +- app-api `/scheduled-runs/*` checks the caller resolves the `scheduled-runs` capability via RBAC; 403 otherwise. +- The dispatcher **re-checks** each due schedule's owner still has the capability before invoking (a revoked tester's schedules silently stop, don't error-spam). + +**GA path (one change, no redeploy):** grant `scheduled-runs` to the default/everyone role (or wildcard). The feature flips from "beta cohort" to "anyone who wants it." Keep `SCHEDULED_RUNS_ENABLED` as the permanent kill switch. + +## 7. PR plan + +Sequence so the risky proof (F1) comes before the trigger and UI investment. + +- **PR-1 β€” Headless run entrypoint (F1) + minimal result spine (F2).** The internal `run_agent_headless(user_id, input, config) β†’ RunResult` primitive: workload-identity invocation of the runtime with explicit `user_id`, the server-side SSE reader, a run-record, and result materialized as an auto-created titled session. Expose a guarded **"Run now"** path (app-api, cookie auth, RBAC-gated) as the validation surface. **Land the Β§4.2 proof here β€” this de-risks everything.** No scheduling yet. +- **PR-2 β€” Schedule data model + CRUD API + cohort primitive (F3, control plane).** `ScheduledPrompt` model + service (shared), app-api `/scheduled-runs/*` (cookie auth, RBAC-gated), the `scheduled_runs_beta` role + `scheduled-runs` capability, `SCHEDULED_RUNS_ENABLED` flag threaded through CDK. Schedules can be created/listed; not yet fired. +- **PR-3 β€” Scheduler engine (F3, data plane).** Dispatcher + worker Lambdas (Docker, bootstrap stub, SSM name export), EventBridge rule gated by `SCHEDULED_RUNS_ENABLED`, IAM (mirror KB-sync). Dispatcher queries due β†’ conditional re-arm β†’ worker calls the PR-1 entrypoint. Delivery = the F2 session. +- **PR-4 β€” SPA management UI.** Signal-based schedule list/create/edit/pause under `frontend/ai.client/src/app/`. Pick a target config in the picker; "Schedule this" affordance. Runaway/error state surfaced to the user. +- **PR-5 β€” GA flip + docs + (optional) email delivery.** Grant `scheduled-runs` to the default role; release notes; and optionally the `deliverEmail` path (final tool call emails the result via the user's Gmail connector β€” a strong dogfood of the connector + tool-approval surface). + +**Validation use case (separate, thin PR):** seed a system Assistant (e.g. "Oliver") + a schedule pointed at it, to prove the primitives end-to-end. No engine code β€” pure configuration. + +**Parallel dependencies (not blockers):** per-user markdown memory (`docs/specs/user-markdown-memory.md`, F5) and first-class KB (F4) β€” either sharpens scheduled runs, but the engine must stand without them. + +## 8. Open questions for Phil + +1. βœ… **Unattended runtime auth β€” RESOLVED by the spike** (`docs/specs/harness-entrypoint-spike-findings.md`, 2026-07-05). The preferred workload-credential-as-bearer path is *provably impossible* (gateway JWT authorizer rejects the opaque workload blob; SigV4 refused). Chosen: **platform mints a per-owner Cognito access token** (`CognitoRefreshBearerAuth`), gated behind an explicit **headless-grant record** (own consent/revocation; "logged in within N days" policy). Workload identity keeps its real job β€” minting the user's connector tokens from the vault *inside* the run. See the findings doc's Unknown 1 for the two dead-ends with evidence. +2. **Delivery default.** v1 = session-in-list only, or turn on **connector-email** (`deliverEmail`) for the beta cohort from day one? Email is the better dogfood but adds the connector-token + tool-approval path to the critical path. +3. **Cohort mechanism.** RBAC `scheduled_runs_beta` role (recommended, reuses mature infra) vs. a `FEATURE_COHORT#scheduled-runs` sentinel item in the auth-providers table (lighter, but net-new gating code). Confirm RBAC. +4. **Run-config surface.** What can a schedule target in v1 β€” an Assistant id (recommended, shippable today), a Skill, or an explicit tool+model set? Assistant is simplest; Skill owns tool-binding but is behind the deferred `SKILLS_ENABLED`. +5. **Run frequency ceiling.** Max schedules per user and max runs/day per schedule for the beta (cost + spam guard)? Proposed: 5 schedules/user, 1 run/day/schedule for the cohort. + +--- + +## Appendix β€” why this is the right dogfood + +The capability we most lack for non-coding daily use is **proactivity**: today the agent only speaks when spoken to. A headless run entrypoint + scheduled trigger flips that β€” and because it's a *primitive*, a chief-of-staff persona, a weekly digest, and a monitoring alert are all just configuration on top (parent plan Β§5). Building it also forces us through β€” from the *user's* seat β€” the exact surfaces that are hardest and least dogfooded: unattended connector-token use, session creation outside a live chat, RBAC-scoped rollout, and the connector-email + tool-approval loop. Ship it narrow, live in it, widen it. diff --git a/docs/specs/scheduled-runs-phase-b-brief.md b/docs/specs/scheduled-runs-phase-b-brief.md new file mode 100644 index 00000000..da6a9bcb --- /dev/null +++ b/docs/specs/scheduled-runs-phase-b-brief.md @@ -0,0 +1,52 @@ +# Phase B Scoping Brief β€” The Scheduled Trigger (F3) + +**Status:** Handoff brief +**Author:** Phil Merrell (drafted with Claude) +**Date:** 2026-07-05 +**Parents:** `docs/specs/scheduled-agent-runs.md` (Β§4 architecture, Β§5 data model, Β§7 PR plan β€” the detailed design) Β· `docs/specs/agentic-platform-primitives.md` Β§6 (locked decisions) Β· `docs/specs/harness-entrypoint-spike-findings.md` (Phase A punch list) +**Builds on:** Phase A (merged β€” `apis/shared/harness/` + the `/runs/now` surface + `scheduled-runs` capability + `SCHEDULED_RUNS_ENABLED` flag on `develop`). + +--- + +## 0. Where we are + +Phase A shipped the keystone: `run_agent_headless(...)` runs a turn **as a user**, off the live socket, governed and delivered. Phase B is the **scheduled trigger** β€” a thin scheduler that calls that primitive on a cadence. It is *one trigger* on F1; keep it thin. The detailed design already exists in `scheduled-agent-runs.md` (Β§4/Β§5/Β§7) β€” this brief only records what's **new since that plan**: the governance model (role/auth-based, Β§1), and the work breakdown + model tiering (Β§2). + +## 1. Governance model β€” role/auth-based, no content-scrubbing gate + +**Decision (2026-07-05, revised β€” an earlier draft over-scoped this).** Governance for scheduled runs is **role/auth-based**, not content-classification-based. A scheduled run executes **as the owning user, with that user's own RBAC**, and delivers **back to that same user's own session list**. So it can only touch tools/KBs/connectors the user is already authorized for, and the result is visible only to the one person who could have pulled it interactively. **No new access boundary is crossed and no new recipient is introduced** β€” a PII/content-scrubbing pass would not prevent any exposure RBAC isn't already preventing. + +The controls that actually apply, all already built: + +- **RBAC (run-as-user)** β€” the run inherits exactly the owner's tool/model/KB grants. The primary control. +- **Grant lifecycle** β€” the headless-grant record (consent + revocation + N-days login-recency) is the real FERPA-relevant control on "the platform acts as you" (Phase A). +- **Quota + runaway guards** β€” cost ceilings + per-schedule max-runs/day + auto-pause on repeated failure. +- **Audit** β€” the fail-closed run-audit floor records who/what/when (Phase A). + +**So B0 collapses β€” there is no PII-ordering fork and B2's delivery is not blocked.** Keep `check_input`/`classify_output` as **dormant no-op seams** (cheap optionality). They only become relevant if a run ever delivers to a *recipient other than the owner* (e.g. the deferred "email the result to someone else" feature) β€” governed *there*, not here. + +## 2. Work breakdown & model tiering + +Per the agreed rule β€” **keystone/design-sensitive β†’ top model (Fable 5); pattern-following fan-out β†’ cheaper tier (Sonnet).** + +| Item | What | Model | Depends on | +|---|---|---|---| +| **B1** | Schedule data model (`ScheduledPrompt`, sparse `DueScheduleIndex`) + app-api CRUD (`/schedules` β€” create/list/pause/delete), gated by the existing `scheduled-runs` capability + flag. **Inert**: schedules can be created but nothing fires yet. Mirror `apis/shared/sync_policies/`. | **Sonnet** | Phase A (done) | +| **B2** | Dispatcher + worker Lambdas + EventBridge rule (gated by flag) + IAM. Mirror `infrastructure/lib/constructs/kb-sync/` + `apis/app_api/kb_sync/`. Worker calls `run_agent_headless(trigger="schedule")` β€” delivery flows straight through (governance is role/auth-based, Β§1). | **Sonnet** | B1 | +| **B3** | SPA management UI (signal-based list/create/edit/pause under `frontend/ai.client/`). | **Sonnet** | B1 | + +**Sequence:** B1 first (safe, unblocks B2/B3) β†’ B2 βˆ₯ B3 β†’ cohort scheduling. GA (grant capability to `default` + optional email) is a later phase. (B0 was dropped β€” see Β§1.) + +## 3. Constraints & reuse (carry from the parent specs) + +- **Model on `sync_policies`**: sparse "due" GSI, dispatcher/worker split, conditional re-arm (no double-dispatch), `paused_error` state. Model the Lambda plumbing on **KB-sync**. +- **Cohort gating already exists** β€” reuse the `scheduled-runs` capability + `SCHEDULED_RUNS_ENABLED` flag from Phase A; don't reinvent. +- **No inference-api routes.** Schedule CRUD is app-api, cookie auth. +- **`oauth_required` β†’ pause the schedule** (the KB-sync `paused_reauth` analog) and surface the consent URL; a headless run can't pop a consent window. `run_agent_headless` already returns this as a first-class status. +- **Snapshot `enabled_tools` at schedule creation** (Phase A punch #7) β€” don't resolve "all RBAC-allowed" lazily at fire time, or the catalog shifting under a sleeping schedule causes least-surprise violations. +- **Runaway guards**: per-schedule max runs/day, auto-pause on repeated failure. Cadence β†’ normalized `next_run_at` (timezone-aware) so the dispatcher stays a dumb "who's due" query β€” no cron strings in the engine. +- **HeadlessAuthError from the worker β†’ pause the schedule**, don't error-spam (the grant expired / user hasn't logged in within N days). + +## 4. Open decisions + +None blocking. Governance is settled as role/auth-based (Β§1); B1/B2/B3 are pattern-following and proceed on the tiering in Β§2. diff --git a/docs/specs/tool-search-token-bloat-strategy.md b/docs/specs/tool-search-token-bloat-strategy.md new file mode 100644 index 00000000..7bfc2980 --- /dev/null +++ b/docs/specs/tool-search-token-bloat-strategy.md @@ -0,0 +1,123 @@ +# Cross-source tool-search strategy for MCP token bloat + +Status: analysis / not built +Last updated: 2026-07-04 + +## Problem + +Tool schemas for all four tool sources β€” built-in (code-interpreter/browser), +external MCP via Lambda (`mcp_external`), Gateway MCP targets, and A2A β€” are +serialized into the Bedrock `toolConfig` on **every turn**. As the catalog +grows this becomes a large, constant input-token cost (measured as the +`toolTokens` partition by `ContextAttributionHook`). There is no lazy/deferred +tool exposure today. + +## The convergence seam + +All four sources converge only at +[`BaseAgent._build_filtered_tools()`](../../backend/src/agents/main_agent/base_agent.py) β†’ +flat list β†’ Strands β†’ serialized into Bedrock `toolConfig` every turn. + +Any cross-source search MUST live at this seam β€” it is the only layer that sees +all four sources. + +## What each mechanism can cover + +- **Gateway semantic search** β€” the built-in `x_amz_bedrock_agentcore_search` + tool, enabled at gateway create with `listingMode=DEFAULT`. Embeds each + registered target's tool metadata into a serverless vector store; the agent + calls it via MCP `tools/call` and gets the top ~10–15. **Covers Gateway + targets only** β€” not built-in, not `mcp_external`, not A2A. We don't consume + it yet: `FilteredMCPClient.list_tools_sync` lists all gateway tools and ships + all matching schemas, so gateway tools currently pay full token cost. + Sub-lever: migrate `mcp_external` β†’ Gateway targets (`protocol=mcp`) to widen + vector-store coverage. + +- **FastMCP tool-search transform** β€” server-side, searches only that one + server's own tools. Not privy to our catalog. Legit only as an in-server + tactic for a single bloated FastMCP server. + +- **Anthropic native Tool Search Tool** (`tool_search_tool_bm25_20251119` / + `_regex_`) β€” **RULED OUT on our path.** It is a server-side tool; on Bedrock, + server-side tools are available via `InvokeModel` only, not Converse. Strands + drives Bedrock via `converse_stream`. (Note: `anthropic_beta` *flags* like + fine-grained-tool-streaming DO pass through Converse `additionalRequestFields`; + server-side *tools* do not β€” different mechanism.) + +- **AWS Agent Registry (Preview)** β€” a managed discovery service exposed as a + remote **MCP endpoint**. MCP servers, agents, skills, and custom resources are + published as *records*, validated against protocol schemas, curated through an + approval workflow, and searched via hybrid semantic+keyword. Auth is IAM or + corporate JWT. See [Registry as a dynamic tool-discovery tier](#registry-as-a-dynamic-tool-discovery-tier). + +## Recommended tiers + +0. **Curate first** β€” assistant/role-scoped `enabled_tools` profiles. Search only + pays off when ONE assistant needs a large universe but few tools per turn. +1. **Consume Gateway semantic search** for the gateway slice + migrate external + onto the gateway (mostly AWS-managed, ~80–90% of the win). +2. **Unified `search_tools(query)` facade** at `_build_filtered_tools`, + delegating the gateway slice to `x_amz_bedrock_agentcore_search` and indexing + the residual off the DynamoDB catalog / S3 Vectors / Bedrock KB. + +### Tier-2 gotchas + +Strands fixes the tool list at agent construction (cache keyed on `tools_hash` +in `inference_api/chat/service.py`) β†’ dynamic promotion needs an agent rebuild +or runtime tool registration (the real cost). Must **append** schemas (like +Anthropic native does), not rebuild β€” a naive rebuild invalidates the prompt +cache after the insertion point on every search and can cost more than the bloat +removed. + +## Registry as a dynamic tool-discovery tier + +AWS Agent Registry is a *discovery + governance* layer, not an execution layer β€” +it catalogs and advertises resources but does not run them. That makes it a +candidate **dynamic-discovery** answer to the token-bloat problem, distinct from +the tiers above: + +- **How it would cut bloat.** Instead of front-loading every tool schema into + `toolConfig`, the agent queries the Registry's MCP-native endpoint + semantically for the right tool/sub-agent at the point of need, and we promote + only the matched schemas at the seam. This is the same "discover then promote" + shape as Tier-2, but the index + governance are AWS-managed rather than + hand-rolled off DynamoDB/S3 Vectors. + +- **Coverage vs. Gateway semantic search.** Gateway search covers Gateway + targets only. Registry can catalog **any** resource type β€” MCP servers, + agents, skills, custom records β€” so in principle it spans sources the Gateway + vector store never sees (`mcp_external`, A2A leaf agents, Harness sub-agents). + Registry advertises/governs; the Gateway still *connects* the tool for + invocation. They are complementary, not overlapping. + +- **Bonus: governance for free.** The approval workflow + curation is exactly the + admin/RBAC posture in the admin-skills plan β€” an admin curates which tools and + sub-agents are discoverable, rather than us building that workflow. + +- **Still needs the seam.** Registry returns *records* (metadata + how to reach + the resource), not live Strands tool objects. Discovery via Registry still + terminates at `_build_filtered_tools` promotion + the Tier-2 append-not-rebuild + constraint. Registry replaces the *index*, not the promotion machinery. + +- **Caveats.** Preview / region-gated β€” not for a delivery-critical path yet. + Registries are created via AgentCore control APIs, not CDK β€” a parallel + provisioning surface to own (same posture as Harness and Gateway targets). + +**Where it sits in the plan:** an alternative index backing Tier-2, and the +natural discovery/governance layer if we adopt Harness-as-leaf-sub-agents. Worth +a spike *after* the Tier-1 Gateway-search measurement, not before β€” Tier-1 is +lower-risk and mostly AWS-managed. + +## Suggested next step + +1-day Tier-1 spike β€” dev gateway `DEFAULT`/semantic, agent calls +`x_amz_bedrock_agentcore_search` instead of the full list, measure `toolTokens` +before/after. + +## Related + +- `project_gateway_3lo_listing_mode` β€” `DEFAULT` co-gates 3LO + semantic search +- `project_issue419_gateway_target_registration` +- `project_context_attribution_prototype` β€” `toolTokens` partition is the measurement +- `project_skills_registry_tool_binding` β€” Registry for governance/discovery over bound tools +- `docs/specs/admin-skills-rbac-tool-binding.md` β€” the RBAC/curation posture Registry approval maps onto diff --git a/docs/specs/user-markdown-memory.md b/docs/specs/user-markdown-memory.md new file mode 100644 index 00000000..49ccc982 --- /dev/null +++ b/docs/specs/user-markdown-memory.md @@ -0,0 +1,621 @@ +# Memory Spaces β€” user-owned, shareable markdown "second brains" for agents + +**Status:** Draft / proposal (reframed 2026-07-07 from "per-user markdown memory" to the **Memory Space** primitive) +**Author:** (drafted with Claude) +**Date:** 2026-06-27 Β· reframed 2026-07-07 +**Targets branch:** `develop` +**Related:** skills reference-file / progressive-disclosure pattern (`agents/main_agent/skills/`, `apis/shared/skills/resource_store.py`); AgentCore Memory write-only limitation (`agents/main_agent/session/turn_based_session_manager.py`); context attribution (`apis/shared/costs/`); assistant sharing / collaborative editing (issue #113, `resolve_assistant_permission`); agentic-platform primitives epic F5 (`docs/specs/agentic-platform-primitives.md`); scheduled runs (`apis/app_api/schedules/`) + +## Summary + +Give every user one or more **Memory Spaces** β€” named, human-readable **markdown wikis** that +agents read at the start of a conversation and maintain over time. A Memory Space is a +per-owner (optionally **shared**) "second brain": a tiny always-loaded **index** (`MEMORY.md`) +of one-line pointers, plus a set of **typed markdown entries** fetched on demand. The shape is +the one Karpathy popularized and the one this very repo's coding agent uses internally. + +The reframe from the original draft: memory is **not** "the user's one flat pile of facts." It +is a **first-class, named, bindable primitive** β€” a Memory Space β€” that a user can have several +of, that agents **bind** to declaratively, that ships with **templates** (Chief of Staff, +Research Notebook, blank wiki), and that can be **shared** with other users. **Oliver is not a +feature; Oliver is a "Chief of Staff" template + an agent bound to a space.** + +The architectural move is that we already ship this mechanism β€” it's the **skills reference-file +/ progressive-disclosure** path ([`skill_registry.read_resource`](../../backend/src/agents/main_agent/skills/skill_registry.py), +[`SkillResourceStore`](../../backend/src/apis/shared/skills/resource_store.py)): S3 +content-addressed storage, a lightweight DynamoDB manifest, **server-side read +mid-conversation**, and a Level-1 catalog injected into the system prompt with Level-2+ files +fetched via a tool. A Memory Space is that mechanism **re-scoped from per-skill to per-space**, +plus a **write/consolidation** path, a **binding** model, and a **sharing** model. + +This is the right design (vs. leaning on AgentCore Memory) because **AgentCore Memory is +write-only in cloud** β€” the SDK restore branch never fires, so Memory cannot be the read-time +source of truth today (see +[`project_session_restore_writeonly_memory`](../../backend/src/agents/main_agent/session/turn_based_session_manager.py) +analysis). Managed AgentCore memory (on-by-default on a harness) covers the **opaque +conversational-continuity** slice; a Memory Space covers the **inspectable, editable, +entity-linked knowledge** slice. They are complementary, not competitors. + +--- + +## The abstraction (the core of this spec) + +Oliver β€” a chief-of-staff agent with full institutional memory β€” decomposes into **three +separable layers**. The middle one, generalized, is the primitive. + +``` +β”Œβ”€ AGENT (persona + behavior) ──────────────────────────┐ +β”‚ "You are Oliver… wake-up protocol… how you think" β”‚ ← instructions (assistant / harness config) +β”‚ β”‚ + bound tools (calendar, drive, …) +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ binds to (declarative) + β–Ό +β”Œβ”€ MEMORY SPACE (the primitive) ────────────────────────┐ +β”‚ MEMORY.md ← always-on index / orientation β”‚ +β”‚ entries/ people/ projects/ (entity, mutable) β”‚ ← markdown + frontmatter +β”‚ daily/ briefs/ (episodic, append-only) β”‚ + [[wikilinks]] = the graph edges +β”‚ manifest (DynamoDB) ← indexed fields β†’ query β”‚ +β”‚ members ← owner + shared grants (viewer/editor) +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ rendered by + β–Ό + SPA "Memory" panel ← view / edit / export / forget-me / share +``` + +### Two kinds of "connectedness" β€” and they live in different places + +A natural instinct is to put "how everything connects" into the agent's instructions plus a +`MEMORY.md`. That is **half right**, and the correction is the whole point of making this a +primitive rather than a prompt convention: + +| Kind | What it is | Where it lives | Enforced by | +|---|---|---|---| +| **Structural** | *which* space(s) an agent reads/writes, access mode, which entries always-load, entry schema, who may read/write | **Declarative config** on the agent record + the space record | **Platform** (RBAC, deterministic index hydration, edit UI, sharing) | +| **Semantic** | what the wiki *means*, how entries relate, how to reason across them | **`MEMORY.md` index + `[[wikilinks]]` in entries + agent instructions** | The **LLM** reads it | + +Putting the *structural* wiring in freeform prose ("remember to read your people files") keeps it +a brittle, unenforceable convention with no access control and no edit surface. Making the +binding **declarative** turns it into a primitive: the platform can enforce who reads/writes, +hydrate the index identically every wake-up, render the "what I remember" panel, and share the +space. **`MEMORY.md` is the content map; instructions are the behavior; the binding is config.** + +### Memory Space is the fourth bindable primitive + +The platform already binds **tools**, **skills**, and **KBs** to assistants. A **Memory Space** +binds the same way and is governed the same way: + +- **Registry / RBAC (F6):** spaces are catalogable and access-controlled exactly like tools and + skills β€” read vs. write grants, sharing, audit. (Sharing is F5 Γ— F6.) +- **Scheduler (Phase B, shipped):** binding a space to a *scheduled* run is what turns a passive + notebook into Oliver β€” a nightly run scans the manifest for stale commitments and surfaces + them unprompted. "Presence, not a tool" = Memory Space (F5) + proactive trigger (already built). + +--- + +## Concepts (glossary) + +- **Memory Space** β€” a named, first-class container (`SPACE#{space_id}`) holding an index + (`MEMORY.md`), a set of typed entries, a DynamoDB manifest, and a member list. Owned by one + user; optionally shared with others (viewer/editor). A user may own/belong to many. +- **Entry** β€” one markdown file with frontmatter. Three built-in **entry types**: + - `entity` β€” a mutable record keyed by subject (a person, a project). Updated in place. + - `episodic` β€” an append-only, dated record (a daily log, a brief). Latest N ride the index. + - `fact` β€” a flat distilled fact (the original spec's unit). The catch-all. +- **Space Template** β€” a preset that seeds a new space: which entry types, always-load rules, and + a starter `MEMORY.md`. Ships: **Chief of Staff**, **Research Notebook**, **Blank Wiki**. +- **Binding** β€” declarative config on an agent/assistant that lists the space(s) it reads/writes, + the access mode, and the always-load manifest (which entries hydrate at wake-up). +- **Member / grant** β€” a `(user, role)` pair on a space. Roles: `owner`, `editor`, `viewer` + (mirrors assistant sharing, issue #113). + +--- + +### Prior art + +- **Karpathy's LLM Wiki / second brain** β€” agent-maintained markdown wiki: immutable raw + sources, an AI-generated/-maintained wiki layer, and a schema file governing read/write/ + reconcile. Core claim: LLMs don't get bored, so the wiki-maintenance burden that kills human + wikis disappears. ([gist](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)) +- **MRAgent β€” "Memory is Reconstructed, Not Retrieved"** ([arxiv 2606.06036](https://arxiv.org/abs/2606.06036), + [repo](https://github.com/Ji-shuo/MRAgent)) β€” interleaves reasoning with memory access rather + than a static retrieve-then-reason step. Transferable lesson: **don't pre-stuff context; let + the agent fetch on demand inside its loop.** +- **This repo's own coding-agent memory** β€” `MEMORY.md` index + one-fact-per-file markdown with + frontmatter (`name`/`description`/`type`), `[[wikilinks]]`, a consolidation pass. The cleanest + reference implementation of exactly what we'd build. +- **The Oliver skill** (`oliver:oliver`, `~/Documents/memory/`) β€” a *live, working* instance of + this exact pattern: `MEMORY.md` index + `people/` and `projects/` entity files + `daily/` and + `briefs/` episodic files, with a wake-up protocol that always-loads the index + latest daily + + latest brief, then pulls entries on demand. Oliver is the worked example this spec generalizes + (see [Oliver as a template](#oliver-as-a-worked-example)). + +## Goals + +- A **Memory Space** primitive: named, durable, **human-readable** markdown a user can own several + of, that agents read at conversation start and update over time. +- **Templated** β€” new spaces seed from Chief of Staff / Research Notebook / Blank so ergonomics + come for free without hardcoding any one use case. +- **Bindable** β€” an agent/assistant declares which space(s) it uses and how, as config. +- **Token-bounded**: steady-state cost is the index only; entry bodies are lazy. +- **User-visible & user-editable** β€” "here's what I remember," with edit / delete / export. This + is a product feature (control over what's remembered), and it's how agent-written memory stays + correctable. +- **User-owned & portable** β€” a user can **download the entire space as a `.zip`** of its raw + markdown (index + all entries, directory structure preserved) at any time. Full ownership, zero + lock-in: the export is the complete, human-readable, re-importable corpus β€” the same property + `agentcore export harness` gives on the run side, and something vector RAG can't offer. See + [Export / download](#9-export--download-full-ownership). +- **Shareable** β€” a space can be shared with other users (viewer/editor), enabling team wikis and + shared institutional memory (phased; see [Sharing](#sharing--access-control)). +- Reuse the **skills reference-file mechanism** (S3 store + DynamoDB manifest + on-demand read + tool) re-scoped to `SPACE#{space_id}`, with minimal new infrastructure. +- Ship **dark behind a flag** (`MEMORY_SPACES_ENABLED`, default off), per-environment enable, + mirroring `SKILLS_ENABLED`. + +## Non-goals (v1) + +- A graph/vector memory (MRAgent's full Cue–Tag–Content graph). v1 is markdown + an index + + a few indexed manifest fields; the *reconstruction* lesson is adopted (on-demand fetch), the + graph store is not. Wikilinks are the edges. +- Importing raw source documents into a wiki (Karpathy's "raw sources" layer). A space holds + distilled entries, not a document corpus β€” that overlaps the existing RAG/assistant KB. +- Cross-space linking. A `[[wikilink]]` resolves **within** its space in v1. +- **Full org-shared memory in the first release** β€” the storage is *keyed for sharing from day + one* (see below), but the grant/collaboration surface is a distinct later phase (A4). Sequencing, + not a governance gate β€” access control is identity-based and inherits the platform posture. +- Replacing AgentCore Memory or the compaction/summary path β€” this is additive. + +--- + +## Background: the pattern we're extending + +Skills already do per-skill progressive disclosure end-to-end. Much of this spec is re-pointing +it at spaces and adding writes + binding + sharing. + +``` +SkillResourceRef (DynamoDB manifest row) ← lightweight pointer, no bytes + filename, content_hash, size, content_type, s3_key + +SkillResourceStore (S3, content-addressed) ← put() dedupes on hash, get() fetches + skills/{skill_id}/{content_hash} + +Runtime: + get_catalog() β†’ Level-1 listing injected into system prompt + get_resource_names() β†’ filenames offered to the model (no bytes) + read_resource(name) β†’ server-side S3 fetch mid-conversation, returns text +``` + +Concrete anchors: +- Manifest model: [`SkillResourceRef`](../../backend/src/apis/shared/skills/models.py) +- S3 store: [`SkillResourceStore`](../../backend/src/apis/shared/skills/resource_store.py) +- Runtime disclosure: [`skill_registry.py`](../../backend/src/agents/main_agent/skills/skill_registry.py) (`get_catalog`, `get_resource_names`, `read_resource`) +- Bucket construct: [`skill-resources-construct.ts`](../../infrastructure/lib/constructs/skills/skill-resources-construct.ts) +- System-prompt assembly: [`system_prompt_builder.py`](../../backend/src/agents/main_agent/core/system_prompt_builder.py), per-turn resolution in [`system_prompt_resolver.py`](../../backend/src/apis/inference_api/chat/system_prompt_resolver.py) +- Sharing chokepoint to mirror: `resolve_assistant_permission` (issue #113, assistant viewer/editor) +- Token accounting we'll reuse: `contextBreakdown` partitions in [`costs/models.py`](../../backend/src/apis/shared/costs/models.py) + +**The asymmetries that matter:** skill resources are **read-only, admin-authored, per-skill**; a +Memory Space is **read-write, user/agent-authored, per-space, and shareable**. The store, +manifest, and read tool transfer directly. The new work is (1) the **write + consolidation** +path, (2) the **binding** model, and (3) the **sharing / access-control** model. + +--- + +## Design + +### 1. Storage layout β€” keyed by **space**, not by user + +Sharing forces the identity decision up front: a shared space **cannot** live under any one +user's partition. The space is its own entity; ownership and membership are records *about* it. + +``` +S3 (content-addressed, one bucket): + spaces/{space_id}/MEMORY.md # the index β€” small, always-loaded + spaces/{space_id}/entries/{type}/{slug}.md # one entry per file, fetched on demand + entries/entity/jane-doe.md + entries/project/agentcore-v2.md + entries/episodic/2026-07-07-daily.md +``` + +``` +DynamoDB (dedicated `memory-spaces` table β€” the project uses per-domain tables, +not one global table; a dedicated table avoids GSI-number collisions and lets the +MemorySpacesConstruct own both the bucket and the table): + PK=SPACE#{space_id} SK=META # space name, template, owner_id, created, index pointer + PK=SPACE#{space_id} SK=INDEX # manifest: [{slug,type,description,content_hash,size,updated,updated_by,indexed:{...}}] + PK=SPACE#{space_id} SK=MEMBER#{email} # role: viewer|editor (owner lives on the META row) +``` + +Owned vs. shared-in are listed via **two GSIs** unioned in code β€” mirroring +assistant sharing (owner index + share-by-email index), rather than one combined +index (the proven pattern; a single `USER#`-keyed index can't cover both an +`owner_id` and an invited-by-`email` grant): + +``` + OwnerIndex GSI1PK=OWNER#{owner_id} GSI1SK=SPACE#{space_id} # list owned spaces + MemberIndex GSI2PK=MEMBER#{email} GSI2SK=SPACE#{space_id} # list shared-in spaces +``` + +Each entry file carries frontmatter (mirrors the coding-agent memory shape; adds type + author): + +```markdown +--- +name: jane-doe +type: entity # entity | episodic | fact +description: VP Research; owns the NSF AI grant relationship +subject: Jane Doe # entity key (entity type) +status: active +commitments: # indexed β†’ manifest, so "who owes what" is a query not a scan + - { owed_by: phil, desc: "send grant draft", due: 2026-07-12, open: true } +updated: 2026-07-07 +updated_by: 18419330-… # write attribution (essential for shared spaces + forget-me) +--- + +Jane cares about defensible governance… Link related entries with [[agentcore-v2]]. +``` + +**No bodies in DynamoDB** (400 KB item-limit rule, same reason skills went to S3). The manifest +row is the fast-path pointer + cache-key input + the **indexed-field query surface** (a small +allowlist of frontmatter fields β€” e.g. `type`, `status`, `commitments.due`, `updated` β€” copied +into `indexed` so aggregate/temporal queries don't load every body). + +### 2. New shared service: `MemorySpaceStore` + `MemorySpaceService` + +New package `apis/shared/memory/` (a shared concern: both app-api and inference-api consume it, +so it lives in `apis.shared` per the import-boundary rule). + +```python +# apis/shared/memory/store.py β€” space-keyed, mirrors SkillResourceStore +class MemorySpaceStore: + def read_index(self, space_id: str) -> str: ... + def list_entries(self, space_id: str, *, type: str | None = None, + where: dict | None = None) -> list[MemoryEntryRef]: ... # manifest query + def read_entry(self, space_id: str, slug: str) -> str: ... + def write_entry(self, space_id: str, slug: str, body: str, *, author: str) -> MemoryEntryRef: ... + def update_index(self, space_id: str, body: str) -> None: ... + def delete_entry(self, space_id: str, slug: str) -> None: ... + +# apis/shared/memory/service.py β€” space lifecycle + access control +class MemorySpaceService: + def create_space(self, owner: str, name: str, template: str) -> MemorySpace: ... + def list_spaces_for_user(self, user_id: str) -> list[MemorySpace]: ... # owned + shared-in (GSI) + def resolve_permission(self, space_id: str, user_id: str) -> Role | None: ...# THE chokepoint + def share(self, space_id: str, actor: str, grantee: str, role: Role) -> None: ... + def revoke(self, space_id: str, actor: str, grantee: str) -> None: ... +``` + +Mirror `SkillResourceStore`: lazy boto3 init, content-addressed objects, raise loudly on miss, +best-effort delete. Env var `S3_MEMORY_SPACES_BUCKET_NAME`. **Every read/write path routes +through `resolve_permission`** β€” the single chokepoint, exactly like `resolve_assistant_permission`. + +### 3. Binding β€” how an agent knows which space(s) it uses + +Declarative, on the assistant/agent record (not in prose): + +```jsonc +// assistant / agent config +"memorySpaces": [ + { "spaceId": "spc_oliver", "access": "readwrite", + "alwaysLoad": ["MEMORY.md", "latest:episodic/daily", "latest:episodic/brief"] } +] +``` + +- `access`: `read` | `readwrite`. Enforced against the invoking user's grant on the space (a + `readwrite` binding still requires the *user* to hold `editor`+; see [Sharing](#sharing--access-control)). +- `alwaysLoad`: which entries hydrate at wake-up. `latest:episodic/daily` is the Oliver rule + ("most recent daily + brief"), resolved from the manifest at bind time. +- Default binding = the user's **personal** space (auto-created on first use), read-write, index-only + always-load. An assistant can bind additional/other spaces. + +### 4. Read path β€” index in prompt, entries on demand + +**Index injection (Level 1).** At conversation start, inject each bound space's `MEMORY.md` +(plus `alwaysLoad` entries) as a bounded block. Only the index is pre-loaded; bodies are +reconstructed on demand (MRAgent discipline). + +> **Prompt-cache constraint (decide here).** Per-owner content in the system prefix gives each +> user a distinct cache key and interacts badly with shared-prefix caching +> ([`system_prompt_resolver.py`](../../backend/src/apis/inference_api/chat/system_prompt_resolver.py)). +> - **(A) Dedicated cache block.** Append the memory index as its own `cache_control` breakpoint +> after the shared platform prefix. Platform prefix stays globally cached; the index caches +> *within* the session. **Preferred.** *Bonus for shared spaces:* a shared space's index is +> byte-identical across members, so its cache block can be reused across all members of the +> space β€” a caching **win** unique to shared spaces. +> - **(B) Tool-loaded on turn 1.** Agent calls `memory_index(space)` on turn 1. Zero prefix +> disruption, one extra round-trip. +> +> Recommend **(A)**; spike both and measure with `contextBreakdown`. + +**Entry fetch (Level 2).** `memory_read(space, slug)` fetches one entry body server-side from S3 +β€” a direct analog of `read_resource`. `memory_query(space, where)` runs a manifest query (e.g. +`{type: entity, "commitments.open": true}`) returning refs, not bodies β€” this is how "who owes +what" and staleness scans avoid a full-corpus load. + +### 5. Write path + +- **(W1) Synchronous agentic write** β€” `memory_write(space, slug, body)` / + `memory_update(space, slug, patch)` tools the model calls mid-turn, stamping `updated_by` with + the invoking user, plus an index-update step. Transparent, matches the coding-agent model. + **v1 default.** +- **(W2) Async post-turn reflection** β€” a background job reads the completed turn and proposes + edits, hung off [`turn_based_session_manager.update_after_turn`](../../backend/src/agents/main_agent/session/turn_based_session_manager.py) + (the seam compaction uses). Reliable, no in-loop discipline needed, adds an LLM call/turn. + **Phase 2.** + +**Consolidation.** A periodic pass (Karpathy's insight β€” LLMs don't get bored) that merges +duplicate entries, fixes stale ones, prunes the index, enforces the index cap. Run as a +**scheduled job per space** (the shipped scheduler) or lazily on threshold. Mirrors the repo's +`consolidate-memory` skill. + +### 6. Sharing & access control + +The reason the storage is space-keyed. A space is shared by granting other users a role on it, +mirroring assistant sharing (issue #113) so the model, API shape, and SPA dialog are familiar. + +- **Roles:** `owner` (full control + manage members + delete space), `editor` (read + write + entries + index), `viewer` (read only). `resolve_permission` is the chokepoint every route and + every agent tool passes through β€” no write path bypasses it. +- **Agent writes in a shared space carry the invoking user's identity.** The run-as-user model + (headless-grant / act-as-user, already built for scheduled runs) means an agent's + `memory_write` executes *as* the invoking user; the platform checks that user holds `editor`+. + A scheduled Oliver run writing to a shared team space writes as its owner, audited by + `updated_by`. +- **Write attribution + audit.** Every entry and manifest row carries `updated_by`; shared-space + edits are attributable. This is both a collaboration affordance ("Jane last edited this") and + the audit trail. +- **Concurrency.** Multi-writer becomes real in shared spaces. Content-addressed entry writes + + **optimistic concurrency on the manifest row** (conditional update on a version attribute); + last-write-wins on individual entries with a visible "edited by X at T" so overwrites are + legible, not silent. +- **Membership API** (mirrors #113): `POST /spaces/{id}/shares`, `PATCH /spaces/{id}/shares` + (upgrade viewer↔editor), `DELETE /spaces/{id}/shares/{user}`. `sharedWith` is a + `ShareEntry[]` (role-carrying), not `string[]`. + +### 7. Infrastructure + +New `MemorySpacesConstruct` (clone of [`skill-resources-construct.ts`](../../infrastructure/lib/constructs/skills/skill-resources-construct.ts)): +S3 bucket (encryption **matching the platform's sessions/artifacts standard** β€” don't +special-case memory), block public access, enforce SSL, **no auto-expiry** (memory is durable; +deletion is explicit/user-driven β€” see [Data governance](#data-governance-proportionate--not-a-special-category) on the dedup-aware purge). Thread the bucket to compute roles via +`PlatformComputeRefs` (typed ref, not SSM), per the construct rules. Read+write grant to +inference-api runtime role and app-api role. The **same construct also owns a dedicated +`memory-spaces` DynamoDB table** (PK/SK, PAY_PER_REQUEST, PITR) with the `OwnerIndex` + +`MemberIndex` GSIs β€” a per-domain table (the project's actual pattern) rather than a GSI grafted +onto `sessions-metadata`, threaded to both roles as `DYNAMODB_MEMORY_SPACES_TABLE_NAME`. + +### 8. User-facing surface (app-api + SPA) + +Because a space is human-readable markdown, expose it. `app-api` routes under `/memory/spaces/` +(user-facing, `Depends(get_current_user_from_session)` β€” **not** Bearer, per the auth rule; every +handler calls `resolve_permission`): + +- `GET /memory/spaces` β€” list the user's spaces (owned + shared-in) +- `POST /memory/spaces` β€” create from a template +- `GET /memory/spaces/{id}` β€” index + entry manifest +- `GET /memory/spaces/{id}/entries/{slug}` β€” read one entry +- `PUT` / `DELETE /memory/spaces/{id}/entries/{slug}` β€” user edits/deletes an entry (editor+) +- `POST|PATCH|DELETE /memory/spaces/{id}/shares[...]` β€” manage members (owner) +- `GET /memory/spaces/{id}/export` β€” **download the whole space as a `.zip`** (see Β§9) +- `DELETE /memory/spaces/{id}` β€” delete (owner) / for a shared-in space, **leave** (drop own grant) + +SPA: a **Memory** section β€” a list of spaces, and per space a "what I remember" panel (view, +edit, delete, **download `.zip`**), a **share dialog** (reuse the assistant-share component + +`redesign-tokens`), and a **create-from-template** flow. This is the differentiator vector RAG +can't offer β€” and the user's control surface over what's remembered. + +### 9. Export / download (full ownership) + +A user can download the **entire space** as a single `.zip` of its raw markdown at any time β€” +the concrete expression of "you own this data." Because a space *is* human-readable markdown, +the export is loss-free: it is the complete corpus, not a rendering of it. + +- **Contents.** The zip mirrors the S3 layout so it is self-contained and re-importable: + ``` + {space-name}/ + MEMORY.md # the index, verbatim + entries/entity/*.md # every entry, with frontmatter intact + entries/episodic/*.md + entries/fact/*.md + metadata.json # space name, template, created, members (roles), export timestamp + ``` + Entry frontmatter (`type`, `updated`, `updated_by`, `[[wikilinks]]`, indexed fields) **is** the + source of truth; the DynamoDB manifest is a derived cache and is *not* needed in the export β€” + it can be rebuilt from the files on import. `metadata.json` carries the small amount of + space-level state the files don't. +- **Access.** Any member with read (viewer+) may export the content they can already read; the + owner exports the full space. Routes through `resolve_permission` like every other path. +- **Mechanics.** Built server-side in `app-api`: read the manifest β†’ `get` each object from the + content-addressed store β†’ stream a zip response (`Content-Disposition: attachment`). Stream + rather than buffer so large spaces don't pin memory; the entry count is bounded by the + consolidation cap so this stays modest. +- **Round-trip (future, non-goal v1).** The export format is deliberately import-friendly β€” a + later `POST /memory/spaces/import` could reconstruct a space (and rebuild the manifest) from + this exact zip, giving true portability between environments/accounts. Symmetry now, import + later. + +--- + +## Oliver as a worked example + +Oliver is **not** special-cased. It is: + +1. A **space** created from the **Chief of Staff** template, which seeds: + - entry types: `entity` (people, projects), `episodic` (daily, briefs) + - `alwaysLoad`: `[MEMORY.md, latest:episodic/daily, latest:episodic/brief]` + - a starter `MEMORY.md` with sections for strategic priorities, key people, active projects, + open commitments. +2. An **assistant** ("Oliver") whose **instructions** carry the persona + wake-up protocol + + how-to-think, **bound** to that space `readwrite`. +3. Optional: the space **shared** `viewer` with a chief-of-staff's delegate, or a scheduled run + bound to it that scans `memory_query(space, {"commitments.open": true, "commitments.due": "<7d"})` + nightly and surfaces stale commitments β€” the proactive behavior, built from primitives. + +Every capability Oliver has ("who owes what," "prep me for my 2pm," "someone's owed Phil for two +weeks β€” flag it") maps to: index always-load + `memory_query` over indexed commitment fields + +entity entries + the scheduler. **A "Research Notebook" space with a different template and a +different bound assistant reuses all of it.** + +--- + +## Token efficiency analysis + +- **Steady-state overhead = index only.** ~1 line (~15–25 tokens) per entry. 50 entries β‰ˆ + **~1k tok/turn**; 200 β‰ˆ **~4k tok/turn**. Bounded/tunable via the consolidation cap. Entry + bodies cost **zero** unless fetched. +- **Lazy bodies (MRAgent discipline).** You pay a body's tokens only on turns that fetch it. +- **Indexed queries beat scans.** "Who owes what" is a manifest query over `commitments.open`, + not a load of every person file β€” the difference between O(1 row set) and O(all bodies). +- **Shared-space cache win.** A shared index caches once across all members (strategy A block), + unlike per-user memory. +- **Versus alternatives:** transcript replay is unbounded (what compaction fights); vector RAG + pays embedding + opaque chunk injection and isn't user-inspectable; AgentCore summaries are + unusable for read in cloud. +- **Measurement.** Add a `memory` partition to `contextBreakdown` (same method skills PR-7 used to + confirm `toolTokens` dropped). + +**Net:** capped index + lazy fetch + indexed queries β‡’ **~1–4k tok/turn steady-state**, far +cheaper and more controllable than replay or RAG. The one cost risk is prompt-cache disruption, +mitigated by strategy (A). + +--- + +## Data governance (proportionate β€” not a special category) + +**A Memory Space is the same data class as the content the platform already stores** β€” session +transcripts, artifacts, assistant KBs, uploaded docs β€” behind the same **Entra-backed JWT + RBAC**. +It is durable, sometimes model-derived, and (when shared) cross-user, but every one of those +properties is already true of stored conversations. Memory introduces **no new legal boundary** +that transcripts don't already cross, so it **inherits the platform's existing data-governance +posture** rather than needing a bespoke one. Access control is the whole of the exposure story, +and identity claims already own it: `resolve_permission` gates every read/write, exactly like the +rest of the app. + +Governance is enforced by **identity, not by content inspection.** There is no agentic-write +redaction / content-scrubbing pass β€” a shared space is a *deliberate grant*, and the sharing user +is responsible for its contents, exactly as when they share an assistant or a document today. Do +not add friction the rest of the platform doesn't have. + +What *is* worth doing β€” three cheap defaults, none of which gate the build: + +- **Encryption at rest = inherit the platform standard.** Use whatever the sessions/artifacts + buckets use (CMK or AES256); don't special-case memory. +- **Deletion must actually purge.** The one genuine operational wrinkle: memory is durable by + design (no TTL) and the store is content-addressed/deduped, so a delete/offboarding path must + really remove the bytes (mind the dedup β€” a shared object may back multiple entries). This is a + "make delete work" engineering detail, not a compliance project. +- **Agent-write visibility = the feature you already want.** Because the model writes memory, the + user can see and correct what's remembered via the "what I remember" panel (Β§8). The governance + win falls out of the feature for free β€” zero added friction. + +Plus one UX line: the **share dialog states the scope** ("sharing includes current and future +entries, including ones the agent writes later"), since entries accrue after the grant. That's a +sentence, not a gate. + +--- + +## Phasing (proposed PRs) β€” two workstreams + +**The boundary (decided 2026-07-07):** this epic delivers the **Memory Space primitive and its +user-facing "own your data" surface** β€” the corpus, its store/service API, and the ways a *person* +manages it (CRUD, export, sharing, the SPA panel). It does **not** deliver the ways an *agent +consumes* it: the `memory_*` tools, the declarative binding, and the system-prompt index +injection are **agent-consumption**, and they live in the **Agent / Harness workstream** (below). + +Rationale: a Memory Space is the **4th bindable primitive** (alongside tools/skills/KBs). Welding +its consumption tools/prompt-wiring into the memory epic would bind it to one agent surface +(today's `inference-api`). Keeping consumption in the Agent/Harness layer lets *any* surface β€” +interactive `inference-api`, our headless harness entrypoint, or an adopted managed Harness β€” bind +and consume the same primitive. The primitive exposes only `MemorySpaceService`; the agent layer +calls it. + +### Workstream A β€” Memory Spaces epic (primitive + user surface) + +- **A1 β€” Data layer.** βœ… (PR #582) `apis/shared/memory/` (`MemorySpaceStore` + `MemorySpaceService` + + `MemoryEntryRef`/`MemorySpace`/`MemoryIndex`/`SpaceMember` models + space-keyed repo + + `resolve_permission` + Blank/Chief-of-Staff/Research-Notebook templates). `MemorySpacesConstruct` + (CDK) = the S3 bucket + a dedicated `memory-spaces` table with `OwnerIndex`/`MemberIndex` GSIs, + wired to both compute roles (readwrite). moto tests. No runtime wiring. Flag + `MEMORY_SPACES_ENABLED`, default off. +- **A2 β€” User surface (app-api CRUD).** `app-api` `/memory/spaces/` routes over `MemorySpaceService` + (`Depends(get_current_user_from_session)`, router mounted behind the flag): list / create-from- + template / get / delete-or-leave, entry read/list/upsert/delete, index read/update. Error + translation (`NotFoundβ†’404`, `Permissionβ†’403`). Route tests. +- **A3 β€” Export / download (Β§9).** βœ… `GET /memory/spaces/{id}/export` β†’ streamed `.zip` of the raw + markdown (index + `entries//*.md` + `metadata.json`). The "own your data" leg. + `MemorySpaceService.export_space` gathers the corpus (viewer+, members only for editor+); the + route builds the zip in a `SpooledTemporaryFile` (spills to disk so a large space never pins + memory) and streams it. Archive path components are sanitized against zip-slip. Route tests. +- **A4 β€” Sharing.** βœ… Membership API (`GET|POST|PATCH|DELETE .../shares`) over the `MEMBER#` rows + (owner grants/updates/revokes viewer|editor; editor+ lists) + shared concurrency: the manifest + `INDEX` row now takes a **conditional write** on `version` (`put_index(expected_version=…)` β†’ + `OptimisticLockError`), and `write_entry`/`delete_entry` run a bounded read-modify-retry loop + (`_mutate_index`) that converges on transient races and surfaces `MemorySpaceConcurrencyError` + (β†’ 409) only on a sustained one. Access control is identity-based; no content gate. +- **A5 β€” SPA Memory panel.** βœ… The Memory section under `frontend/ai.client/src/app/memory-spaces/`: + a list page (owned + shared-in cards with role/template badges), a detail page (view/edit the + `MEMORY.md` index + entry list with view/edit/create/delete via a dialog), create-from-template + dialog, download `.zip` (blob β†’ anchor), and a share dialog (add-by-email + per-row role + delta + save over the A4 endpoints). Signal facade + API service mirror the assistants/schedules pattern; + nav entry gated on a live `accessible$` probe (404 = kill switch off β†’ hidden), redesign-tokens + throughout. Viewer = read-only. Facade spec green; dev build + tsc clean. +- **A6 β€” Consolidation.** βœ… (deterministic slice) `MemorySpaceService.consolidate(space_id)` + + `POST /memory/spaces/{id}/consolidate` (editor+) β†’ a `ConsolidationReport`. Auto-fixes only + storage hygiene β€” orphaned content-addressed objects (no manifest/index ref, from crashed/raced + writes) are GC'd. Everything needing judgment is *reported, not mutated*: duplicate content + across slugs, dead `[[slug]]` wikilinks in MEMORY.md (opt-in `stripDeadLinks` unlinks them, + keeping prose), and over-cap entry counts (`MEMORY_SPACE_INDEX_CAP`, default 200). It never + merges or evicts entries β€” that's deferred to the **LLM consolidation pass (Workstream B era)**, + which extends this exact `consolidate()` seam once agentic writes create real duplication/staleness + to act on. (Deliberately not auto-run on a schedule/threshold yet β€” on-demand only; scheduler wiring + and SPA surfacing are follow-ups.) + +### Workstream B β€” Agent / Harness consumption (binds the primitive) + +These are **not** part of the memory epic; they are how an agent surface reads/writes a bound space. +They plug into `inference-api` today and ride whatever run surface a given lane uses. + +> **Superseded framing (2026-07-07):** B1's binding no longer "extends the Assistant." It lands as a +> `memory_space` **Binding** on the new unified **Agent** record β€” see +> [`agent-designer.md`](agent-designer.md). B1 = that spec's Phase-1 memory binding kind; B2/B3 = its +> Phase-3 harness resolution. + +- **B1 β€” Binding.** `memorySpaces` declarative config on the assistant/agent record (`spaceId`, + `access`, `alwaysLoad`); resolution against the invoking user's grant; default personal-space + auto-create. This is the seam that connects an agent to a space. +- **B2 β€” Read path.** `memory_read` / `memory_query` tools (over `MemorySpaceService`) + system- + prompt index injection (strategy A, the prompt-cache decision) + a `memory` partition in + `contextBreakdown`. Wake-up hydration (`alwaysLoad`, `latest:` resolution). +- **B3 β€” Write path (agentic, W1).** `memory_write` / `memory_update` tools + `updated_by` + attribution, through the tool RBAC/registry. +- **B4 β€” Reflection writes (W2), optional.** Post-turn reflection on the `update_after_turn` seam. + Defer until W1 reliability is measured. + +--- + +## Open questions + +- **Deletion / offboarding purge** β€” confirm the dedup-aware delete path (a content-addressed + object may back multiple entries or spaces); and forget-me on a *shared-in* space = leave (drop + own grant) vs. the owner deleting the whole space. Inherit the platform's retention posture; + don't invent a memory-specific one. +- **Prompt-cache placement** β€” confirm (A) vs (B) with a spike + `contextBreakdown`; verify the + shared-index cross-member cache win holds in practice. +- **Assistant / shared conversations** β€” whose space(s) apply when a conversation is + assistant-backed or itself shared? Likely: the assistant's bound spaces, resolved against the + *invoking* user's grants. +- **Indexed-field allowlist** β€” which frontmatter fields get copied into the manifest `indexed` + map? Start small (`type`, `status`, `updated`, `commitments.due`/`.open`); expand on evidence. +- **Write-trigger reliability** β€” measure how often W1 actually fires before committing to W2. +- **Index cap & eviction** β€” hard cap on index lines; consolidation decides merge vs. drop. + Start ~150 entries / ~3k tokens. +- **Cross-space linking** β€” deferred; when does a `[[wikilink]]` ever need to resolve across + spaces (e.g. a personal space referencing a shared team space)? +- **Concurrency depth** β€” is optimistic-concurrency on the manifest row enough, or do hot shared + spaces need per-entry locking / CRDT-ish merge? + +## Validation note + +Before/while building the read path (B2), walk the repo's **own** coding-agent memory (`MEMORY.md` + per-fact +files + consolidation) **and the live Oliver skill** (`~/Documents/memory/`) end-to-end as the +reference behavior β€” both are the exact pattern, already proven, and surface the +index-maintenance, entry-typing, and wikilink edge cases early. diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index fce726e6..56a4cbdf 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.0.4", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.0.4", + "version": "1.1.0", "dependencies": { "@angular/cdk": "21.2.14", "@angular/common": "21.2.17", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index 64f88a38..e9798a10 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.0.4", + "version": "1.1.0", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/frontend/ai.client/public/logos/google-calendar.svg b/frontend/ai.client/public/logos/google-calendar.svg new file mode 100644 index 00000000..0db80042 --- /dev/null +++ b/frontend/ai.client/public/logos/google-calendar.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/ai.client/public/logos/google-docs.svg b/frontend/ai.client/public/logos/google-docs.svg new file mode 100644 index 00000000..059b4dd5 --- /dev/null +++ b/frontend/ai.client/public/logos/google-docs.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/ai.client/public/logos/google-drive.svg b/frontend/ai.client/public/logos/google-drive.svg new file mode 100644 index 00000000..f73d93ba --- /dev/null +++ b/frontend/ai.client/public/logos/google-drive.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/ai.client/public/logos/google-gmail.svg b/frontend/ai.client/public/logos/google-gmail.svg new file mode 100644 index 00000000..66758568 --- /dev/null +++ b/frontend/ai.client/public/logos/google-gmail.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/ai.client/src/app/agents/agent-form/agent-form.page.css b/frontend/ai.client/src/app/agents/agent-form/agent-form.page.css new file mode 100644 index 00000000..f4fabed1 --- /dev/null +++ b/frontend/ai.client/src/app/agents/agent-form/agent-form.page.css @@ -0,0 +1 @@ +/* Agent Designer form β€” layout handled by Tailwind utilities in the template. */ diff --git a/frontend/ai.client/src/app/agents/agent-form/agent-form.page.html b/frontend/ai.client/src/app/agents/agent-form/agent-form.page.html new file mode 100644 index 00000000..58831f1a --- /dev/null +++ b/frontend/ai.client/src/app/agents/agent-form/agent-form.page.html @@ -0,0 +1,260 @@ +
+ +
+
+
+ + +

+ {{ mode() === 'create' ? 'New Agent' : 'Edit Agent' }} +

+
+
+ @if (mode() === 'edit' && userPermission() === 'owner') { + + } + + +
+
+
+ +
+ @if (isViewer()) { +
+ You have viewer access to this agent β€” changes can't be saved. +
+ } + + +
+

Persona

+

Name, description and the system instructions that define this agent.

+ +
+ +
+ +
+ + +
+
+ Select Emoji + +
+ +
+
+
+ @if (form.get('emoji')?.value) { + + } +
+ + +
+ + + @if (getFieldError('name'); as e) {

{{ e }}

} +
+
+ + +
+ + + @if (getFieldError('description'); as e) {

{{ e }}

} +
+ + +
+ + + @if (getFieldError('instructions'); as e) {

{{ e }}

} +
+ + +
+
+ + +
+
+ + + @if (tags.length) { +
+ @for (tag of tags; track tag) { + + {{ tag }} + + + } +
+ } +
+
+ + +
+
+ + +
+ @for (starter of starters.controls; track $index) { +
+ + +
+ } +
+
+ + +
+
+
+

The agent runs on this model. Only models your role allows are shown.

+ + @if (models().length === 0) { +

No models are available to your role.

+ } @else { +
+ @for (m of models(); track m.ref) { + + } +
+ } +
+ + + @if (tools().length) { +
+
+
+

Capabilities this agent can call.

+
+ @for (t of tools(); track t.ref) { + + } +
+
+ } + + + @if (skills().length) { +
+
+
+

Reusable instruction bundles with their own bound tools.

+
+ @for (s of skills(); track s.ref) { + + } +
+
+ } + + + @if (spaces().length) { +
+
+
+

A persistent "second brain" the agent reads from β€” and can write to with editor access.

+
+ @for (space of spaces(); track space.ref) { +
+ + + @if (isSpaceSelected(space.ref)) { + @for (sel of memorySelections(); track sel.ref) { + @if (sel.ref === space.ref) { +
+
+ Access: +
+ + +
+
+ +
+ } + } + } +
+ } +
+
+ } + + + @if (kbBinding()) { +
+
+
+

+ This agent's document knowledge base is managed automatically and is edited from the classic assistant view. +

+
+ } +
+
diff --git a/frontend/ai.client/src/app/agents/agent-form/agent-form.page.ts b/frontend/ai.client/src/app/agents/agent-form/agent-form.page.ts new file mode 100644 index 00000000..092c91e2 --- /dev/null +++ b/frontend/ai.client/src/app/agents/agent-form/agent-form.page.ts @@ -0,0 +1,409 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + signal, + computed, + OnInit, + OnDestroy, +} from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { + ReactiveFormsModule, + FormBuilder, + FormGroup, + FormArray, + FormControl, + Validators, +} from '@angular/forms'; +import { Subscription, firstValueFrom } from 'rxjs'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroArrowLeft, + heroFaceSmile, + heroXMark, + heroPlus, + heroTrash, + heroShare, + heroCpuChip, + heroWrenchScrewdriver, + heroSparkles, + heroCircleStack, + heroBookOpen, + heroCheck, +} from '@ng-icons/heroicons/outline'; +import { Dialog } from '@angular/cdk/dialog'; +import { PickerComponent } from '@ctrl/ngx-emoji-mart'; +import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay'; +import { AgentService } from '../services/agent.service'; +import { AgentBinding, BindableItem, MemorySpaceBindingConfig } from '../models/agent.model'; +import { SidenavService } from '../../services/sidenav/sidenav.service'; +import { ThemeService } from '../../components/topnav/components/theme-toggle/theme.service'; +import { ToastService } from '../../services/toast/toast.service'; +import { TooltipDirective } from '../../components/tooltip/tooltip.directive'; +import { + ShareAssistantDialogComponent, + ShareAssistantDialogData, +} from '../../assistants/components/share-assistant-dialog.component'; + +/** A memory-space selection with its per-binding config (access + alwaysLoad). */ +interface MemorySelection { + ref: string; + label: string; + role: string; + access: 'read' | 'readwrite'; + alwaysLoadIndex: boolean; // maps to alwaysLoad: ['MEMORY.md'] +} + +/** + * Agent Designer β€” the authoring surface (Phase 4). A persona + a governed model + * single-select + binding pickers (tools, skills, memory spaces), each populated + * from `GET /agents/bindable?kind=…` so the user only sees what their role enables + * (D4). The KB is welded to the agent (synthesized on read, not author-settable) so + * it is shown read-only. Mirrors the write-side rules in `binding_validation`. + */ +@Component({ + selector: 'app-agent-form-page', + templateUrl: './agent-form.page.html', + styleUrl: './agent-form.page.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ReactiveFormsModule, + NgIcon, + RouterLink, + PickerComponent, + CdkOverlayOrigin, + CdkConnectedOverlay, + TooltipDirective, + ], + providers: [ + provideIcons({ + heroArrowLeft, + heroFaceSmile, + heroXMark, + heroPlus, + heroTrash, + heroShare, + heroCpuChip, + heroWrenchScrewdriver, + heroSparkles, + heroCircleStack, + heroBookOpen, + heroCheck, + }), + ], +}) +export class AgentFormPage implements OnInit, OnDestroy { + private fb = inject(FormBuilder); + private route = inject(ActivatedRoute); + private router = inject(Router); + private agentService = inject(AgentService); + private sidenavService = inject(SidenavService); + private themeService = inject(ThemeService); + private toast = inject(ToastService); + private dialog = inject(Dialog); + + form!: FormGroup; + private formSub?: Subscription; + + readonly agentId = signal(null); + readonly saving = signal(false); + readonly loadingAgent = signal(false); + readonly userPermission = signal<'owner' | 'editor' | 'viewer'>('owner'); + readonly isEmojiPickerOpen = signal(false); + readonly isDarkMode = this.themeService.theme; + + readonly mode = computed<'create' | 'edit'>(() => (this.agentId() ? 'edit' : 'create')); + readonly isViewer = computed(() => this.userPermission() === 'viewer'); + + // Bindable palettes (RBAC-filtered) + current selections. + readonly models = signal([]); + readonly tools = signal([]); + readonly skills = signal([]); + readonly spaces = signal([]); + + readonly selectedModelId = signal(null); + readonly selectedToolRefs = signal>(new Set()); + readonly selectedSkillRefs = signal>(new Set()); + readonly memorySelections = signal([]); + /** Welded KB binding synthesized by the backend β€” displayed read-only. */ + readonly kbBinding = signal(null); + + readonly emojiPickerPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -8 }, + ]; + + get starters(): FormArray { + return this.form.get('starters') as FormArray; + } + + ngOnInit(): void { + this.sidenavService.hide(); + + this.form = this.fb.group({ + name: ['', [Validators.required, Validators.minLength(3)]], + description: ['', [Validators.required, Validators.minLength(10)]], + instructions: ['', [Validators.required, Validators.minLength(20)]], + visibility: ['PRIVATE'], + tags: [[] as string[]], + starters: this.fb.array([]), + emoji: [''], + }); + + // Load the RBAC-filtered palettes in parallel; then hydrate an existing agent. + void this.loadPalettes(); + + const id = this.route.snapshot.paramMap.get('id'); + this.agentId.set(id); + if (id) { + this.loadingAgent.set(true); + void this.loadAgent(id).finally(() => this.loadingAgent.set(false)); + } + } + + ngOnDestroy(): void { + this.sidenavService.show(); + this.formSub?.unsubscribe(); + } + + private async loadPalettes(): Promise { + const [models, tools, skills, spaces] = await Promise.all([ + this.agentService.loadBindable('model'), + this.agentService.loadBindable('tool'), + this.agentService.loadBindable('skill'), + this.agentService.loadBindable('memory_space'), + ]); + this.models.set(models); + this.tools.set(tools); + this.skills.set(skills); + this.spaces.set(spaces); + } + + private async loadAgent(id: string): Promise { + try { + const agent = await this.agentService.getAgent(id); + this.userPermission.set(agent.userPermission ?? 'owner'); + this.form.patchValue({ + name: agent.name, + description: agent.description, + instructions: agent.instructions, + visibility: agent.visibility, + tags: agent.tags ?? [], + emoji: agent.emoji ?? '', + }); + this.starters.clear(); + (agent.starters ?? []).forEach((s) => this.starters.push(new FormControl(s, Validators.required))); + + this.selectedModelId.set(agent.modelConfig?.modelId ?? null); + + const toolRefs = new Set(); + const skillRefs = new Set(); + const memory: MemorySelection[] = []; + for (const b of agent.bindings ?? []) { + if (b.kind === 'tool') toolRefs.add(b.ref); + else if (b.kind === 'skill') skillRefs.add(b.ref); + else if (b.kind === 'memory_space') { + const cfg = (b.config ?? {}) as Partial; + memory.push({ + ref: b.ref, + label: this.spaceLabel(b.ref), + role: this.spaceRole(b.ref), + access: cfg.access === 'readwrite' ? 'readwrite' : 'read', + alwaysLoadIndex: (cfg.alwaysLoad ?? []).includes('MEMORY.md'), + }); + } else if (b.kind === 'knowledge_base') { + this.kbBinding.set(b); + } + } + this.selectedToolRefs.set(toolRefs); + this.selectedSkillRefs.set(skillRefs); + this.memorySelections.set(memory); + } catch (err) { + console.error('Error loading agent:', err); + this.toast.error('Could not load this agent.'); + } + } + + private spaceLabel(ref: string): string { + return this.spaces().find((s) => s.ref === ref)?.label ?? ref; + } + + private spaceRole(ref: string): string { + return (this.spaces().find((s) => s.ref === ref)?.meta?.['role'] as string) ?? 'viewer'; + } + + // ---- persona helpers ------------------------------------------------- + addStarter(): void { + this.starters.push(new FormControl('', Validators.required)); + } + removeStarter(index: number): void { + this.starters.removeAt(index); + } + toggleEmojiPicker(): void { + this.isEmojiPickerOpen.update((o) => !o); + } + closeEmojiPicker(): void { + this.isEmojiPickerOpen.set(false); + } + onEmojiSelect(event: { emoji: { native: string } }): void { + this.form.patchValue({ emoji: event.emoji.native }); + this.closeEmojiPicker(); + } + clearEmoji(): void { + this.form.patchValue({ emoji: '' }); + } + + // ---- tags ------------------------------------------------------------ + get tags(): string[] { + return (this.form.get('tags')?.value as string[]) ?? []; + } + addTag(value: string): void { + const t = value.trim(); + if (t && !this.tags.includes(t)) { + this.form.get('tags')?.setValue([...this.tags, t]); + } + } + removeTag(tag: string): void { + this.form.get('tags')?.setValue(this.tags.filter((x) => x !== tag)); + } + + getFieldError(field: string): string | null { + const c = this.form.get(field); + if (!c || !c.touched || !c.errors) return null; + if (c.errors['required']) return 'This field is required'; + if (c.errors['minlength']) return `Minimum length is ${c.errors['minlength'].requiredLength} characters`; + return null; + } + + // ---- model ----------------------------------------------------------- + selectModel(ref: string): void { + this.selectedModelId.set(this.selectedModelId() === ref ? null : ref); + } + + // ---- tools / skills (multi-select toggles) --------------------------- + toggleTool(ref: string): void { + this.selectedToolRefs.update((set) => toggle(set, ref)); + } + isToolSelected(ref: string): boolean { + return this.selectedToolRefs().has(ref); + } + toggleSkill(ref: string): void { + this.selectedSkillRefs.update((set) => toggle(set, ref)); + } + isSkillSelected(ref: string): boolean { + return this.selectedSkillRefs().has(ref); + } + + // ---- memory spaces --------------------------------------------------- + isSpaceSelected(ref: string): boolean { + return this.memorySelections().some((m) => m.ref === ref); + } + toggleSpace(item: BindableItem): void { + this.memorySelections.update((cur) => { + if (cur.some((m) => m.ref === item.ref)) { + return cur.filter((m) => m.ref !== item.ref); + } + const role = (item.meta?.['role'] as string) ?? 'viewer'; + return [ + ...cur, + { ref: item.ref, label: item.label, role, access: 'read', alwaysLoadIndex: true }, + ]; + }); + } + /** readwrite requires editor+ on the space (D5) β€” the option is disabled otherwise. */ + canWrite(sel: MemorySelection): boolean { + return sel.role === 'owner' || sel.role === 'editor'; + } + setAccess(ref: string, access: 'read' | 'readwrite'): void { + this.memorySelections.update((cur) => + cur.map((m) => (m.ref === ref ? { ...m, access } : m)), + ); + } + toggleAlwaysLoad(ref: string): void { + this.memorySelections.update((cur) => + cur.map((m) => (m.ref === ref ? { ...m, alwaysLoadIndex: !m.alwaysLoadIndex } : m)), + ); + } + + // ---- submit ---------------------------------------------------------- + private buildBindings(): AgentBinding[] { + const bindings: AgentBinding[] = []; + for (const ref of this.selectedToolRefs()) bindings.push({ kind: 'tool', ref }); + for (const ref of this.selectedSkillRefs()) bindings.push({ kind: 'skill', ref }); + for (const m of this.memorySelections()) { + const config: Record = { access: m.access }; + if (m.alwaysLoadIndex) config['alwaysLoad'] = ['MEMORY.md']; + bindings.push({ kind: 'memory_space', ref: m.ref, config }); + } + // KB bindings are welded/synthesized β€” never sent (backend rejects an explicit one). + return bindings; + } + + async onSubmit(): Promise { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + if (!this.selectedModelId()) { + this.toast.error('Select a model for this agent.'); + return; + } + + const v = this.form.value; + const payload = { + name: v.name, + description: v.description, + instructions: v.instructions, + visibility: v.visibility, + tags: v.tags ?? [], + starters: this.starters.value ?? [], + emoji: v.emoji || undefined, + modelConfig: { modelId: this.selectedModelId()! }, + bindings: this.buildBindings(), + }; + + this.saving.set(true); + try { + if (this.mode() === 'create') { + await this.agentService.createAgent(payload); + } else { + await this.agentService.updateAgent(this.agentId()!, { ...payload, status: 'COMPLETE' }); + } + this.toast.success('Agent saved.'); + this.router.navigate(['/agents']); + } catch (err: unknown) { + const detail = (err as { error?: { detail?: string } } | null)?.error?.detail; + this.toast.error(detail ?? 'Could not save the agent.'); + } finally { + this.saving.set(false); + } + } + + onCancel(): void { + this.router.navigate(['/agents']); + } + + openShareDialog(): void { + const id = this.agentId(); + if (!id) return; + this.dialog.open(ShareAssistantDialogComponent, { + data: { + assistant: { + assistantId: id, + name: this.form.get('name')?.value || 'Agent', + visibility: this.form.get('visibility')?.value, + userPermission: this.userPermission(), + }, + } as unknown as ShareAssistantDialogData, + hasBackdrop: false, + }); + } +} + +function toggle(set: Set, ref: string): Set { + const next = new Set(set); + if (next.has(ref)) next.delete(ref); + else next.add(ref); + return next; +} diff --git a/frontend/ai.client/src/app/agents/agents.page.css b/frontend/ai.client/src/app/agents/agents.page.css new file mode 100644 index 00000000..ac25790c --- /dev/null +++ b/frontend/ai.client/src/app/agents/agents.page.css @@ -0,0 +1 @@ +/* Agents list page β€” layout handled by Tailwind utilities in the template. */ diff --git a/frontend/ai.client/src/app/agents/agents.page.html b/frontend/ai.client/src/app/agents/agents.page.html new file mode 100644 index 00000000..821eba18 --- /dev/null +++ b/frontend/ai.client/src/app/agents/agents.page.html @@ -0,0 +1,136 @@ +
+
+ +
+
+
+

+ Agents +

+

+ Compose an agent from a model, tools, skills and memory β€” governed by your role. +

+
+ + +
+
+ + +
+ @if (loading()) { +
+ @for (i of [1, 2, 3]; track i) { +
+
+
+
+
+
+
+
+
+
+ } +
+ } @else if (error()) { +
+
+ + + +
+

{{ error() }}

+
+ } @else if (agents().length === 0) { +
+
+
+

No agents yet

+

+ Create your first agent to bind a model, tools, skills and a memory space together. +

+ +
+ } @else { +
+ @for (agent of agents(); track agent.agentId) { +
+ + + + +
+ @if (modelLabel(agent); as model) { + + + } + @if (bindingCount(agent, 'tool'); as n) { + {{ n }} tool{{ n === 1 ? '' : 's' }} + } + @if (bindingCount(agent, 'skill'); as n) { + {{ n }} skill{{ n === 1 ? '' : 's' }} + } + @if (bindingCount(agent, 'memory_space'); as n) { + {{ n }} memory + } +
+ + +
+ {{ agent.visibility }} +
+ + + @if ((agent.userPermission ?? 'owner') === 'owner') { + + + } +
+
+
+ } +
+ } +
+
+
diff --git a/frontend/ai.client/src/app/agents/agents.page.ts b/frontend/ai.client/src/app/agents/agents.page.ts new file mode 100644 index 00000000..754e2a6d --- /dev/null +++ b/frontend/ai.client/src/app/agents/agents.page.ts @@ -0,0 +1,130 @@ +import { Component, ChangeDetectionStrategy, inject, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { Dialog } from '@angular/cdk/dialog'; +import { firstValueFrom } from 'rxjs'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroPlus, + heroPencilSquare, + heroChatBubbleLeftRight, + heroShare, + heroTrash, + heroCpuChip, + heroSparkles, +} from '@ng-icons/heroicons/outline'; +import { AgentService } from './services/agent.service'; +import { Agent } from './models/agent.model'; +import { + ConfirmationDialogComponent, + ConfirmationDialogData, +} from '../components/confirmation-dialog/confirmation-dialog.component'; +import { ShareAssistantDialogComponent, ShareAssistantDialogData } from '../assistants/components/share-assistant-dialog.component'; +import { TooltipDirective } from '../components/tooltip/tooltip.directive'; + +/** + * Agent Designer β€” the list surface. A sibling of the Assistants list page but + * over the `/agents` surface: each card carries the Agent's model + binding + * summary (the whole point of an Agent vs. a legacy Assistant). Share reuses the + * assistants share dialog since the records are the same (agentId == assistantId). + */ +@Component({ + selector: 'app-agents', + templateUrl: './agents.page.html', + styleUrl: './agents.page.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIcon, TooltipDirective], + providers: [ + provideIcons({ + heroPlus, + heroPencilSquare, + heroChatBubbleLeftRight, + heroShare, + heroTrash, + heroCpuChip, + heroSparkles, + }), + ], +}) +export class AgentsPage implements OnInit { + private router = inject(Router); + private agentService = inject(AgentService); + private dialog = inject(Dialog); + + readonly agents = this.agentService.agents$; + readonly loading = this.agentService.loading$; + readonly error = this.agentService.error$; + + ngOnInit(): void { + void this.load(); + } + + private async load(): Promise { + try { + await this.agentService.loadAgents(true); + } catch (err) { + console.error('Error loading agents:', err); + } + } + + /** Count bindings by kind for the card summary (KB is welded, shown separately). */ + bindingCount(agent: Agent, kind: string): number { + return agent.bindings.filter((b) => b.kind === kind).length; + } + + modelLabel(agent: Agent): string | null { + return agent.modelConfig?.modelId ?? null; + } + + async onCreateNew(): Promise { + try { + const draft = await this.agentService.createDraft({ name: 'Untitled Agent' }); + this.router.navigate(['/agents', draft.agentId, 'edit']); + } catch (err) { + console.error('Error creating draft agent:', err); + } + } + + onEdit(agent: Agent): void { + this.router.navigate(['/agents', agent.agentId, 'edit']); + } + + onChat(agent: Agent): void { + this.router.navigate(['/'], { queryParams: { assistantId: agent.agentId } }); + } + + async onShare(agent: Agent): Promise { + // The share dialog operates on an Assistant shape; agentId == assistantId and + // the share records are identical, so we adapt the Agent into what it needs. + const dialogRef = this.dialog.open(ShareAssistantDialogComponent, { + data: { + assistant: { + assistantId: agent.agentId, + name: agent.name, + visibility: agent.visibility, + userPermission: agent.userPermission ?? 'owner', + }, + } as unknown as ShareAssistantDialogData, + }); + await firstValueFrom(dialogRef.closed); + } + + async onDelete(agent: Agent): Promise { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Delete Agent', + message: `Are you sure you want to delete "${agent.name}"? This action cannot be undone.`, + confirmText: 'Delete', + cancelText: 'Cancel', + destructive: true, + } as ConfirmationDialogData, + }); + const confirmed = await firstValueFrom(dialogRef.closed); + if (confirmed) { + try { + await this.agentService.deleteAgent(agent.agentId); + } catch (err) { + console.error('Error deleting agent:', err); + } + } + } +} diff --git a/frontend/ai.client/src/app/agents/models/agent.model.ts b/frontend/ai.client/src/app/agents/models/agent.model.ts new file mode 100644 index 00000000..63c80009 --- /dev/null +++ b/frontend/ai.client/src/app/agents/models/agent.model.ts @@ -0,0 +1,119 @@ +import { ShareEntry, UserPermission } from '../../assistants/models/assistant.model'; + +/** + * Agent Designer contract (Phase 4). Mirrors the backend `/agents` surface + * (`AgentResponse` / `AgentBinding` / `AgentModelConfig` in + * `apis/shared/assistants/models.py`). An Agent evolves the Assistant: same + * underlying record (`agentId == assistantId`, legacy ids stay valid) but the + * Agent shape carries the governed `modelConfig` + uniform `bindings[]`. + */ + +export type BindingKind = 'knowledge_base' | 'tool' | 'skill' | 'memory_space'; + +/** Value stored in a `memory_space` binding's `config`. */ +export interface MemorySpaceBindingConfig { + /** Read-only injection vs. read + `memory_*` write tools (D5 gate). */ + access: 'read' | 'readwrite'; + /** Entry slugs (or `MEMORY.md`) always hydrated into the prompt. */ + alwaysLoad?: string[]; +} + +/** Governed single-select model (D3). `modelId` is the Bedrock/provider id. */ +export interface AgentModelConfig { + modelId: string; + provider?: string; + params?: Record; +} + +/** A single primitive binding on an Agent (D3). `config` is kind-specific. */ +export interface AgentBinding { + kind: BindingKind; + ref: string; + config?: Record; +} + +export interface Agent { + agentId: string; + ownerName: string; + name: string; + description: string; + instructions: string; + modelConfig?: AgentModelConfig; + bindings: AgentBinding[]; + visibility: 'PRIVATE' | 'PUBLIC' | 'SHARED'; + tags: string[]; + starters: string[]; + emoji?: string; + imageUrl?: string; + usageCount: number; + status: 'DRAFT' | 'COMPLETE'; + createdAt: string; + updatedAt: string; + + // Share metadata (present for shared agents) + firstInteracted?: boolean; + isSharedWithMe?: boolean; + userPermission?: UserPermission; +} + +export interface CreateAgentDraftRequest { + name?: string; +} + +export interface CreateAgentRequest { + name: string; + description: string; + instructions: string; + visibility?: 'PRIVATE' | 'PUBLIC' | 'SHARED'; + tags?: string[]; + starters?: string[]; + emoji?: string; + imageUrl?: string; + modelConfig?: AgentModelConfig; + bindings?: AgentBinding[]; +} + +export interface UpdateAgentRequest { + name?: string; + description?: string; + instructions?: string; + visibility?: 'PRIVATE' | 'PUBLIC' | 'SHARED'; + tags?: string[]; + starters?: string[]; + emoji?: string; + status?: 'DRAFT' | 'COMPLETE'; + imageUrl?: string; + modelConfig?: AgentModelConfig; + bindings?: AgentBinding[]; +} + +export interface AgentsListResponse { + agents: Agent[]; + nextToken?: string; +} + +export interface AgentSharesResponse { + agentId: string; + sharedWith: ShareEntry[]; +} + +/** + * Bindable-primitives catalog (Phase 2). `GET /agents/bindable?kind=…` returns + * the RBAC-filtered palette for the caller β€” a uniform item every picker + * consumes. `ref` is what the UI stores: `modelConfig.modelId` for + * `kind === 'model'`, otherwise a `binding.ref`. + */ +export type BindableKind = 'model' | BindingKind; + +export interface BindableItem { + kind: string; + ref: string; + label: string; + description: string; + meta: Record; +} + +export interface BindableListResponse { + kind: string; + items: BindableItem[]; +} diff --git a/frontend/ai.client/src/app/agents/services/agent-api.service.ts b/frontend/ai.client/src/app/agents/services/agent-api.service.ts new file mode 100644 index 00000000..61144486 --- /dev/null +++ b/frontend/ai.client/src/app/agents/services/agent-api.service.ts @@ -0,0 +1,82 @@ +import { Injectable, inject, computed } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ConfigService } from '../../services/config.service'; +import { + Agent, + AgentsListResponse, + AgentSharesResponse, + BindableKind, + BindableListResponse, + CreateAgentDraftRequest, + CreateAgentRequest, + UpdateAgentRequest, +} from '../models/agent.model'; +import { + ShareAssistantRequest, + UnshareAssistantRequest, + UpdateSharePermissionRequest, +} from '../../assistants/models/assistant.model'; + +/** + * Thin HTTP client over the `/agents` surface (Agent Designer). Mirrors + * `AssistantApiService`: raw `Observable`s, base URL from `ConfigService`, the + * signal facade (`AgentService`) owns state + error translation. Share requests + * reuse the assistants share DTOs β€” the records are the same (agentId == assistantId). + */ +@Injectable({ providedIn: 'root' }) +export class AgentApiService { + private http = inject(HttpClient); + private config = inject(ConfigService); + private readonly baseUrl = computed(() => `${this.config.appApiUrl()}/agents`); + + createDraft(request: CreateAgentDraftRequest = {}): Observable { + return this.http.post(`${this.baseUrl()}/draft`, request); + } + + createAgent(request: CreateAgentRequest): Observable { + return this.http.post(this.baseUrl(), request); + } + + getAgents(params?: { includeDrafts?: boolean }): Observable { + let httpParams = new HttpParams(); + if (params?.includeDrafts !== undefined) { + httpParams = httpParams.set('include_drafts', params.includeDrafts.toString()); + } + return this.http.get(this.baseUrl(), { params: httpParams }); + } + + getAgent(id: string): Observable { + return this.http.get(`${this.baseUrl()}/${id}`); + } + + updateAgent(id: string, request: UpdateAgentRequest): Observable { + return this.http.put(`${this.baseUrl()}/${id}`, request); + } + + deleteAgent(id: string): Observable { + return this.http.delete(`${this.baseUrl()}/${id}`); + } + + /** RBAC-filtered palette of bindable primitives of one kind (Phase 2). */ + getBindable(kind: BindableKind): Observable { + const params = new HttpParams().set('kind', kind); + return this.http.get(`${this.baseUrl()}/bindable`, { params }); + } + + shareAgent(id: string, request: ShareAssistantRequest): Observable { + return this.http.post(`${this.baseUrl()}/${id}/shares`, request); + } + + unshareAgent(id: string, request: UnshareAssistantRequest): Observable { + return this.http.delete(`${this.baseUrl()}/${id}/shares`, { body: request }); + } + + updateSharePermission(id: string, request: UpdateSharePermissionRequest): Observable { + return this.http.patch(`${this.baseUrl()}/${id}/shares`, request); + } + + getAgentShares(id: string): Observable { + return this.http.get(`${this.baseUrl()}/${id}/shares`); + } +} diff --git a/frontend/ai.client/src/app/agents/services/agent.service.spec.ts b/frontend/ai.client/src/app/agents/services/agent.service.spec.ts new file mode 100644 index 00000000..7afd6aef --- /dev/null +++ b/frontend/ai.client/src/app/agents/services/agent.service.spec.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { AgentService } from './agent.service'; +import { AgentApiService } from './agent-api.service'; +import { Agent, BindableItem } from '../models/agent.model'; + +function stubAgent(overrides: Partial = {}): Agent { + return { + agentId: 'ast-001', + ownerName: 'Alice', + name: 'My Agent', + description: 'Helpful', + instructions: 'You are helpful.', + bindings: [], + visibility: 'PRIVATE', + tags: [], + starters: [], + usageCount: 0, + status: 'COMPLETE', + createdAt: '2026-07-08T00:00:00Z', + updatedAt: '2026-07-08T00:00:00Z', + ...overrides, + }; +} + +describe('AgentService', () => { + let service: AgentService; + let mockApi: { + getAgents: ReturnType; + getAgent: ReturnType; + createDraft: ReturnType; + createAgent: ReturnType; + updateAgent: ReturnType; + deleteAgent: ReturnType; + getBindable: ReturnType; + }; + + beforeEach(() => { + TestBed.resetTestingModule(); + mockApi = { + getAgents: vi.fn(), + getAgent: vi.fn(), + createDraft: vi.fn(), + createAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + getBindable: vi.fn(), + }; + TestBed.configureTestingModule({ + providers: [AgentService, { provide: AgentApiService, useValue: mockApi }], + }); + service = TestBed.inject(AgentService); + }); + + it('loads agents and marks the feature accessible', async () => { + const agent = stubAgent(); + mockApi.getAgents.mockReturnValue(of({ agents: [agent] })); + + await service.loadAgents(); + + expect(service.agents$()).toEqual([agent]); + expect(service.accessible$()).toBe(true); + expect(service.loading$()).toBe(false); + }); + + it('marks the feature inaccessible on a 404 (kill switch off) without an error', async () => { + mockApi.getAgents.mockReturnValue(throwError(() => ({ status: 404 }))); + + await service.loadAgents(); + + expect(service.accessible$()).toBe(false); + expect(service.agents$()).toEqual([]); + expect(service.error$()).toBeNull(); + }); + + it('surfaces a real error for non-gating failures and leaves accessible unresolved', async () => { + mockApi.getAgents.mockReturnValue(throwError(() => new Error('network blip'))); + + await expect(service.loadAgents()).rejects.toThrow('network blip'); + expect(service.error$()).toBe('network blip'); + expect(service.accessible$()).toBeNull(); + }); + + it('prepends a created agent to local state', async () => { + mockApi.getAgents.mockReturnValue(of({ agents: [] })); + await service.loadAgents(); + + const created = stubAgent({ agentId: 'ast-new', name: 'New' }); + mockApi.createAgent.mockReturnValue(of(created)); + + const result = await service.createAgent({ name: 'New', description: 'd', instructions: 'i' }); + + expect(result).toEqual(created); + expect(service.agents$()[0]).toEqual(created); + }); + + it('replaces an updated agent in local state', async () => { + const agent = stubAgent(); + mockApi.getAgents.mockReturnValue(of({ agents: [agent] })); + await service.loadAgents(); + + const updated = stubAgent({ name: 'Renamed' }); + mockApi.updateAgent.mockReturnValue(of(updated)); + await service.updateAgent('ast-001', { name: 'Renamed' }); + + expect(service.agents$()[0].name).toBe('Renamed'); + }); + + it('removes a deleted agent from local state', async () => { + const agent = stubAgent(); + mockApi.getAgents.mockReturnValue(of({ agents: [agent] })); + await service.loadAgents(); + + mockApi.deleteAgent.mockReturnValue(of(undefined)); + await service.deleteAgent('ast-001'); + + expect(service.agents$()).toEqual([]); + }); + + it('memoises the bindable palette per kind and returns [] on failure', async () => { + const items: BindableItem[] = [ + { kind: 'model', ref: 'us.anthropic.claude', label: 'Claude', description: 'Bedrock', meta: {} }, + ]; + mockApi.getBindable.mockReturnValue(of({ kind: 'model', items })); + + const first = await service.loadBindable('model'); + const second = await service.loadBindable('model'); + + expect(first).toEqual(items); + expect(second).toEqual(items); + expect(mockApi.getBindable).toHaveBeenCalledTimes(1); // cached + + mockApi.getBindable.mockReturnValue(throwError(() => new Error('boom'))); + const skills = await service.loadBindable('skill'); + expect(skills).toEqual([]); // degrades gracefully + }); +}); diff --git a/frontend/ai.client/src/app/agents/services/agent.service.ts b/frontend/ai.client/src/app/agents/services/agent.service.ts new file mode 100644 index 00000000..99056277 --- /dev/null +++ b/frontend/ai.client/src/app/agents/services/agent.service.ts @@ -0,0 +1,108 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { AgentApiService } from './agent-api.service'; +import { + Agent, + BindableItem, + BindableKind, + CreateAgentDraftRequest, + CreateAgentRequest, + UpdateAgentRequest, +} from '../models/agent.model'; + +/** + * Signal-based state for the Agent Designer. Mirrors the assistants / memory-spaces + * facades: private mutable signals, readonly public views, async methods that + * round-trip through the API service and keep local state in sync. + * + * `accessible$` rides the list call the same way `MemorySpaceService` does β€” a + * successful (even empty) list means the `AGENTS_API_ENABLED` kill switch is on; + * a 404 flips it false so the nav entry and page hide gracefully. The bindable + * palette is memoised per-kind (`loadBindable`) so the pickers don't re-fetch on + * every keystroke. + */ +@Injectable({ providedIn: 'root' }) +export class AgentService { + private api = inject(AgentApiService); + + private agents = signal([]); + private loading = signal(false); + private error = signal(null); + private accessible = signal(null); + + readonly agents$ = this.agents.asReadonly(); + readonly loading$ = this.loading.asReadonly(); + readonly error$ = this.error.asReadonly(); + readonly accessible$ = this.accessible.asReadonly(); + + private bindableCache = new Map(); + + async loadAgents(includeDrafts = true): Promise { + this.loading.set(true); + this.error.set(null); + try { + const response = await firstValueFrom(this.api.getAgents({ includeDrafts })); + this.agents.set(response?.agents ?? []); + this.accessible.set(true); + } catch (err: unknown) { + const status = (err as { status?: number } | null)?.status; + if (status === 404) { + // Kill switch off β€” fail gracefully rather than surfacing an error. + this.accessible.set(false); + this.agents.set([]); + return; + } + this.error.set(err instanceof Error ? err.message : 'Failed to load agents'); + throw err; + } finally { + this.loading.set(false); + } + } + + getAgent(id: string): Promise { + return firstValueFrom(this.api.getAgent(id)); + } + + createDraft(request: CreateAgentDraftRequest = {}): Promise { + return firstValueFrom(this.api.createDraft(request)); + } + + async createAgent(request: CreateAgentRequest): Promise { + this.error.set(null); + const agent = await firstValueFrom(this.api.createAgent(request)); + this.agents.update((current) => [agent, ...current]); + return agent; + } + + async updateAgent(id: string, request: UpdateAgentRequest): Promise { + this.error.set(null); + const agent = await firstValueFrom(this.api.updateAgent(id, request)); + this.agents.update((current) => current.map((a) => (a.agentId === id ? agent : a))); + return agent; + } + + async deleteAgent(id: string): Promise { + this.error.set(null); + await firstValueFrom(this.api.deleteAgent(id)); + this.agents.update((current) => current.filter((a) => a.agentId !== id)); + } + + /** + * Fetch (and memoise) the RBAC-filtered bindable palette for a kind. Returns + * `[]` on failure so a picker degrades to "nothing available" rather than + * breaking the form. Pass `force` to bypass the cache after a mutation elsewhere. + */ + async loadBindable(kind: BindableKind, force = false): Promise { + if (!force && this.bindableCache.has(kind)) { + return this.bindableCache.get(kind)!; + } + try { + const response = await firstValueFrom(this.api.getBindable(kind)); + const items = response?.items ?? []; + this.bindableCache.set(kind, items); + return items; + } catch { + return []; + } + } +} diff --git a/frontend/ai.client/src/app/app.html b/frontend/ai.client/src/app/app.html index 09f5b8ff..e8b40355 100644 --- a/frontend/ai.client/src/app/app.html +++ b/frontend/ai.client/src/app/app.html @@ -121,4 +121,7 @@ - \ No newline at end of file + + + + \ No newline at end of file diff --git a/frontend/ai.client/src/app/app.routes.ts b/frontend/ai.client/src/app/app.routes.ts index cdfd51c0..1b386bd4 100644 --- a/frontend/ai.client/src/app/app.routes.ts +++ b/frontend/ai.client/src/app/app.routes.ts @@ -49,11 +49,51 @@ export const routes: Routes = [ loadComponent: () => import('./assistants/assistants.page').then(m => m.AssistantsPage), canActivate: [authGuard], }, + { + path: 'agents/new', + loadComponent: () => import('./agents/agent-form/agent-form.page').then(m => m.AgentFormPage), + canActivate: [authGuard], + }, + { + path: 'agents/:id/edit', + loadComponent: () => import('./agents/agent-form/agent-form.page').then(m => m.AgentFormPage), + canActivate: [authGuard], + }, + { + path: 'agents', + loadComponent: () => import('./agents/agents.page').then(m => m.AgentsPage), + canActivate: [authGuard], + }, + { + path: 'schedules/new', + loadComponent: () => import('./schedules/schedule-form/schedule-form.page').then(m => m.ScheduleFormPage), + canActivate: [authGuard], + }, + { + path: 'schedules/:scheduleId/edit', + loadComponent: () => import('./schedules/schedule-form/schedule-form.page').then(m => m.ScheduleFormPage), + canActivate: [authGuard], + }, + { + path: 'schedules', + loadComponent: () => import('./schedules/schedules.page').then(m => m.SchedulesPage), + canActivate: [authGuard], + }, { path: 'memories', loadComponent: () => import('./memory/memory-dashboard.page').then(m => m.MemoryDashboardPage), canActivate: [authGuard], }, + { + path: 'memory-spaces/:id', + loadComponent: () => import('./memory-spaces/memory-space-detail.page').then(m => m.MemorySpaceDetailPage), + canActivate: [authGuard], + }, + { + path: 'memory-spaces', + loadComponent: () => import('./memory-spaces/memory-spaces.page').then(m => m.MemorySpacesPage), + canActivate: [authGuard], + }, { path: 'manage-sessions', loadComponent: () => import('./manage-sessions/manage-sessions.page').then(m => m.ManageSessionsPage), diff --git a/frontend/ai.client/src/app/app.ts b/frontend/ai.client/src/app/app.ts index 2c920224..e9b30c62 100644 --- a/frontend/ai.client/src/app/app.ts +++ b/frontend/ai.client/src/app/app.ts @@ -3,10 +3,12 @@ import { Router, RouterOutlet } from '@angular/router'; import { Sidenav } from './components/sidenav/sidenav'; import { ErrorToastComponent } from './components/error-toast/error-toast.component'; import { ToastComponent } from './components/toast'; +import { BackgroundTaskToastsComponent } from './components/background-task-toasts/background-task-toasts.component'; import { SidenavService } from './services/sidenav/sidenav.service'; import { HeaderService } from './services/header/header.service'; import { TooltipDirective } from './components/tooltip/tooltip.directive'; import { SessionService } from './auth/session.service'; +import { SessionService as SessionListService } from './session/services/session/session.service'; import { ArtifactStateService } from './session/services/artifacts/artifact-state.service'; @Component({ @@ -16,6 +18,7 @@ import { ArtifactStateService } from './session/services/artifacts/artifact-stat Sidenav, ErrorToastComponent, ToastComponent, + BackgroundTaskToastsComponent, TooltipDirective ], templateUrl: './app.html', @@ -27,6 +30,7 @@ export class App { protected headerService = inject(HeaderService); private router = inject(Router); private session = inject(SessionService); + private sessionList = inject(SessionListService); private artifactState = inject(ArtifactStateService); /** True while an artifact pane is docked β€” content reserves right-side @@ -52,6 +56,9 @@ export class App { const handler = () => { if (document.visibilityState === 'visible') { this.session.recheck(); + // Surface unread dots from scheduled (server-side) runs that finished + // while the tab was backgrounded β€” refetch the list on return, no poll. + this.sessionList.refreshSessions(); } }; document.addEventListener('visibilitychange', handler); diff --git a/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.html b/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.html index 0ff52d82..8b45abad 100644 --- a/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.html +++ b/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.html @@ -253,8 +253,8 @@

Behavior

- -
+ +

Knowledge base

@if (webCrawlActive()) { @@ -415,42 +415,102 @@

Knowledge ba

} - - @if (isLoadingDocuments()) { + + @if (isLoadingKnowledge()) {
-

Uploaded Documents

-
-
-
-

Loading documents…

-
-
+ Loading knowledge base… +
    + @for (row of skeletonRows; track $index) { + + } +
+
+ } @else { + + @if (canManageSync() && webCrawls().length > 0) { +
+

Web sources

+
    + @for (crawl of webCrawls(); track crawl.crawlId) { +
  • +
  • + } +
- } @else if (uploadedDocuments().length > 0) { + } + + + @if (uploadedDocuments().length > 0) {

Uploaded Documents

    @for (doc of uploadedDocuments(); track doc.documentId) { -
  • -
    +
  • +
    @if (doc.status === 'complete') { - + } @else if (doc.status === 'failed') { - + } @else if (pollingDocuments().has(doc.documentId)) { -
    +
    } @else { - + }

    {{ doc.filename }}

    -
    +
    {{ formatBytes(doc.sizeBytes) }} Uploaded {{ doc.chunkCount }} chunks } + @if (canManageSync() && isDriveSyncable(doc) && reconnectLabelForDocument(doc); as provider) { + + Auto-sync from {{ provider }} + }
    @if (doc.status === 'failed' && doc.errorMessage) {

    {{ doc.errorMessage }}

    } + + @if (canManageSync() && isDriveSyncable(doc)) { + + }
    @if (doc.status === 'complete') { @@ -501,6 +583,7 @@

    Uploaded

} + }
diff --git a/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.ts b/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.ts index f5b90430..b08f85e0 100644 --- a/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.ts +++ b/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.ts @@ -56,7 +56,14 @@ import { } from '../components/web-source-dialog.component'; import { FileSourceService } from '../services/file-source.service'; import { WebSourceService } from '../services/web-source.service'; +import { SyncPolicyService } from '../services/sync-policy.service'; import { FileSourceConnector } from '../models/file-source.model'; +import { CrawlJob } from '../models/web-source.model'; +import { SyncPolicy, SyncSourceType } from '../models/sync-policy.model'; +import { + SyncPolicyControlComponent, + SyncIntervalSelection, +} from '../components/sync-policy-control.component'; import { UserConnectorsService } from '../../settings/connectors/services/user-connectors.service'; import { OAuthConsentService } from '../../services/oauth-consent/oauth-consent.service'; import { ToastService } from '../../services/toast/toast.service'; @@ -74,6 +81,7 @@ import { ToastService } from '../../services/toast/toast.service'; PickerComponent, CdkOverlayOrigin, CdkConnectedOverlay, + SyncPolicyControlComponent, ], providers: [ provideIcons({ @@ -100,6 +108,7 @@ export class AssistantFormPage implements OnInit, OnDestroy { private documentService = inject(DocumentService); private fileSourceService = inject(FileSourceService); private webSourceService = inject(WebSourceService); + private syncPolicyService = inject(SyncPolicyService); private readonly connectorsService = inject(UserConnectorsService); private readonly consentService = inject(OAuthConsentService); readonly sidenavService = inject(SidenavService); @@ -166,6 +175,47 @@ export class AssistantFormPage implements OnInit, OnDestroy { readonly webCrawlActive = signal(false); private crawlWatcherHandle: ReturnType | null = null; + // ── KB sync policies ──────────────────────────────────────────────────── + + /** Sync policies covering this assistant's sources. Loaded for owners/editors only. */ + readonly syncPolicies = signal([]); + /** All crawl jobs (any status) β€” completed crawls are the syncable web sources. */ + readonly webCrawls = signal([]); + /** + * True while the crawl catalog is doing its initial fetch. Mirrors + * {@link isLoadingDocuments} so both knowledge lists share one loading gate + * and reveal together β€” previously crawls had no loading flag, so the Web + * sources list popped into existence after its network round-trip. + */ + readonly isLoadingCrawls = signal(false); + /** Source refs (document/crawl ids) with a sync mutation in flight. */ + readonly syncBusySourceRefs = signal>(new Set()); + /** Provider whose consent popup was opened from a "Reconnect" affordance. */ + readonly reconnectingProviderId = signal(null); + + /** + * Sync controls are owner/editor-only: the backend edit-gates the whole + * sync-policy surface, so viewers never see the controls (and we never + * issue the list call that would 403 for them). + */ + readonly canManageSync = computed( + () => this.mode() === 'edit' && this.userPermission() !== 'viewer', + ); + + private readonly policiesBySourceRef = computed( + () => new Map(this.syncPolicies().map((p) => [p.sourceRef, p])), + ); + + /** + * Crawls that can carry a sync policy. A `running` crawl is excluded β€” + * its page set is still forming β€” but it still renders in the web-sources + * list with a progress note. + */ + readonly syncableCrawlStatuses: ReadonlySet = new Set([ + 'complete', + 'failed', + ]); + /** * True once at least one document exists, is uploading, or is still * loading. Drives swapping the full drop zone for a compact "Add files" @@ -175,9 +225,29 @@ export class AssistantFormPage implements OnInit, OnDestroy { () => this.uploadedDocuments().length > 0 || this.currentUpload() !== null || - this.isLoadingDocuments(), + this.isLoadingDocuments() || + this.isLoadingCrawls(), + ); + + /** + * Single initial-load gate for the two knowledge lists (Web sources + + * Uploaded Documents). While true a skeleton stands in for both; when it + * clears they render together, so neither list pops in after the other. + */ + readonly isLoadingKnowledge = computed( + () => this.isLoadingDocuments() || this.isLoadingCrawls(), ); + /** + * Varied bar widths (percent) for the knowledge skeleton rows, so the + * placeholder reads as content rather than a repeating pattern. + */ + readonly skeletonRows: ReadonlyArray<{ title: number; meta: number }> = [ + { title: 58, meta: 34 }, + { title: 72, meta: 42 }, + { title: 46, meta: 28 }, + ]; + form!: FormGroup; // Emoji picker positioning - opens below and to the right @@ -211,6 +281,20 @@ export class AssistantFormPage implements OnInit, OnDestroy { if (!completion || !completion.providerId) { return; } + // A consent kicked off from a sync-control "Reconnect" affordance: + // a fresh consent auto-resumes paused_reauth policies server-side, + // so all that's left here is to refetch and confirm. + if (completion.providerId === this.reconnectingProviderId()) { + this.consentService.acknowledgeCompletion(); + this.finishReconnect(); + if (completion.status === 'success') { + this.toast.success('Reconnected β€” sync will resume automatically.'); + void this.loadSyncData(); + } else { + this.toast.error(completion.error ?? 'Could not reconnect the content source.'); + } + return; + } const connecting = this.connectingProviderId(); if (completion.providerId !== connecting) { return; @@ -234,6 +318,10 @@ export class AssistantFormPage implements OnInit, OnDestroy { this.connectingProviderId.set(null); this.connectPhase.set(null); } + const reconnecting = this.reconnectingProviderId(); + if (reconnecting && this.reconnectAwaiting && !inFlight.has(reconnecting)) { + this.finishReconnect(); + } }); } @@ -258,9 +346,17 @@ export class AssistantFormPage implements OnInit, OnDestroy { status: ['DRAFT'], }); - // If editing, load the assistant data and documents + // If editing, load the assistant data and documents. Sync data waits on + // loadAssistant because it needs the resolved userPermission β€” the + // sync-policy surface is edit-gated and viewers must not trigger a 403. if (id) { - this.loadAssistant(id); + // Raise both knowledge-load flags synchronously so the very first paint + // shows the skeleton (not an empty flash). loadSyncData waits on the + // resolved permission, so isLoadingCrawls holds the gate until then; it + // is cleared there, including on the viewer early-return. + this.isLoadingDocuments.set(true); + this.isLoadingCrawls.set(true); + void this.loadAssistant(id).then(() => this.loadSyncData()); this.loadDocuments(); } @@ -644,6 +740,9 @@ export class AssistantFormPage implements OnInit, OnDestroy { this.toast.success('Crawling web content…'); await this.loadDocuments(); this.startCrawlWatcher(); + // Surface the new crawl in the web-sources list right away (it shows + // as running until the watcher sees it finish). + void this.loadSyncData(); } } @@ -673,6 +772,9 @@ export class AssistantFormPage implements OnInit, OnDestroy { // tick and the server reporting "no crawls running" β€” still // incremental, so no list-wide refresh. await this.discoverNewDocuments(); + // The crawl just went terminal β€” refresh the web-sources list so + // its row flips from "Crawling…" to a syncable source. + void this.loadSyncData(); return; } await this.discoverNewDocuments(); @@ -891,6 +993,12 @@ export class AssistantFormPage implements OnInit, OnDestroy { try { await this.documentService.deleteDocument(assistantId, documentId); + // The backend cascades sync policies with their source β€” mirror that + // locally so a covering policy's control disappears with the row. + const covering = this.syncPolicyFor(documentId); + if (covering) { + this.removePolicy(covering.policyId); + } } catch (error) { this.uploadedDocuments.set(previousDocs); const message = @@ -899,6 +1007,256 @@ export class AssistantFormPage implements OnInit, OnDestroy { } } + // ── KB sync policy actions ────────────────────────────────────────────── + + /** True while the reconnect consent popup is open (guards the abort effect). */ + private reconnectAwaiting = false; + /** Source ref whose row is busy because of an in-flight reconnect. */ + private reconnectSourceRef: string | null = null; + + /** + * Load sync policies + the crawl catalog for owners/editors. Best-effort: + * the sync surface is secondary to the document editor, so a failure logs + * and leaves the controls at their previous state instead of blocking. + */ + private async loadSyncData(): Promise { + const assistantId = this.assistantId(); + if (!assistantId || !this.canManageSync()) { + // Viewers (and create mode) never load crawls β€” release the gate so the + // document list can reveal. + this.isLoadingCrawls.set(false); + return; + } + try { + const [policies, crawls] = await Promise.allSettled([ + this.syncPolicyService.listPolicies(assistantId), + this.webSourceService.listCrawls(assistantId), + ]); + if (policies.status === 'fulfilled') { + this.syncPolicies.set(policies.value); + } else { + console.error('Error loading sync policies:', policies.reason); + } + if (crawls.status === 'fulfilled') { + this.webCrawls.set(crawls.value); + } else { + console.error('Error loading web sources:', crawls.reason); + } + } finally { + this.isLoadingCrawls.set(false); + } + } + + syncPolicyFor(sourceRef: string): SyncPolicy | null { + return this.policiesBySourceRef().get(sourceRef) ?? null; + } + + isSyncBusy(sourceRef: string): boolean { + return this.syncBusySourceRefs().has(sourceRef); + } + + /** + * A document is a syncable Drive-import source when it carries import + * provenance from a real connector. Web pages carry the sentinel + * connector id 'web' β€” those sync at the crawl level, not per page. + */ + isDriveSyncable(doc: Document): boolean { + return !!doc.sourceFileId && !!doc.sourceConnectorId && doc.sourceConnectorId !== 'web'; + } + + isCrawlSyncable(crawl: CrawlJob): boolean { + return this.syncableCrawlStatuses.has(crawl.status); + } + + /** Provider display name for a document's reconnect affordance. */ + reconnectLabelForDocument(doc: Document): string { + const provider = this.fileSources().find((s) => s.providerId === doc.sourceConnectorId); + return provider?.displayName ?? ''; + } + + async onSyncIntervalSelected( + sourceType: SyncSourceType, + sourceRef: string, + selection: SyncIntervalSelection, + ): Promise { + const assistantId = this.assistantId(); + if (!assistantId) { + return; + } + const existing = this.syncPolicyFor(sourceRef); + this.setSyncBusy(sourceRef, true); + try { + if (selection === 'manual') { + if (existing) { + await this.syncPolicyService.deletePolicy(assistantId, existing.policyId); + this.removePolicy(existing.policyId); + } + } else if (existing) { + this.upsertPolicy( + await this.syncPolicyService.updatePolicy(assistantId, existing.policyId, { + interval: selection, + }), + ); + } else { + this.upsertPolicy( + await this.syncPolicyService.createPolicy(assistantId, { + sourceType, + sourceRef, + interval: selection, + }), + ); + } + } catch (error) { + this.toastSyncError(error, 'Could not update sync settings.'); + // A duplicate/not-found conflict means our local view is stale β€” converge. + void this.loadSyncData(); + } finally { + this.setSyncBusy(sourceRef, false); + } + } + + async onSyncPause(sourceRef: string): Promise { + await this.patchSyncState(sourceRef, 'paused_user'); + } + + async onSyncResume(sourceRef: string): Promise { + await this.patchSyncState(sourceRef, 'active'); + } + + private async patchSyncState( + sourceRef: string, + state: 'active' | 'paused_user', + ): Promise { + const assistantId = this.assistantId(); + const existing = this.syncPolicyFor(sourceRef); + if (!assistantId || !existing) { + return; + } + this.setSyncBusy(sourceRef, true); + try { + this.upsertPolicy( + await this.syncPolicyService.updatePolicy(assistantId, existing.policyId, { state }), + ); + } catch (error) { + this.toastSyncError( + error, + state === 'active' ? 'Could not resume sync.' : 'Could not pause sync.', + ); + void this.loadSyncData(); + } finally { + this.setSyncBusy(sourceRef, false); + } + } + + async onSyncRunNow(sourceRef: string): Promise { + const assistantId = this.assistantId(); + const existing = this.syncPolicyFor(sourceRef); + if (!assistantId || !existing) { + return; + } + this.setSyncBusy(sourceRef, true); + try { + this.upsertPolicy(await this.syncPolicyService.runNow(assistantId, existing.policyId)); + this.toast.success('Sync requested β€” it will run within about 15 minutes.'); + } catch (error) { + // 429 (cooldown) and 409 (not active) both carry a user-appropriate + // detail message from the server β€” surface it as-is. + this.toastSyncError(error, 'Could not request a sync.'); + } finally { + this.setSyncBusy(sourceRef, false); + } + } + + /** + * "Reconnect " for a paused_reauth policy. Runs the same OAuth + * consent popup as the connector buttons; the backend's consent-complete + * hook flips the paused policies back to active, so on success we only + * refetch. Reconnect is a Drive-source affair β€” web crawls fetch + * anonymously and can never enter paused_reauth. + */ + async onSyncReconnect(sourceRef: string): Promise { + const doc = this.uploadedDocuments().find((d) => d.documentId === sourceRef); + const providerId = doc?.sourceConnectorId; + if (!providerId || providerId === 'web') { + this.toast.error('This source cannot be reconnected from here.'); + return; + } + this.reconnectingProviderId.set(providerId); + this.reconnectSourceRef = sourceRef; + this.setSyncBusy(sourceRef, true); + try { + const result = await this.connectorsService.initiateConsent(providerId); + if (result.connected) { + // The vault thinks the token is fine (provider-side revocations are + // invisible to it) β€” no consent popup will open, so the resume hook + // won't fire. Refetch and tell the user what actually happened. + this.finishReconnect(); + await this.loadSyncData(); + if (this.syncPolicyFor(sourceRef)?.state === 'paused_reauth') { + this.toast.error( + 'The connection still needs to be re-authorized. Disconnect and reconnect it under Settings β†’ Connectors, then sync will resume.', + ); + } else { + this.toast.success('Reconnected β€” sync will resume automatically.'); + } + return; + } + if (!result.authorizationUrl) { + this.finishReconnect(); + this.toast.error('Unexpected response from the server.'); + return; + } + this.consentService.requestConsent(providerId, result.authorizationUrl); + void this.consentService.openConsentPopup(providerId); + this.reconnectAwaiting = true; + } catch (error) { + this.finishReconnect(); + const message = + error instanceof Error ? error.message : 'Could not start the reconnect flow.'; + this.toast.error(message); + } + } + + /** Reset all reconnect bookkeeping (success, failure, or aborted popup). */ + private finishReconnect(): void { + this.reconnectingProviderId.set(null); + this.reconnectAwaiting = false; + if (this.reconnectSourceRef) { + this.setSyncBusy(this.reconnectSourceRef, false); + this.reconnectSourceRef = null; + } + } + + private setSyncBusy(sourceRef: string, busy: boolean): void { + this.syncBusySourceRefs.update((set) => { + const next = new Set(set); + if (busy) { + next.add(sourceRef); + } else { + next.delete(sourceRef); + } + return next; + }); + } + + private upsertPolicy(policy: SyncPolicy): void { + this.syncPolicies.update((list) => { + const exists = list.some((p) => p.policyId === policy.policyId); + return exists + ? list.map((p) => (p.policyId === policy.policyId ? policy : p)) + : [...list, policy]; + }); + } + + private removePolicy(policyId: string): void { + this.syncPolicies.update((list) => list.filter((p) => p.policyId !== policyId)); + } + + private toastSyncError(error: unknown, fallback: string): void { + const message = error instanceof Error && error.message ? error.message : fallback; + this.toast.error(message); + } + async startPollingDocument(documentId: string, assistantId: string): Promise { // Add to polling set this.pollingDocuments.update((set) => new Set(set).add(documentId)); diff --git a/frontend/ai.client/src/app/assistants/components/assistant-card.component.ts b/frontend/ai.client/src/app/assistants/components/assistant-card.component.ts index bba8e260..a1a9c20f 100644 --- a/frontend/ai.client/src/app/assistants/components/assistant-card.component.ts +++ b/frontend/ai.client/src/app/assistants/components/assistant-card.component.ts @@ -4,6 +4,7 @@ import { input, computed, output, + signal, } from '@angular/core'; /** @@ -51,24 +52,45 @@ import { @if (starters().length > 0) {
-

- Try asking -

-
- @for (starter of starters(); track $index) { - - } -
+ + + + + @if (startersExpanded()) { +
+ @for (starter of starters(); track $index) { + + } +
+ }
} @@ -142,6 +164,42 @@ import { padding: 0.75rem 1.5rem 1.5rem; } + .starters-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.375rem 0.25rem; + margin-bottom: 0.25rem; + border-radius: 0.375rem; + cursor: pointer; + transition: opacity 150ms ease; + } + + .starters-header:hover { + opacity: 0.8; + } + + .starters-header:focus-visible { + outline: 2px solid rgb(59 130 246); /* blue-500 */ + outline-offset: 2px; + } + + .starters-panel { + animation: starters-reveal 200ms cubic-bezier(0.16, 1, 0.3, 1); + } + + @keyframes starters-reveal { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .starter-btn { display: flex; align-items: center; @@ -203,6 +261,13 @@ export class AssistantCardComponent { // Outputs readonly starterSelected = output(); + // Accordion state for the conversation starters (expanded by default). + readonly startersExpanded = signal(true); + + toggleStarters(): void { + this.startersExpanded.update((expanded) => !expanded); + } + // Computed: Get first letter of name for avatar readonly firstLetter = computed(() => { const name = this.name(); diff --git a/frontend/ai.client/src/app/assistants/components/sync-policy-control.component.spec.ts b/frontend/ai.client/src/app/assistants/components/sync-policy-control.component.spec.ts new file mode 100644 index 00000000..d76dddc8 --- /dev/null +++ b/frontend/ai.client/src/app/assistants/components/sync-policy-control.component.spec.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SyncPolicyControlComponent, SyncIntervalSelection } from './sync-policy-control.component'; +import { SyncPolicy } from '../models/sync-policy.model'; + +function stubPolicy(overrides: Partial = {}): SyncPolicy { + return { + policyId: 'syn-abc123def456', + assistantId: 'assistant1', + sourceType: 'drive_file', + sourceRef: 'doc-1', + interval: 'daily', + state: 'active', + stateReason: null, + nextSyncAt: null, + lastSyncAt: null, + lastResult: null, + createdAt: '2026-07-03T00:00:00Z', + updatedAt: '2026-07-03T00:00:00Z', + ...overrides, + }; +} + +describe('SyncPolicyControlComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [SyncPolicyControlComponent], + }).compileComponents(); + fixture = TestBed.createComponent(SyncPolicyControlComponent); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + function select(): HTMLSelectElement { + return fixture.nativeElement.querySelector('select') as HTMLSelectElement; + } + + function buttonLabels(): string[] { + return Array.from(fixture.nativeElement.querySelectorAll('button')).map((b) => + ((b as HTMLButtonElement).textContent ?? '').trim(), + ); + } + + function statusLine(): string { + return ((fixture.nativeElement.querySelector('p')?.textContent as string) ?? '').trim(); + } + + it('defaults the select to manual ("Don\'t auto-sync") with no policy and shows no actions', () => { + fixture.detectChanges(); + expect(select().value).toBe('manual'); + const manualOption = select().querySelector('option[value="manual"]'); + expect(manualOption?.textContent?.trim()).toBe("Don't auto-sync"); + expect(buttonLabels()).toEqual([]); + expect(fixture.nativeElement.querySelector('p')).toBeNull(); + }); + + it('shows "Last synced" for a manual-only source using the document timestamp', () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + // No policy input β†’ manual-only; the timestamp comes from the document. + fixture.componentRef.setInput('lastSyncedAt', twoHoursAgo); + fixture.detectChanges(); + expect(statusLine()).toContain('Last synced 2h ago'); + // Relative age is paired with an absolute date/time. + expect(statusLine()).toMatch(/Last synced 2h ago Β· .+/); + }); + + it('renders timestamps that carry a legacy "+00:00Z" suffix (invalid ISO)', () => { + // The backend historically emitted "…+00:00Z" (offset AND Z), which is + // unparseable by strict `new Date()` and left the status blank. Already- + // persisted policies must still render, so the control normalizes it. + const then = new Date(Date.now() - 3 * 60 * 60 * 1000); + const legacy = then.toISOString().replace('Z', '+00:00Z'); + fixture.componentRef.setInput('lastSyncedAt', legacy); + fixture.detectChanges(); + expect(statusLine()).toContain('Last synced 3h ago'); + // Not the blank "Last synced" with no time. + expect(statusLine()).not.toBe('Last synced'); + }); + + it('reflects the policy interval in the select', () => { + fixture.componentRef.setInput('policy', stubPolicy({ interval: 'weekly' })); + fixture.detectChanges(); + expect(select().value).toBe('weekly'); + }); + + it('emits intervalSelected when the user picks a new interval, then reverts the DOM', () => { + const emitted: SyncIntervalSelection[] = []; + fixture.componentInstance.intervalSelected.subscribe((v) => emitted.push(v)); + fixture.detectChanges(); + + select().value = 'daily'; + select().dispatchEvent(new Event('change')); + + expect(emitted).toEqual(['daily']); + // The select only moves for real once the page confirms the mutation + // and re-renders via the policy input. + expect(select().value).toBe('manual'); + }); + + it('does not emit when the selection matches the current interval', () => { + const emitted: SyncIntervalSelection[] = []; + fixture.componentInstance.intervalSelected.subscribe((v) => emitted.push(v)); + fixture.componentRef.setInput('policy', stubPolicy({ interval: 'daily' })); + fixture.detectChanges(); + + select().value = 'daily'; + select().dispatchEvent(new Event('change')); + + expect(emitted).toEqual([]); + }); + + it('shows Sync now + Pause for an active policy and emits their events', () => { + let ranNow = 0; + let paused = 0; + fixture.componentInstance.runNow.subscribe(() => ranNow++); + fixture.componentInstance.pause.subscribe(() => paused++); + fixture.componentRef.setInput('policy', stubPolicy({ state: 'active' })); + fixture.detectChanges(); + + const labels = buttonLabels(); + expect(labels).toContain('Sync now'); + expect(labels).toContain('Pause'); + + const buttons = fixture.nativeElement.querySelectorAll('button'); + (buttons[0] as HTMLButtonElement).click(); + (buttons[1] as HTMLButtonElement).click(); + expect(ranNow).toBe(1); + expect(paused).toBe(1); + }); + + it('shows Resume for a user-paused policy', () => { + let resumed = 0; + fixture.componentInstance.resume.subscribe(() => resumed++); + fixture.componentRef.setInput('policy', stubPolicy({ state: 'paused_user' })); + fixture.detectChanges(); + + expect(buttonLabels()).toEqual(['Resume']); + expect(statusLine()).toBe('Paused'); + + (fixture.nativeElement.querySelector('button') as HTMLButtonElement).click(); + expect(resumed).toBe(1); + }); + + it('shows the Reconnect affordance β€” not Resume β€” for paused_reauth', () => { + let reconnected = 0; + fixture.componentInstance.reconnect.subscribe(() => reconnected++); + fixture.componentRef.setInput( + 'policy', + stubPolicy({ state: 'paused_reauth', stateReason: 'Google Drive access expired' }), + ); + fixture.componentRef.setInput('reconnectLabel', 'Google Drive'); + fixture.detectChanges(); + + expect(buttonLabels()).toEqual(['Reconnect Google Drive']); + expect(statusLine()).toBe('Paused β€” Google Drive access expired'); + + (fixture.nativeElement.querySelector('button') as HTMLButtonElement).click(); + expect(reconnected).toBe(1); + }); + + it('shows Resume with the state reason for an error-paused policy', () => { + fixture.componentRef.setInput( + 'policy', + stubPolicy({ state: 'paused_error', stateReason: 'source no longer accessible' }), + ); + fixture.detectChanges(); + + expect(buttonLabels()).toEqual(['Resume']); + expect(statusLine()).toBe('Paused β€” source no longer accessible'); + }); + + it('shows Resume with an inactivity explanation for paused_inactive', () => { + fixture.componentRef.setInput('policy', stubPolicy({ state: 'paused_inactive' })); + fixture.detectChanges(); + + expect(buttonLabels()).toEqual(['Resume']); + expect(statusLine()).toContain('inactive'); + }); + + it('describes last (relative + absolute) and next sync for an active policy', () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const inThreeDays = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(); + fixture.componentRef.setInput( + 'policy', + stubPolicy({ state: 'active', lastSyncAt: twoHoursAgo, nextSyncAt: inThreeDays }), + ); + fixture.detectChanges(); + + // Relative age, an exact date/time, then the next-run estimate. + expect(statusLine()).toContain('Synced 2h ago'); + expect(statusLine()).toContain('next sync in 3d'); + expect(statusLine()).toMatch(/Synced 2h ago Β· .+ Β· next sync in 3d/); + }); + + it('flags a failed last run on the status line', () => { + fixture.componentRef.setInput( + 'policy', + stubPolicy({ + state: 'active', + lastResult: 'failed', + lastSyncAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }), + ); + fixture.detectChanges(); + + expect(statusLine()).toContain('Last sync failed 1h ago'); + }); + + it('shows a "Saving…" indicator while a mutation is in flight', () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + fixture.componentRef.setInput('policy', stubPolicy({ state: 'active', lastSyncAt: twoHoursAgo })); + fixture.detectChanges(); + // Not busy: the status line describes the sync, not a save. + expect(statusLine()).toContain('Synced 2h ago'); + + fixture.componentRef.setInput('busy', true); + fixture.detectChanges(); + // Busy: the saving indicator takes over so the user sees work happening. + expect(statusLine()).toBe('Saving…'); + expect(fixture.nativeElement.querySelector('[role="status"]')).not.toBeNull(); + }); + + it('shows "Saving…" even for a manual source with no policy or status yet', () => { + // Enabling sync on a manual-only source: no policy exists yet, so without + // the busy branch there would be no feedback at all. + fixture.componentRef.setInput('busy', true); + fixture.detectChanges(); + expect(statusLine()).toBe('Saving…'); + }); + + it('disables all controls while busy', () => { + fixture.componentRef.setInput('policy', stubPolicy({ state: 'active' })); + fixture.componentRef.setInput('busy', true); + fixture.detectChanges(); + + expect(select().disabled).toBe(true); + const buttons = Array.from( + fixture.nativeElement.querySelectorAll('button'), + ) as HTMLButtonElement[]; + expect(buttons.length).toBeGreaterThan(0); + for (const button of buttons) { + expect(button.disabled).toBe(true); + } + }); +}); diff --git a/frontend/ai.client/src/app/assistants/components/sync-policy-control.component.ts b/frontend/ai.client/src/app/assistants/components/sync-policy-control.component.ts new file mode 100644 index 00000000..cfcfb610 --- /dev/null +++ b/frontend/ai.client/src/app/assistants/components/sync-policy-control.component.ts @@ -0,0 +1,290 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroArrowPath, heroChevronDown } from '@ng-icons/heroicons/outline'; + +import { SyncInterval, SyncPolicy } from '../models/sync-policy.model'; + +/** The select's "no policy / turn sync off" sentinel. */ +export type SyncIntervalSelection = SyncInterval | 'manual'; + +/** + * Per-source "Keep in sync" control for the assistant knowledge editor. + * + * Presentational only: renders the interval select, the state-appropriate + * action (Sync now / Pause / Resume / Reconnect) and a status line, and + * emits the user's intent. The page owns the policy state and the HTTP + * round-trips β€” on failure it simply doesn't update the `policy` input and + * the control stays where it was (the select reverts its own DOM value + * eagerly so a failed mutation never leaves a stale selection visible). + * + * `paused_reauth` deliberately has no Resume action β€” the backend rejects + * that resume with 409 because only a fresh OAuth consent can fix it β€” so + * the control renders the Reconnect affordance instead. + */ +@Component({ + selector: 'app-sync-policy-control', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIcon], + providers: [provideIcons({ heroArrowPath, heroChevronDown })], + template: ` +
+
+ +
+ + @if (policy(); as p) { + @if (p.state === 'active') { + + + } @else if (p.state === 'paused_reauth') { + + } @else { + + } + } +
+ @if (busy()) { +

+ + Saving… +

+ } @else if (statusText(); as text) { +

+ + {{ text }} +

+ } + `, +}) +export class SyncPolicyControlComponent { + /** The covering policy, or null when the source is manual-only. */ + readonly policy = input(null); + /** Disables all controls while the page has a mutation in flight. */ + readonly busy = input(false); + /** Source display name, used for the controls' accessible names. */ + readonly sourceName = input(''); + /** Provider display name for the paused_reauth affordance (e.g. "Google Drive"). */ + readonly reconnectLabel = input(''); + /** + * The source's last successful sync, straight from the document record. + * Used to keep a "Last synced …" line visible even when the source is + * manual-only (no governing policy), where `policy` is null. + */ + readonly lastSyncedAt = input(null); + + readonly intervalSelected = output(); + readonly runNow = output(); + readonly pause = output(); + readonly resume = output(); + readonly reconnect = output(); + + readonly selectValue = computed( + () => this.policy()?.interval ?? 'manual', + ); + + readonly statusTone = computed<'muted' | 'warn'>(() => { + const p = this.policy(); + if (!p) return 'muted'; + if (p.state === 'paused_error' || p.state === 'paused_reauth') return 'warn'; + if (p.state === 'active' && p.lastResult === 'failed') return 'warn'; + return 'muted'; + }); + + /** Colour of the status-line dot: green healthy, amber attention, grey idle. */ + readonly statusDot = computed<'ok' | 'warn' | 'idle'>(() => { + if (this.statusTone() === 'warn') return 'warn'; + return this.policy()?.state === 'active' ? 'ok' : 'idle'; + }); + + readonly statusText = computed(() => { + const p = this.policy(); + // Manual-only (no governing policy): still surface when the file last + // refreshed, using the document's own timestamp. + if (!p) { + const ts = this.lastSyncedAt(); + return ts ? this.syncedPhrase('Last synced', ts) : ''; + } + switch (p.state) { + case 'active': { + const parts: string[] = []; + if (p.lastResult === 'failed') { + parts.push( + p.lastSyncAt + ? this.syncedPhrase('Last sync failed', p.lastSyncAt) + : 'Last sync failed', + ); + } else if (p.lastSyncAt) { + parts.push(this.syncedPhrase('Synced', p.lastSyncAt)); + } else { + parts.push('Not synced yet'); + } + if (p.nextSyncAt) { + parts.push(`next sync ${this.formatUntil(p.nextSyncAt)}`); + } + return parts.join(' Β· '); + } + case 'paused_user': { + const ts = p.lastSyncAt ?? this.lastSyncedAt(); + return ts ? `Paused Β· last synced ${this.formatAgo(ts)}` : 'Paused'; + } + case 'paused_error': + return this.pausedText(p.stateReason, 'Paused after repeated failures'); + case 'paused_inactive': + return this.pausedText( + p.stateReason, + 'Paused while the assistant is inactive β€” resumes on next use', + ); + case 'paused_reauth': + return this.pausedText(p.stateReason, 'Reconnect the content source to resume syncing'); + } + }); + + onSelectChange(event: Event): void { + const select = event.target as HTMLSelectElement; + const chosen = select.value as SyncIntervalSelection; + const current = this.selectValue(); + // Revert the DOM eagerly: the select only moves for real once the page + // confirms the mutation and the `policy` input re-renders it. A failed + // request therefore never strands the select on a value that isn't true. + select.value = current; + if (chosen === current) { + return; + } + this.intervalSelected.emit(chosen); + } + + private pausedText(reason: string | null | undefined, fallback: string): string { + return reason ? `Paused β€” ${reason}` : `Paused β€” ${fallback}`; + } + + /** + * "{verb} {relative} Β· {absolute}" β€” pairs a scannable relative age with an + * exact date/time so the reader gets both "how long ago" and "exactly when". + */ + private syncedPhrase(verb: string, iso: string): string { + const ago = this.formatAgo(iso); + const abs = this.formatAbsolute(iso); + if (!ago && !abs) return verb; + if (!abs) return `${verb} ${ago}`; + if (!ago) return `${verb} ${abs}`; + return `${verb} ${ago} Β· ${abs}`; + } + + /** + * Parse a backend ISO timestamp into a Date, tolerating a historical bug + * where timestamps were emitted with BOTH an offset and a "Z" + * (e.g. "2026-07-05T16:00:00+00:00Z"). That string is invalid ISO 8601 and + * unparseable by strict engines (Safari) β€” strip the redundant trailing Z so + * already-persisted policies still render while the backend is corrected. + */ + private parseIso(iso: string): Date { + return new Date(iso.replace(/([+-]\d{2}:\d{2})Z$/, '$1')); + } + + private formatAbsolute(iso: string): string { + const then = this.parseIso(iso); + if (Number.isNaN(then.getTime())) return ''; + return then.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } + + private formatAgo(iso: string): string { + const parsed = this.parseIso(iso); + const then = parsed.getTime(); + if (Number.isNaN(then)) return ''; + const diffMins = Math.floor((Date.now() - then) / 60_000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays}d ago`; + return parsed.toLocaleDateString(); + } + + private formatUntil(iso: string): string { + const then = this.parseIso(iso).getTime(); + if (Number.isNaN(then)) return ''; + const diffMins = Math.ceil((then - Date.now()) / 60_000); + // "due now" covers the run-now window: the dispatcher sweeps every + // 15 minutes, so a due policy runs within the next tick. + if (diffMins <= 15) return 'due now'; + if (diffMins < 60) return `in ${diffMins}m`; + const diffHours = Math.round(diffMins / 60); + if (diffHours < 24) return `in ${diffHours}h`; + return `in ${Math.round(diffHours / 24)}d`; + } +} diff --git a/frontend/ai.client/src/app/assistants/models/document.model.ts b/frontend/ai.client/src/app/assistants/models/document.model.ts index d7fb510b..4b36e5be 100644 --- a/frontend/ai.client/src/app/assistants/models/document.model.ts +++ b/frontend/ai.client/src/app/assistants/models/document.model.ts @@ -52,6 +52,19 @@ export interface Document { chunkCount?: number; createdAt: string; updatedAt: string; + /** + * Import provenance β€” set only for documents imported from an external + * source (Google Drive, web crawl); all null/absent for device uploads. + * A document is a syncable Drive source when `sourceFileId` is present + * and `sourceConnectorId` is a real connector (web pages use the + * sentinel connector id 'web' and sync at the crawl level instead). + */ + sourceConnectorId?: string | null; + sourceAdapterKey?: string | null; + sourceFileId?: string | null; + /** Back-pointer to the covering sync policy, when one exists. */ + syncPolicyId?: string | null; + lastSyncedAt?: string | null; } /** diff --git a/frontend/ai.client/src/app/assistants/models/sync-policy.model.ts b/frontend/ai.client/src/app/assistants/models/sync-policy.model.ts new file mode 100644 index 00000000..af2e6c3a --- /dev/null +++ b/frontend/ai.client/src/app/assistants/models/sync-policy.model.ts @@ -0,0 +1,56 @@ +/** + * Front-end models for assistant KB sync policies β€” scheduled re-index of + * knowledge sources (imported Drive files, web crawls). + * + * Mirrors the backend contract from `apis/app_api/sync_policies/` β€” field + * names are the camelCase aliases the backend serializes. + */ + +export type SyncSourceType = 'drive_file' | 'web_crawl'; +export type SyncInterval = 'daily' | 'weekly' | 'monthly'; +export type SyncPolicyState = + | 'active' + | 'paused_error' + | 'paused_inactive' + | 'paused_reauth' + | 'paused_user'; +export type SyncRunResult = 'changed' | 'unchanged' | 'failed' | 'skipped'; + +/** Public view of a sync policy (backend SyncPolicyResponse). */ +export interface SyncPolicy { + policyId: string; + assistantId: string; + sourceType: SyncSourceType; + sourceRef: string; + interval: SyncInterval; + state: SyncPolicyState; + stateReason?: string | null; + nextSyncAt?: string | null; + lastSyncAt?: string | null; + lastResult?: SyncRunResult | null; + createdAt: string; + updatedAt: string; +} + +/** Request body for POST /assistants/{id}/sync-policies. */ +export interface CreateSyncPolicyRequest { + sourceType: SyncSourceType; + sourceRef: string; + interval: SyncInterval; +} + +/** + * Request body for PATCH /assistants/{id}/sync-policies/{policyId}. + * `state` accepts only the user-owned transitions: 'paused_user' (pause) + * and 'active' (resume). Resuming a paused_reauth policy is rejected with + * 409 β€” only a fresh OAuth consent resumes those. + */ +export interface UpdateSyncPolicyRequest { + interval?: SyncInterval; + state?: 'active' | 'paused_user'; +} + +/** Response from GET /assistants/{id}/sync-policies. */ +export interface SyncPoliciesListResponse { + policies: SyncPolicy[]; +} diff --git a/frontend/ai.client/src/app/assistants/services/sync-policy.service.spec.ts b/frontend/ai.client/src/app/assistants/services/sync-policy.service.spec.ts new file mode 100644 index 00000000..f4b96f9e --- /dev/null +++ b/frontend/ai.client/src/app/assistants/services/sync-policy.service.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; +import { SyncPolicyService, SyncPolicyError } from './sync-policy.service'; +import { ConfigService } from '../../services/config.service'; +import { SyncPolicy } from '../models/sync-policy.model'; + +const BASE = 'http://localhost:8000/assistants/assistant1/sync-policies'; + +function stubPolicy(overrides: Partial = {}): SyncPolicy { + return { + policyId: 'syn-abc123def456', + assistantId: 'assistant1', + sourceType: 'drive_file', + sourceRef: 'doc-1', + interval: 'daily', + state: 'active', + stateReason: null, + nextSyncAt: '2026-07-04T00:00:00Z', + lastSyncAt: null, + lastResult: null, + createdAt: '2026-07-03T00:00:00Z', + updatedAt: '2026-07-03T00:00:00Z', + ...overrides, + }; +} + +describe('SyncPolicyService', () => { + let service: SyncPolicyService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + SyncPolicyService, + { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, + ], + }); + service = TestBed.inject(SyncPolicyService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.match(() => true); + TestBed.resetTestingModule(); + }); + + it('should list policies', async () => { + const policies = [stubPolicy()]; + + const promise = service.listPolicies('assistant1'); + + await vi.waitFor(() => { + const req = httpMock.expectOne(BASE); + expect(req.request.method).toBe('GET'); + req.flush({ policies }); + }); + + expect(await promise).toEqual(policies); + }); + + it('should create a policy', async () => { + const policy = stubPolicy(); + + const promise = service.createPolicy('assistant1', { + sourceType: 'drive_file', + sourceRef: 'doc-1', + interval: 'daily', + }); + + await vi.waitFor(() => { + const req = httpMock.expectOne(BASE); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + sourceType: 'drive_file', + sourceRef: 'doc-1', + interval: 'daily', + }); + req.flush(policy, { status: 201, statusText: 'Created' }); + }); + + expect(await promise).toEqual(policy); + }); + + it('should update a policy interval', async () => { + const policy = stubPolicy({ interval: 'weekly' }); + + const promise = service.updatePolicy('assistant1', 'syn-abc123def456', { + interval: 'weekly', + }); + + await vi.waitFor(() => { + const req = httpMock.expectOne(`${BASE}/syn-abc123def456`); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual({ interval: 'weekly' }); + req.flush(policy); + }); + + expect(await promise).toEqual(policy); + }); + + it('should delete a policy', async () => { + const promise = service.deletePolicy('assistant1', 'syn-abc123def456'); + + await vi.waitFor(() => { + const req = httpMock.expectOne(`${BASE}/syn-abc123def456`); + expect(req.request.method).toBe('DELETE'); + req.flush(null, { status: 204, statusText: 'No Content' }); + }); + + await promise; + }); + + it('should request run-now', async () => { + const policy = stubPolicy({ nextSyncAt: '2026-07-03T00:00:01Z' }); + + const promise = service.runNow('assistant1', 'syn-abc123def456'); + + await vi.waitFor(() => { + const req = httpMock.expectOne(`${BASE}/syn-abc123def456/run-now`); + expect(req.request.method).toBe('POST'); + req.flush(policy, { status: 202, statusText: 'Accepted' }); + }); + + expect(await promise).toEqual(policy); + }); + + it('should surface the server detail and status on a run-now cooldown (429)', async () => { + const promise = service.runNow('assistant1', 'syn-abc123def456'); + + await vi.waitFor(() => { + httpMock + .expectOne(`${BASE}/syn-abc123def456/run-now`) + .flush( + { detail: 'A manual sync was already requested recently; try again in a few minutes' }, + { status: 429, statusText: 'Too Many Requests' }, + ); + }); + + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(SyncPolicyError); + expect((error as SyncPolicyError).status).toBe(429); + expect((error as SyncPolicyError).code).toBe('HTTP_429'); + expect((error as SyncPolicyError).message).toContain('try again in a few minutes'); + }); + + it('should surface the reauth-resume conflict (409) from PATCH', async () => { + const promise = service.updatePolicy('assistant1', 'syn-abc123def456', { state: 'active' }); + + await vi.waitFor(() => { + httpMock + .expectOne(`${BASE}/syn-abc123def456`) + .flush( + { detail: 'Reconnect the content source to resume syncing' }, + { status: 409, statusText: 'Conflict' }, + ); + }); + + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(SyncPolicyError); + expect((error as SyncPolicyError).status).toBe(409); + expect((error as SyncPolicyError).message).toBe( + 'Reconnect the content source to resume syncing', + ); + }); + + it('should fall back to a generic message on non-HTTP failures', async () => { + const promise = service.listPolicies('assistant1'); + + await vi.waitFor(() => { + httpMock.expectOne(BASE).error(new ProgressEvent('error')); + }); + + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(SyncPolicyError); + }); +}); diff --git a/frontend/ai.client/src/app/assistants/services/sync-policy.service.ts b/frontend/ai.client/src/app/assistants/services/sync-policy.service.ts new file mode 100644 index 00000000..a628249a --- /dev/null +++ b/frontend/ai.client/src/app/assistants/services/sync-policy.service.ts @@ -0,0 +1,156 @@ +import { Injectable, computed, inject } from '@angular/core'; +import { HttpClient, HttpContext, HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +import { ConfigService } from '../../services/config.service'; +import { SUPPRESS_ERROR_TOAST } from '../../auth/error.interceptor'; +import { + CreateSyncPolicyRequest, + SyncPoliciesListResponse, + SyncPolicy, + UpdateSyncPolicyRequest, +} from '../models/sync-policy.model'; + +/** + * Error raised by {@link SyncPolicyService}. `code` is `HTTP_{status}` for + * server responses (e.g. `HTTP_429` for the run-now cooldown) or `UNKNOWN`. + * `status` lets callers branch on the contract's meaningful conflicts: + * 409 (duplicate / reauth-resume / run-now-inactive), 429 (cooldown). + */ +export class SyncPolicyError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly status?: number, + ) { + super(message); + this.name = 'SyncPolicyError'; + } +} + +/** + * Client for the sync-policy endpoints (app-api). All endpoints are + * edit-gated server-side (owner or editor share) β€” the knowledge editor + * only renders sync controls for those roles, so a 403 here means the + * permission changed underneath the open page. + */ +@Injectable({ providedIn: 'root' }) +export class SyncPolicyService { + private readonly http = inject(HttpClient); + private readonly config = inject(ConfigService); + private readonly baseUrl = computed(() => this.config.appApiUrl()); + + /** + * The knowledge editor surfaces sync errors inline (status lines and + * toasts with the server's wording), so opt out of the global error toast. + */ + private requestOptions(): { context: HttpContext } { + return { + context: new HttpContext().set(SUPPRESS_ERROR_TOAST, true), + }; + } + + private policiesUrl(assistantId: string): string { + return `${this.baseUrl()}/assistants/${encodeURIComponent(assistantId)}/sync-policies`; + } + + /** List all sync policies for an assistant. */ + async listPolicies(assistantId: string): Promise { + try { + const response = await firstValueFrom( + this.http.get( + this.policiesUrl(assistantId), + this.requestOptions(), + ), + ); + return response.policies; + } catch (err) { + throw this.toError(err, 'Failed to load sync settings'); + } + } + + /** Create a policy covering a content source. 409 = source already synced. */ + async createPolicy( + assistantId: string, + request: CreateSyncPolicyRequest, + ): Promise { + try { + return await firstValueFrom( + this.http.post( + this.policiesUrl(assistantId), + request, + this.requestOptions(), + ), + ); + } catch (err) { + throw this.toError(err, 'Failed to enable sync'); + } + } + + /** + * Change interval and/or pause/resume. Resuming makes the policy due + * immediately; resuming a paused_reauth policy is rejected with 409. + */ + async updatePolicy( + assistantId: string, + policyId: string, + request: UpdateSyncPolicyRequest, + ): Promise { + try { + return await firstValueFrom( + this.http.patch( + `${this.policiesUrl(assistantId)}/${encodeURIComponent(policyId)}`, + request, + this.requestOptions(), + ), + ); + } catch (err) { + throw this.toError(err, 'Failed to update sync settings'); + } + } + + /** Delete a policy β€” the source goes back to manual-only. */ + async deletePolicy(assistantId: string, policyId: string): Promise { + try { + await firstValueFrom( + this.http.delete( + `${this.policiesUrl(assistantId)}/${encodeURIComponent(policyId)}`, + this.requestOptions(), + ), + ); + } catch (err) { + throw this.toError(err, 'Failed to disable sync'); + } + } + + /** + * Request an immediate sync (202). The run still flows through the normal + * dispatcher sweep, so it starts within ~15 minutes. 429 = within the + * 10-minute cooldown; 409 = policy not active. + */ + async runNow(assistantId: string, policyId: string): Promise { + try { + return await firstValueFrom( + this.http.post( + `${this.policiesUrl(assistantId)}/${encodeURIComponent(policyId)}/run-now`, + null, + this.requestOptions(), + ), + ); + } catch (err) { + throw this.toError(err, 'Failed to request a sync'); + } + } + + private toError(err: unknown, fallback: string): SyncPolicyError { + if (err instanceof HttpErrorResponse) { + const detail = (err.error as { detail?: string; message?: string } | null) ?? null; + const message = detail?.detail || detail?.message || err.message || fallback; + return new SyncPolicyError(message, `HTTP_${err.status}`, err.status); + } + if (err instanceof Error) { + return new SyncPolicyError(err.message || fallback, 'UNKNOWN'); + } + return new SyncPolicyError(fallback, 'UNKNOWN'); + } +} diff --git a/frontend/ai.client/src/app/assistants/services/web-source.service.ts b/frontend/ai.client/src/app/assistants/services/web-source.service.ts index eed813a3..8ce07c0d 100644 --- a/frontend/ai.client/src/app/assistants/services/web-source.service.ts +++ b/frontend/ai.client/src/app/assistants/services/web-source.service.ts @@ -87,6 +87,25 @@ export class WebSourceService { } } + /** + * List every crawl for an assistant regardless of status. Completed + * crawls are the syncable web sources β€” a web_crawl sync policy's + * source is the terminal CrawlJob row. + */ + async listCrawls(assistantId: string): Promise { + try { + const response = await firstValueFrom( + this.http.get( + `${this.baseUrl()}/assistants/${encodeURIComponent(assistantId)}/web-sources/crawls`, + this.requestOptions(), + ), + ); + return response.crawls; + } catch (err) { + throw this.toError(err, 'Failed to load web sources'); + } + } + private toError(err: unknown, fallback: string): WebSourceError { if (err instanceof HttpErrorResponse) { const detail = diff --git a/frontend/ai.client/src/app/components/background-task-toasts/background-task-toasts.component.spec.ts b/frontend/ai.client/src/app/components/background-task-toasts/background-task-toasts.component.spec.ts new file mode 100644 index 00000000..8e42b290 --- /dev/null +++ b/frontend/ai.client/src/app/components/background-task-toasts/background-task-toasts.component.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; +import { BackgroundTaskToastsComponent } from './background-task-toasts.component'; +import { BackgroundTaskService } from '../../services/background-tasks/background-task.service'; + +describe('BackgroundTaskToastsComponent', () => { + let fixture: ComponentFixture; + let tasks: BackgroundTaskService; + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [BackgroundTaskToastsComponent], + providers: [provideRouter([{ path: 's/:id', children: [] }]), BackgroundTaskService], + }).compileComponents(); + + fixture = TestBed.createComponent(BackgroundTaskToastsComponent); + tasks = TestBed.inject(BackgroundTaskService); + fixture.detectChanges(); + }); + + it('renders a toast per task with its title', () => { + tasks.start('Running "Briefing"', 'Working…'); + fixture.detectChanges(); + + const text = fixture.nativeElement.textContent as string; + expect(text).toContain('Running "Briefing"'); + expect(text).toContain('Working…'); + }); + + it('shows a View button only once a route is attached', () => { + const id = tasks.start('Run'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('button[aria-label="Dismiss"]')).toBeTruthy(); + + tasks.complete(id, { route: ['/s', 'sess-1'], viewLabel: 'View result' }); + fixture.detectChanges(); + expect((fixture.nativeElement.textContent as string)).toContain('View result'); + }); + + it('View navigates to the route and dismisses the toast', () => { + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + const id = tasks.start('Run'); + tasks.complete(id, { route: ['/s', 'sess-1'] }); + fixture.detectChanges(); + + clickView(); + + expect(navigateSpy).toHaveBeenCalledWith(['/s', 'sess-1']); + expect(tasks.tasks().length).toBe(0); + }); + + it('View runs a custom onView handler instead of route navigation, then dismisses', () => { + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + const onView = vi.fn(); + const id = tasks.start('Run'); + tasks.complete(id, { route: ['/s', 'sess-1'], onView }); + fixture.detectChanges(); + + clickView(); + + expect(onView).toHaveBeenCalled(); + expect(navigateSpy).not.toHaveBeenCalled(); + expect(tasks.tasks().length).toBe(0); + }); + + function clickView(): void { + const viewBtn = Array.from(fixture.nativeElement.querySelectorAll('button')).find((b) => + (b as HTMLButtonElement).textContent?.includes('View'), + ) as HTMLButtonElement; + viewBtn.click(); + } +}); diff --git a/frontend/ai.client/src/app/components/background-task-toasts/background-task-toasts.component.ts b/frontend/ai.client/src/app/components/background-task-toasts/background-task-toasts.component.ts new file mode 100644 index 00000000..775864dd --- /dev/null +++ b/frontend/ai.client/src/app/components/background-task-toasts/background-task-toasts.component.ts @@ -0,0 +1,184 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroArrowRight, + heroCheckCircle, + heroExclamationTriangle, + heroXMark, +} from '@ng-icons/heroicons/outline'; +import { BackgroundTask, BackgroundTaskService } from '../../services/background-tasks/background-task.service'; + +/** + * Shell-level stack of persistent background-task toasts (top-right). Mounted + * once in the app shell so it survives navigation between views β€” a headless + * "Run now" kicked off on the schedules form keeps reporting here after the + * user moves elsewhere. + * + * Unobtrusive but always visible: our signature pulsing dot while processing, + * a check/warning when settled, and a "View" button that links to the result + * (e.g. the session-detail conversation). Native `animate.enter` / + * `animate.leave` (Angular 21) give it polished entry/exit animations. + */ +@Component({ + selector: 'app-background-task-toasts', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIcon], + providers: [ + provideIcons({ heroArrowRight, heroCheckCircle, heroExclamationTriangle, heroXMark }), + ], + host: { class: 'contents' }, + template: ` +
+ @for (task of taskService.tasks(); track task.id) { +
+
+ +
+ @switch (task.status) { + @case ('processing') { + + } + @case ('completed') { +
+ + +
+

{{ task.title }}

+ @if (task.detail) { +

{{ task.detail }}

+ } + @if (task.route) { + + } +
+ + + +
+
+ } +
+ `, + styles: ` + @import "tailwindcss"; + @custom-variant dark (&:where(.dark, .dark *)); + + .bg-toast-enter { + animation: bg-toast-in 260ms cubic-bezier(0.16, 1, 0.3, 1); + } + .bg-toast-leave { + animation: bg-toast-out 200ms ease-in forwards; + } + + @keyframes bg-toast-in { + from { opacity: 0; transform: translateX(1rem) scale(0.98); } + to { opacity: 1; transform: translateX(0) scale(1); } + } + @keyframes bg-toast-out { + from { opacity: 1; transform: translateX(0) scale(1); } + to { opacity: 0; transform: translateX(1rem) scale(0.98); } + } + + /* Signature pulsing dot β€” ring + core, matching PulsatingLoaderComponent */ + .bg-toast-dot { + position: relative; + display: block; + width: 7px; + height: 7px; + flex-shrink: 0; + } + .bg-toast-dot::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 300%; + height: 300%; + transform: translate(-50%, -50%); + border-radius: 50%; + background-color: var(--color-blue-500); + animation: bg-toast-pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite; + } + .bg-toast-dot::after { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + border-radius: 50%; + background-color: var(--color-blue-500); + animation: bg-toast-pulse-dot 1.25s cubic-bezier(0.455, 0.03, 0.515, 0.955) -0.4s infinite; + } + :host-context(.dark) .bg-toast-dot::before, + :host-context(.dark) .bg-toast-dot::after { + background-color: var(--color-blue-400); + } + + @keyframes bg-toast-pulse-ring { + 0% { transform: translate(-50%, -50%) scale(0.33); } + 80%, 100% { opacity: 0; } + } + @keyframes bg-toast-pulse-dot { + 0% { transform: scale(0.8); } + 50% { transform: scale(1); } + 100% { transform: scale(0.8); } + } + + @media (prefers-reduced-motion: reduce) { + .bg-toast-enter, + .bg-toast-leave, + .bg-toast-dot::before, + .bg-toast-dot::after { + animation: none; + } + } + `, +}) +export class BackgroundTaskToastsComponent { + protected readonly taskService = inject(BackgroundTaskService); + private readonly router = inject(Router); + + protected view(task: BackgroundTask): void { + if (task.onView) { + task.onView(); + } else if (task.route) { + this.router.navigate(task.route); + } + this.taskService.dismiss(task.id); + } + + protected dismiss(id: string): void { + this.taskService.dismiss(id); + } +} diff --git a/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.css b/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.css index 1ee9fd99..cd025de3 100644 --- a/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.css +++ b/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.css @@ -39,3 +39,56 @@ animation: none; } } + +/* + * Title-generating skeleton (new-conversation shimmer). Coloured here rather + * than with Tailwind utilities so it can't silently no-op on an arbitrary + * variant, and so the selected-row override is a plain descendant rule with + * predictable specificity. A brand-new session is also the *selected* row + * (.session-row--active, applied by the same routerLinkActive that paints the + * bg-gray-200 / dark:bg-white/5 highlight) β€” a plain gray-200 shimmer vanishes + * against that highlight, so the active-row rules darken it. Dark mode is + * class-based here (@custom-variant dark β†’ html.dark), matched via + * :host-context; its added specificity keeps dark ahead of the light rules. + */ +.title-skeleton { + background-color: var(--color-gray-200); +} + +.session-row--active .title-skeleton { + background-color: color-mix(in oklab, var(--color-gray-400) 70%, transparent); +} + +:host-context(html.dark) .title-skeleton { + background-color: color-mix(in oklab, var(--color-white) 10%, transparent); +} + +:host-context(html.dark) .session-row--active .title-skeleton { + background-color: color-mix(in oklab, var(--color-white) 20%, transparent); +} + +/* Title reveal: gentle left-to-right slide + fade, played when the generated + title first replaces the skeleton (and on rename). Mirrors the top-nav + reveal for consistency; the span is a flex item (blockified) so translate + applies. Keyed by the title string in the template, so it replays only when + the text actually changes β€” not on unrelated re-renders. */ +.session-title-enter { + animation: session-title-enter 220ms ease-out; +} + +@keyframes session-title-enter { + from { + opacity: 0; + transform: translateX(-8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .session-title-enter { + animation: none; + } +} diff --git a/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html b/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html index 532d175f..e0e3805f 100644 --- a/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html +++ b/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html @@ -49,28 +49,85 @@

- {{ getSessionTitle(session) }} - - - + @if (isSessionStreaming(session.sessionId)) { + (response in progress) + } @else if (shouldShowUnreadDot(session)) { + (unread) + } + + @if (isSessionStreaming(session.sessionId)) { + + + } @else { + @if (shouldShowUnreadDot(session)) { + + + } + + + } @@ -110,6 +167,16 @@

{{ currentSession().title || '' }}

+ @if (renaming()) { + + + } @else if (titlePending()) { + +
+ } @else if (currentSession().sessionId) { + + + + + + + + } + + + @if (isLoadingAssistant()) { +
+ } @else if (assistant()) { + + } diff --git a/frontend/ai.client/src/app/components/topnav/topnav.spec.ts b/frontend/ai.client/src/app/components/topnav/topnav.spec.ts index 4531838f..2e8b7917 100644 --- a/frontend/ai.client/src/app/components/topnav/topnav.spec.ts +++ b/frontend/ai.client/src/app/components/topnav/topnav.spec.ts @@ -3,18 +3,33 @@ import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { signal } from '@angular/core'; import { SessionService } from '../../session/services/session/session.service'; +import { ChatStateService } from '../../session/services/chat/chat-state.service'; import { SidenavService } from '../../services/sidenav/sidenav.service'; describe('Topnav', () => { let mockRouter: any; let mockSessionService: any; let mockSidenavService: any; + let mockChatStateService: any; + let metadataLoading: ReturnType>; + let loadingSessionIds: Set; beforeEach(() => { TestBed.resetTestingModule(); mockRouter = { navigate: vi.fn() }; + metadataLoading = signal(false); mockSessionService = { currentSession: signal({ sessionId: 'test-session', title: 'Test Session' }), + sessionMetadataResource: { isLoading: metadataLoading }, + markSessionRead: vi.fn(), + markSessionUnread: vi.fn(), + refreshSessions: vi.fn(), + }; + loadingSessionIds = new Set(); + mockChatStateService = { + isSessionLoading: (id: string) => loadingSessionIds.has(id), + markSessionUnread: vi.fn(), + clearSessionUnread: vi.fn(), }; mockSidenavService = { open: vi.fn() }; @@ -22,6 +37,7 @@ describe('Topnav', () => { providers: [ { provide: Router, useValue: mockRouter }, { provide: SessionService, useValue: mockSessionService }, + { provide: ChatStateService, useValue: mockChatStateService }, { provide: SidenavService, useValue: mockSidenavService }, ], }); @@ -52,4 +68,61 @@ describe('Topnav', () => { component.openSidenav(); expect(mockSidenavService.open).toHaveBeenCalled(); }); + + describe('title pending / fallback', () => { + it('is not pending when the session already has a title', async () => { + const component = await createComponent(); + expect((component as any).titlePending()).toBe(false); + expect((component as any).displayTitle()).toBe('Test Session'); + }); + + it('is pending while metadata is still loading (hard refresh)', async () => { + mockSessionService.currentSession.set({ sessionId: 'sess-1', title: '' }); + metadataLoading.set(true); + const component = await createComponent(); + expect((component as any).titlePending()).toBe(true); + }); + + it('is pending while a brand-new session is streaming its first response', async () => { + mockSessionService.currentSession.set({ sessionId: 'sess-1', title: '' }); + loadingSessionIds.add('sess-1'); + const component = await createComponent(); + expect((component as any).titlePending()).toBe(true); + }); + + it('falls back to "Untitled Session" once resolved with no title', async () => { + // Not loading, not streaming, empty title β†’ the skeleton must clear. + mockSessionService.currentSession.set({ sessionId: 'sess-1', title: '' }); + const component = await createComponent(); + expect((component as any).titlePending()).toBe(false); + expect((component as any).displayTitle()).toBe('Untitled Session'); + }); + }); + + describe('mark as read/unread toggle', () => { + it('marks a read session unread and surfaces the sidebar dot optimistically', async () => { + mockSessionService.currentSession.set({ sessionId: 'sess-1', title: 'T', unread: false }); + const component = await createComponent(); + + expect((component as any).isCurrentUnread()).toBe(false); + (component as any).onToggleReadClick(new Event('click')); + + expect(mockSessionService.markSessionUnread).toHaveBeenCalled(); + expect(mockChatStateService.markSessionUnread).toHaveBeenCalledWith('sess-1'); + expect(mockSessionService.markSessionRead).not.toHaveBeenCalled(); + }); + + it('marks an unread session read, clears the client dot, and refreshes the sidebar', async () => { + mockSessionService.currentSession.set({ sessionId: 'sess-1', title: 'T', unread: true }); + const component = await createComponent(); + + expect((component as any).isCurrentUnread()).toBe(true); + (component as any).onToggleReadClick(new Event('click')); + + expect(mockSessionService.markSessionRead).toHaveBeenCalled(); + expect(mockChatStateService.clearSessionUnread).toHaveBeenCalledWith('sess-1'); + expect(mockSessionService.refreshSessions).toHaveBeenCalled(); + expect(mockSessionService.markSessionUnread).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/ai.client/src/app/components/topnav/topnav.ts b/frontend/ai.client/src/app/components/topnav/topnav.ts index 4c9ee452..f629b7e2 100644 --- a/frontend/ai.client/src/app/components/topnav/topnav.ts +++ b/frontend/ai.client/src/app/components/topnav/topnav.ts @@ -1,20 +1,124 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, ChangeDetectionStrategy, computed, signal, afterNextRender, Injector, input, output } from '@angular/core'; import { Router } from '@angular/router'; +import { Dialog } from '@angular/cdk/dialog'; +import { CdkMenuTrigger, CdkMenu, CdkMenuItem } from '@angular/cdk/menu'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { firstValueFrom } from 'rxjs'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroChevronDown, heroTrash, heroPencilSquare, heroArrowUpOnSquare, heroCloudArrowUp, heroEnvelope, heroEnvelopeOpen } from '@ng-icons/heroicons/outline'; import { SessionService } from '../../session/services/session/session.service'; +import { ChatStateService } from '../../session/services/chat/chat-state.service'; +import { ShareModalComponent, ShareModalData } from '../../session/components/share-modal'; +import { ExportDialogComponent, ExportDialogData } from '../../session/components/export-dialog'; +import { UserService } from '../../auth/user.service'; import { SidenavService } from '../../services/sidenav/sidenav.service'; +import { ToastService } from '../../services/toast/toast.service'; +import { ConfirmationDialogComponent, ConfirmationDialogData } from '../confirmation-dialog'; +import { Assistant } from '../../assistants/models/assistant.model'; +import { AssistantIndicatorComponent } from '../../session/components/assistant-indicator/assistant-indicator.component'; @Component({ selector: 'app-topnav', - imports: [], + imports: [NgIcon, CdkMenuTrigger, CdkMenu, CdkMenuItem, AssistantIndicatorComponent], + providers: [provideIcons({ heroChevronDown, heroTrash, heroPencilSquare, heroArrowUpOnSquare, heroCloudArrowUp, heroEnvelope, heroEnvelopeOpen })], templateUrl: './topnav.html', styleUrl: './topnav.css', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class Topnav { private router = inject(Router); protected sidenavService = inject(SidenavService); protected sessionService = inject(SessionService); + private chatStateService = inject(ChatStateService); + private dialog = inject(Dialog); + private toastService = inject(ToastService); + private userService = inject(UserService); + private injector = inject(Injector); + readonly currentSession = this.sessionService.currentSession; + /** + * The assistant/agent attached to the active conversation, surfaced as a + * chip beside the session title. Null when the conversation has no assistant. + * Owned by the session page and threaded through the chat container. + */ + readonly assistant = input(null); + /** Whether the current user owns the assistant (gates Edit/Share actions). */ + readonly isAssistantOwner = input(false); + /** True while the attached assistant is still being fetched. */ + readonly isLoadingAssistant = input(false); + + readonly assistantNewSession = output(); + readonly assistantEdit = output(); + readonly assistantShare = output(); + + /** + * True while the active session's metadata is being fetched (e.g. on a hard + * refresh, where the session id and title arrive together only once the + * request resolves). Drives the title skeleton so the header isn't blank + * during that window. + */ + protected readonly titleLoading = computed(() => + this.sessionService.sessionMetadataResource.isLoading() + ); + + /** + * True only while a title is genuinely still expected β€” so the skeleton + * can never outlive a resolved-but-untitled session. Two pending sources: + * 1. Metadata is still being fetched (hard refresh). + * 2. A brand-new conversation whose title is generating server-side and + * arrives mid-stream via the `session_title` SSE event; bounded to the + * active response, so once the stream ends without a title we fall + * back to the static "Untitled Session" label instead of shimmering + * forever. + * A session that already has a title is never pending. + */ + protected readonly titlePending = computed(() => { + const session = this.currentSession(); + if (session.title) { + return false; + } + if (this.titleLoading()) { + return true; + } + return this.chatStateService.isSessionLoading(session.sessionId); + }); + + /** Header label once a title is no longer pending β€” real title or fallback. */ + protected readonly displayTitle = computed( + () => this.currentSession().title || 'Untitled Session' + ); + + /** True while the title is being edited inline. */ + protected readonly renaming = signal(false); + + /** Holds the current value of the inline rename input. */ + protected readonly renameValue = signal(''); + + /** True while the current session is being deleted. */ + protected readonly deleting = signal(false); + + /** + * Menu positioning - opens below the trigger, aligned to its start edge, + * with an upward fallback. + */ + protected readonly menuPositions: ConnectedPosition[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 4 + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -4 + } + ]; + newChat() { this.router.navigate(['']); } @@ -22,4 +126,182 @@ export class Topnav { openSidenav() { this.sidenavService.open(); } + + /** + * Enters inline rename mode, seeding the input with the current title and + * focusing it once the template re-renders. + */ + protected onRenameClick(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.renameValue.set(this.currentSession().title || ''); + this.renaming.set(true); + + afterNextRender(() => { + const input = document.querySelector('input[aria-label="Rename conversation title"]'); + if (input) { + input.focus(); + input.select(); + } + }, { injector: this.injector }); + } + + /** + * Applies the rename optimistically: the header and sidenav update + * immediately and rename mode exits without waiting on the API. Only on + * failure do we revert to the previous title and surface a toast. + */ + protected async onRenameSubmit(): Promise { + const session = this.currentSession(); + const sessionId = session.sessionId; + const previousTitle = session.title; + const newTitle = this.renameValue().trim(); + + if (!newTitle || newTitle === previousTitle) { + this.onRenameCancel(); + return; + } + + // Optimistically apply the new title and leave rename mode right away. + this.applyTitle(sessionId, newTitle); + this.renaming.set(false); + + try { + await this.sessionService.updateSessionTitle(sessionId, newTitle); + this.sessionService.sessionsResource.reload(); + } catch (error) { + console.error('Failed to rename session:', error); + // Roll back the optimistic change. + this.applyTitle(sessionId, previousTitle); + this.sessionService.sessionsResource.reload(); + this.toastService.error( + 'Failed to rename', + 'There was an error renaming the conversation. Please try again.' + ); + } + } + + /** + * Reflects a title into the local cache and, when it matches the active + * session, the currentSession signal so the header updates instantly. + */ + private applyTitle(sessionId: string, title: string): void { + this.sessionService.updateSessionTitleInCache(sessionId, title); + if (this.sessionService.currentSession().sessionId === sessionId) { + this.sessionService.currentSession.update(current => ({ ...current, title })); + } + } + + /** Cancels rename mode without saving. */ + protected onRenameCancel(): void { + this.renaming.set(false); + } + + /** Enter submits, Escape cancels. */ + protected onRenameKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter') { + event.preventDefault(); + this.onRenameSubmit(); + } else if (event.key === 'Escape') { + event.preventDefault(); + this.onRenameCancel(); + } + } + + /** True when the active session currently carries the durable unread flag. */ + protected readonly isCurrentUnread = computed(() => this.currentSession().unread === true); + + /** + * Toggles the session's durable unread flag from the options menu. When + * unread, marks it read (clears the sidebar dot); otherwise marks it unread + * ("remind me to revisit"), which re-surfaces the dot. + */ + protected onToggleReadClick(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + const session = this.currentSession(); + if (session.unread) { + void this.sessionService.markSessionRead(session); + this.chatStateService.clearSessionUnread(session.sessionId); + // markSessionRead relies on the watermark (no refetch); kick the sidebar + // list to re-render so its dot clears too, not just this header's menu. + this.sessionService.refreshSessions(); + } else { + // markSessionUnread already refetches the list; the client-side flag + // surfaces the sidebar dot immediately (server flag is eventually + // consistent), and clears when the session is next opened. + void this.sessionService.markSessionUnread(session); + this.chatStateService.markSessionUnread(session.sessionId); + } + } + + /** Opens the share modal for the current session. */ + protected onShareClick(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + this.dialog.open(ShareModalComponent, { + data: { + sessionId: this.currentSession().sessionId, + ownerEmail: this.userService.currentUser()?.email ?? '', + } as ShareModalData, + }); + } + + /** Opens the "Save to…" export dialog for the current session. */ + protected onExportClick(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + const session = this.currentSession(); + this.dialog.open(ExportDialogComponent, { + data: { + sessionId: session.sessionId, + title: session.title, + } as ExportDialogData, + }); + } + + /** + * Confirms and deletes the current session, navigating home afterwards. + */ + protected async onDeleteClick(event: Event): Promise { + event.preventDefault(); + event.stopPropagation(); + + const sessionId = this.currentSession().sessionId; + + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Delete Conversation', + message: 'Are you sure you want to delete this conversation? This action cannot be undone. Any shared links to this conversation will stop working.', + confirmText: 'Delete', + cancelText: 'Cancel', + destructive: true + } as ConfirmationDialogData + }); + + const confirmed = await firstValueFrom(dialogRef.closed); + if (!confirmed) { + return; + } + + try { + this.deleting.set(true); + await this.sessionService.deleteSession(sessionId); + this.toastService.success( + 'Conversation deleted', + 'The conversation has been permanently deleted.' + ); + this.router.navigate(['']); + } catch (error) { + console.error('Failed to delete session:', error); + this.toastService.error( + 'Failed to delete', + 'There was an error deleting the conversation. Please try again.' + ); + } finally { + this.deleting.set(false); + } + } } diff --git a/frontend/ai.client/src/app/memory-spaces/components/create-space-dialog.component.ts b/frontend/ai.client/src/app/memory-spaces/components/create-space-dialog.component.ts new file mode 100644 index 00000000..ab213766 --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/components/create-space-dialog.component.ts @@ -0,0 +1,175 @@ +import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroXMark, heroCircleStack } from '@ng-icons/heroicons/outline'; +import { MemorySpaceSummary, SpaceTemplate } from '../models/memory-space.model'; +import { MemorySpaceService } from '../services/memory-space.service'; + +export interface CreateSpaceDialogData { + templates: SpaceTemplate[]; +} + +/** The created space, or `undefined` if cancelled. */ +export type CreateSpaceDialogResult = MemorySpaceSummary | undefined; + +/** + * Create a Memory Space from a template. Collects a name + template choice, + * calls the service, and closes with the created space so the parent can + * navigate straight into it. + */ +@Component({ + selector: 'app-create-space-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, NgIcon], + providers: [provideIcons({ heroXMark, heroCircleStack })], + host: { + class: 'block', + '(keydown.escape)': 'onCancel()', + }, + template: ` + + +
+ +
+ `, + styles: ` + @import "tailwindcss"; + @custom-variant dark (&:where(.dark, .dark *)); + .dialog-backdrop { animation: backdrop-fade-in 200ms ease-out; } + @keyframes backdrop-fade-in { from { opacity: 0; } to { opacity: 1; } } + .dialog-panel { animation: dialog-fade-in-up 200ms ease-out; } + @keyframes dialog-fade-in-up { + from { opacity: 0; transform: translateY(1rem) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + `, +}) +export class CreateSpaceDialogComponent { + protected readonly dialogRef = inject>(DialogRef); + protected readonly data = inject(DIALOG_DATA); + private readonly service = inject(MemorySpaceService); + + protected readonly name = signal(''); + protected readonly template = signal(this.data.templates[0]?.templateId ?? 'blank'); + protected readonly saving = signal(false); + protected readonly error = signal(null); + + protected async onCreate(): Promise { + const name = this.name().trim(); + if (!name) { + return; + } + this.saving.set(true); + this.error.set(null); + try { + const space = await this.service.createSpace({ name, template: this.template() }); + this.dialogRef.close(space); + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to create space'); + } finally { + this.saving.set(false); + } + } + + protected onCancel(): void { + this.dialogRef.close(undefined); + } +} diff --git a/frontend/ai.client/src/app/memory-spaces/components/entry-dialog.component.ts b/frontend/ai.client/src/app/memory-spaces/components/entry-dialog.component.ts new file mode 100644 index 00000000..74db388e --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/components/entry-dialog.component.ts @@ -0,0 +1,235 @@ +import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroXMark, heroDocumentText, heroChevronDown } from '@ng-icons/heroicons/outline'; +import { EntryType } from '../models/memory-space.model'; +import { MemorySpaceService } from '../services/memory-space.service'; + +export interface EntryDialogData { + spaceId: string; + /** Present in view/edit mode; absent to create a new entry. */ + slug?: string; + /** Prefill for edit mode so the body/type/description show before the read resolves. */ + type?: EntryType; + description?: string; + /** editor+ may write; a viewer opens read-only. */ + canEdit: boolean; +} + +export type EntryDialogResult = { action: 'saved' | 'deleted' } | undefined; + +/** + * View, edit, or create a single Memory Space entry (one markdown file). + * Editors get a slug/type/description/body form; viewers get a read-only body. + */ +@Component({ + selector: 'app-entry-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, NgIcon], + providers: [provideIcons({ heroXMark, heroDocumentText, heroChevronDown })], + host: { + class: 'block', + '(keydown.escape)': 'onCancel()', + }, + template: ` + + +
+ +
+ `, + styles: ` + @import "tailwindcss"; + @custom-variant dark (&:where(.dark, .dark *)); + .dialog-backdrop { animation: backdrop-fade-in 200ms ease-out; } + @keyframes backdrop-fade-in { from { opacity: 0; } to { opacity: 1; } } + .dialog-panel { animation: dialog-fade-in-up 200ms ease-out; } + @keyframes dialog-fade-in-up { + from { opacity: 0; transform: translateY(1rem) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + `, +}) +export class EntryDialogComponent { + protected readonly dialogRef = inject>(DialogRef); + protected readonly data = inject(DIALOG_DATA); + private readonly service = inject(MemorySpaceService); + + protected readonly canEdit = this.data.canEdit; + protected readonly slug = signal(this.data.slug ?? ''); + protected readonly type = signal(this.data.type ?? 'fact'); + protected readonly description = signal(this.data.description ?? ''); + protected readonly body = signal(''); + protected readonly loading = signal(!!this.data.slug); + protected readonly saving = signal(false); + protected readonly error = signal(null); + + protected readonly isCreate = computed(() => !this.data.slug); + + constructor() { + if (this.data.slug) { + void this.loadBody(this.data.slug); + } + } + + private async loadBody(slug: string): Promise { + this.loading.set(true); + try { + const entry = await this.service.readEntry(this.data.spaceId, slug); + this.body.set(entry.content); + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to load entry'); + } finally { + this.loading.set(false); + } + } + + protected async onSave(): Promise { + const slug = this.slug().trim(); + if (!slug) { + return; + } + this.saving.set(true); + this.error.set(null); + try { + await this.service.upsertEntry(this.data.spaceId, slug, { + body: this.body(), + type: this.type(), + description: this.description(), + }); + this.dialogRef.close({ action: 'saved' }); + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to save entry'); + } finally { + this.saving.set(false); + } + } + + protected onCancel(): void { + this.dialogRef.close(undefined); + } +} diff --git a/frontend/ai.client/src/app/memory-spaces/components/share-space-dialog.component.ts b/frontend/ai.client/src/app/memory-spaces/components/share-space-dialog.component.ts new file mode 100644 index 00000000..0dcba090 --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/components/share-space-dialog.component.ts @@ -0,0 +1,328 @@ +import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroXMark, + heroShare, + heroUserPlus, + heroTrash, + heroChevronDown, +} from '@ng-icons/heroicons/outline'; +import { MemorySpaceSummary, ShareRole, SpaceMember } from '../models/memory-space.model'; +import { MemorySpaceService } from '../services/memory-space.service'; + +export interface ShareSpaceDialogData { + space: MemorySpaceSummary; +} + +export type ShareSpaceDialogResult = { action: 'shared' } | undefined; + +/** A working grant row (mirrors SpaceMember minus the server timestamp). */ +interface GrantRow { + email: string; + permission: ShareRole; +} + +/** + * Share a Memory Space with other users by email (owner only). Mirrors the + * assistant share dialog's add-by-email + current-shares + delta-on-save + * ergonomics, over the `/memory/spaces/{id}/shares` endpoints. No visibility + * concept β€” a grant simply exists (identity-based access, no content gate). + */ +@Component({ + selector: 'app-share-space-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, NgIcon], + providers: [provideIcons({ heroXMark, heroShare, heroUserPlus, heroTrash, heroChevronDown })], + host: { + class: 'block', + '(keydown.escape)': 'onCancel()', + }, + template: ` + + +
+ +
+ `, + styles: ` + @import "tailwindcss"; + @custom-variant dark (&:where(.dark, .dark *)); + .dialog-backdrop { animation: backdrop-fade-in 200ms ease-out; } + @keyframes backdrop-fade-in { from { opacity: 0; } to { opacity: 1; } } + .dialog-panel { animation: dialog-fade-in-up 200ms ease-out; } + @keyframes dialog-fade-in-up { + from { opacity: 0; transform: translateY(1rem) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + `, +}) +export class ShareSpaceDialogComponent { + protected readonly dialogRef = inject>(DialogRef); + protected readonly data = inject(DIALOG_DATA); + private readonly service = inject(MemorySpaceService); + + protected readonly emailInput = signal(''); + protected readonly newPermission = signal('viewer'); + protected readonly grants = signal([]); + protected readonly loading = signal(true); + protected readonly saving = signal(false); + protected readonly error = signal(null); + + /** Snapshot loaded from the API β€” diffed on save to compute the grant deltas. */ + private initialGrants: GrantRow[] = []; + + private static readonly EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + constructor() { + void this.loadShares(); + } + + protected async loadShares(): Promise { + this.loading.set(true); + try { + const response = await this.service.listShares(this.data.space.spaceId); + const rows = (response?.members ?? []).map((m: SpaceMember) => ({ + email: m.email, + permission: m.permission, + })); + this.initialGrants = rows.map((r) => ({ ...r })); + this.grants.set(rows); + } catch { + // A viewer/editor without owner rights can't list β€” start empty and let + // Save surface any 403. Initial-load failure is not a user-facing error. + this.initialGrants = []; + this.grants.set([]); + } finally { + this.loading.set(false); + } + } + + protected addEmail(): void { + const email = this.emailInput().trim().toLowerCase(); + if (!email) { + return; + } + if (!ShareSpaceDialogComponent.EMAIL_RE.test(email)) { + this.flashError('Please enter a valid email address'); + return; + } + if (this.grants().some((g) => g.email === email)) { + this.flashError('That person already has access'); + return; + } + this.grants.update((current) => [...current, { email, permission: this.newPermission() }]); + this.emailInput.set(''); + } + + protected setPermission(email: string, permission: ShareRole): void { + this.grants.update((current) => + current.map((g) => (g.email === email ? { ...g, permission } : g)), + ); + } + + protected removeEmail(email: string): void { + this.grants.update((current) => current.filter((g) => g.email !== email)); + } + + protected async onSave(): Promise { + this.saving.set(true); + this.error.set(null); + const spaceId = this.data.space.spaceId; + + try { + const initial = new Map(this.initialGrants.map((g) => [g.email, g.permission])); + const next = new Map(this.grants().map((g) => [g.email, g.permission])); + + for (const [email, permission] of next) { + const previous = initial.get(email); + if (previous === undefined) { + await this.service.addShare(spaceId, { email, permission }); + } else if (previous !== permission) { + await this.service.updateShare(spaceId, email, permission); + } + } + for (const [email] of initial) { + if (!next.has(email)) { + await this.service.removeShare(spaceId, email); + } + } + this.dialogRef.close({ action: 'shared' }); + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to save shares'); + } finally { + this.saving.set(false); + } + } + + protected onCancel(): void { + this.dialogRef.close(undefined); + } + + private flashError(message: string): void { + this.error.set(message); + setTimeout(() => this.error.set(null), 3000); + } +} diff --git a/frontend/ai.client/src/app/memory-spaces/memory-space-detail.page.html b/frontend/ai.client/src/app/memory-spaces/memory-space-detail.page.html new file mode 100644 index 00000000..13354454 --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/memory-space-detail.page.html @@ -0,0 +1,183 @@ +
+
+ + + + @if (loading()) { + + Loading memory space… + } @else if (error() && !space()) { + + } @else if (space(); as sp) { + +
+
+
+

+ {{ sp.name }} +

+ + {{ sp.role }} + +
+ @if (!canEdit()) { +

You have read-only access to this space.

+ } +
+ +
+ @if (isOwner()) { + + } + + +
+
+ + @if (error()) { + + } + + +
+
+
+

Index (MEMORY.md)

+

+ The always-loaded catalog of one-line pointers into this space. +

+
+ @if (canEdit()) { + + } +
+ +
+ + +
+
+
+

+ Entries + {{ entries().length }} +

+

+ Typed markdown files fetched on demand. +

+
+ @if (canEdit()) { + + } +
+ + @if (entries().length === 0) { +
+

No entries yet.

+
+ } @else { +
    + @for (entry of entries(); track entry.slug) { +
  • +
    +
    +

    {{ entry.slug }}

    + + {{ entry.type }} + +
    + @if (entry.description) { +

    {{ entry.description }}

    + } +
    +
  • + } +
+ } +
+ } +
+
diff --git a/frontend/ai.client/src/app/memory-spaces/memory-space-detail.page.ts b/frontend/ai.client/src/app/memory-spaces/memory-space-detail.page.ts new file mode 100644 index 00000000..08c80279 --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/memory-space-detail.page.ts @@ -0,0 +1,238 @@ +import { Component, ChangeDetectionStrategy, inject, OnInit, signal, computed } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { Dialog } from '@angular/cdk/dialog'; +import { firstValueFrom } from 'rxjs'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroArrowLeft, + heroPlus, + heroShare, + heroArrowDownTray, + heroTrash, + heroPencilSquare, +} from '@ng-icons/heroicons/outline'; +import { MemorySpaceService } from './services/memory-space.service'; +import { MemoryEntryRef, MemorySpaceDetail } from './models/memory-space.model'; +import { EntryDialogComponent, EntryDialogData, EntryDialogResult } from './components/entry-dialog.component'; +import { + ShareSpaceDialogComponent, + ShareSpaceDialogData, + ShareSpaceDialogResult, +} from './components/share-space-dialog.component'; +import { + ConfirmationDialogComponent, + ConfirmationDialogData, +} from '../components/confirmation-dialog/confirmation-dialog.component'; + +/** + * Memory Space detail β€” view/edit the MEMORY.md index and the space's entries. + * Editors get editable fields; viewers get read-only. Owner-only actions + * (share) plus download/delete-or-leave live in the header. + */ +@Component({ + selector: 'app-memory-space-detail', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, NgIcon], + providers: [ + provideIcons({ + heroArrowLeft, + heroPlus, + heroShare, + heroArrowDownTray, + heroTrash, + heroPencilSquare, + }), + ], + templateUrl: './memory-space-detail.page.html', +}) +export class MemorySpaceDetailPage implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(Dialog); + private service = inject(MemorySpaceService); + + protected readonly space = signal(null); + protected readonly indexText = signal(''); + protected readonly loading = signal(true); + protected readonly error = signal(null); + protected readonly savingIndex = signal(false); + protected readonly exporting = signal(false); + + protected readonly canEdit = computed(() => { + const role = this.space()?.role; + return role === 'owner' || role === 'editor'; + }); + protected readonly isOwner = computed(() => this.space()?.role === 'owner'); + protected readonly entries = computed(() => this.space()?.entries ?? []); + + private get spaceId(): string { + return this.route.snapshot.paramMap.get('id') ?? ''; + } + + ngOnInit(): void { + void this.load(); + } + + private async load(): Promise { + this.loading.set(true); + this.error.set(null); + try { + const detail = await this.service.getSpace(this.spaceId); + this.space.set(detail); + this.indexText.set(detail.index); + } catch (err) { + const status = (err as { status?: number } | null)?.status; + this.error.set( + status === 404 + ? 'This memory space no longer exists or you no longer have access.' + : err instanceof Error + ? err.message + : 'Failed to load memory space', + ); + } finally { + this.loading.set(false); + } + } + + protected goBack(): void { + void this.router.navigate(['/memory-spaces']); + } + + protected async onSaveIndex(): Promise { + if (!this.canEdit()) { + return; + } + this.savingIndex.set(true); + this.error.set(null); + try { + await this.service.updateIndex(this.spaceId, this.indexText()); + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to save index'); + } finally { + this.savingIndex.set(false); + } + } + + protected async onNewEntry(): Promise { + const dialogRef = this.dialog.open(EntryDialogComponent, { + data: { spaceId: this.spaceId, canEdit: true } as EntryDialogData, + }); + const result = await firstValueFrom(dialogRef.closed); + if (result?.action === 'saved') { + await this.load(); + } + } + + protected async onOpenEntry(entry: MemoryEntryRef): Promise { + const dialogRef = this.dialog.open(EntryDialogComponent, { + data: { + spaceId: this.spaceId, + slug: entry.slug, + type: entry.type, + description: entry.description, + canEdit: this.canEdit(), + } as EntryDialogData, + }); + const result = await firstValueFrom(dialogRef.closed); + if (result?.action === 'saved') { + await this.load(); + } + } + + protected async onDeleteEntry(entry: MemoryEntryRef, event: Event): Promise { + event.stopPropagation(); + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Delete entry', + message: `Delete "${entry.slug}"? This cannot be undone.`, + confirmText: 'Delete', + cancelText: 'Cancel', + destructive: true, + } as ConfirmationDialogData, + }); + const confirmed = await firstValueFrom(dialogRef.closed); + if (confirmed) { + try { + await this.service.deleteEntry(this.spaceId, entry.slug); + await this.load(); + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to delete entry'); + } + } + } + + protected async onShare(): Promise { + const space = this.space(); + if (!space) { + return; + } + const dialogRef = this.dialog.open(ShareSpaceDialogComponent, { + data: { space } as ShareSpaceDialogData, + }); + await firstValueFrom(dialogRef.closed); + } + + protected async onDownload(): Promise { + const space = this.space(); + if (!space) { + return; + } + this.exporting.set(true); + try { + const blob = await this.service.exportSpace(this.spaceId); + this.triggerDownload(blob, `${this.safeFileName(space.name)}.zip`); + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to export space'); + } finally { + this.exporting.set(false); + } + } + + protected async onDeleteOrLeave(): Promise { + const space = this.space(); + if (!space) { + return; + } + const isOwner = space.role === 'owner'; + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: isOwner ? 'Delete memory space' : 'Leave memory space', + message: isOwner + ? `Permanently delete "${space.name}" and all its entries? This cannot be undone.` + : `Leave "${space.name}"? You'll lose access until the owner shares it again.`, + confirmText: isOwner ? 'Delete' : 'Leave', + cancelText: 'Cancel', + destructive: true, + } as ConfirmationDialogData, + }); + const confirmed = await firstValueFrom(dialogRef.closed); + if (confirmed) { + try { + await this.service.deleteOrLeave(this.spaceId); + this.goBack(); + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to remove space'); + } + } + } + + private triggerDownload(blob: Blob, fileName: string): void { + if (typeof document === 'undefined') { + return; + } + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + } + + private safeFileName(name: string): string { + const cleaned = name.trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-._]+|[-._]+$/g, ''); + return cleaned || 'memory-space'; + } +} diff --git a/frontend/ai.client/src/app/memory-spaces/memory-spaces.page.html b/frontend/ai.client/src/app/memory-spaces/memory-spaces.page.html new file mode 100644 index 00000000..994c7ef4 --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/memory-spaces.page.html @@ -0,0 +1,138 @@ +
+
+ +
+
+

+ Memory Spaces +

+

+ Named markdown "second brains" your agents read and maintain over time. +

+
+ @if (accessible() !== false) { + + } +
+ + + @if (accessible() === false) { +
+
+ } @else { + + @if (error()) { + + } + + + @if (loading() && spaces().length === 0) { + + Loading memory spaces… + } @else if (spaces().length === 0) { + +
+
+ } @else { + +
+ @for (space of spaces(); track space.spaceId) { +
+
+

+ {{ space.name }} +

+ + {{ space.role }} + +
+ +
+ + {{ templateLabel(space.template) }} + +
+ + +
+ @if (space.role === 'owner') { + + } + + +
+
+ } +
+ } + } +
+
diff --git a/frontend/ai.client/src/app/memory-spaces/memory-spaces.page.ts b/frontend/ai.client/src/app/memory-spaces/memory-spaces.page.ts new file mode 100644 index 00000000..68e1e641 --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/memory-spaces.page.ts @@ -0,0 +1,160 @@ +import { Component, ChangeDetectionStrategy, inject, OnInit, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { Dialog } from '@angular/cdk/dialog'; +import { firstValueFrom } from 'rxjs'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroPlus, + heroCircleStack, + heroShare, + heroArrowDownTray, + heroTrash, + heroArrowRightStartOnRectangle, +} from '@ng-icons/heroicons/outline'; +import { MemorySpaceService } from './services/memory-space.service'; +import { MemorySpaceSummary } from './models/memory-space.model'; +import { + CreateSpaceDialogComponent, + CreateSpaceDialogData, + CreateSpaceDialogResult, +} from './components/create-space-dialog.component'; +import { + ShareSpaceDialogComponent, + ShareSpaceDialogData, + ShareSpaceDialogResult, +} from './components/share-space-dialog.component'; +import { + ConfirmationDialogComponent, + ConfirmationDialogData, +} from '../components/confirmation-dialog/confirmation-dialog.component'; + +/** + * Memory Spaces list page β€” the "own your data" surface. Lists a user's owned + * and shared-in spaces, creates from a template, and per space: open (detail), + * share (owner), download `.zip`, delete/leave. The whole feature 404s while + * the backend kill switch is off; the facade's `accessible$` drives a graceful + * "unavailable" state rather than an error. + */ +@Component({ + selector: 'app-memory-spaces', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIcon], + providers: [ + provideIcons({ + heroPlus, + heroCircleStack, + heroShare, + heroArrowDownTray, + heroTrash, + heroArrowRightStartOnRectangle, + }), + ], + templateUrl: './memory-spaces.page.html', +}) +export class MemorySpacesPage implements OnInit { + private router = inject(Router); + private dialog = inject(Dialog); + protected readonly service = inject(MemorySpaceService); + + readonly spaces = this.service.spaces$; + readonly loading = this.service.loading$; + readonly error = this.service.error$; + readonly accessible = this.service.accessible$; + + /** Space id currently exporting, so its button can show progress. */ + protected readonly exportingId = signal(null); + + ngOnInit(): void { + void this.service.loadSpaces(); + } + + protected openSpace(space: MemorySpaceSummary): void { + void this.router.navigate(['/memory-spaces', space.spaceId]); + } + + /** Friendly template name for the card badge, falling back to the raw id. */ + protected templateLabel(templateId: string): string { + const match = this.service.templates$().find((t) => t.templateId === templateId); + if (match) { + return match.name; + } + return templateId + .split('-') + .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word)) + .join(' '); + } + + protected async onCreate(): Promise { + const dialogRef = this.dialog.open(CreateSpaceDialogComponent, { + data: { templates: this.service.templates$() } as CreateSpaceDialogData, + }); + const created = await firstValueFrom(dialogRef.closed); + if (created) { + void this.router.navigate(['/memory-spaces', created.spaceId]); + } + } + + protected async onShare(space: MemorySpaceSummary, event: Event): Promise { + event.stopPropagation(); + const dialogRef = this.dialog.open(ShareSpaceDialogComponent, { + data: { space } as ShareSpaceDialogData, + }); + await firstValueFrom(dialogRef.closed); + } + + protected async onDownload(space: MemorySpaceSummary, event: Event): Promise { + event.stopPropagation(); + this.exportingId.set(space.spaceId); + try { + const blob = await this.service.exportSpace(space.spaceId); + this.triggerDownload(blob, `${this.safeFileName(space.name)}.zip`); + } catch (err) { + console.error('Failed to export memory space:', err); + } finally { + this.exportingId.set(null); + } + } + + protected async onDeleteOrLeave(space: MemorySpaceSummary, event: Event): Promise { + event.stopPropagation(); + const isOwner = space.role === 'owner'; + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: isOwner ? 'Delete memory space' : 'Leave memory space', + message: isOwner + ? `Permanently delete "${space.name}" and all its entries? This cannot be undone.` + : `Leave "${space.name}"? You'll lose access until the owner shares it again.`, + confirmText: isOwner ? 'Delete' : 'Leave', + cancelText: 'Cancel', + destructive: true, + } as ConfirmationDialogData, + }); + const confirmed = await firstValueFrom(dialogRef.closed); + if (confirmed) { + try { + await this.service.deleteOrLeave(space.spaceId); + } catch (err) { + console.error('Failed to remove memory space:', err); + } + } + } + + private triggerDownload(blob: Blob, fileName: string): void { + if (typeof document === 'undefined') { + return; + } + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + } + + private safeFileName(name: string): string { + const cleaned = name.trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-._]+|[-._]+$/g, ''); + return cleaned || 'memory-space'; + } +} diff --git a/frontend/ai.client/src/app/memory-spaces/models/memory-space.model.ts b/frontend/ai.client/src/app/memory-spaces/models/memory-space.model.ts new file mode 100644 index 00000000..e45eaabb --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/models/memory-space.model.ts @@ -0,0 +1,95 @@ +/** + * Memory Spaces β€” user-owned, shareable markdown "second brains" (F5, A5 SPA surface). + * + * These interfaces mirror the app-api response models under + * `apis/app_api/memory_spaces/` (which serialize camelCase). Keep them in + * lockstep with that backend contract β€” a breaking change to either side must + * update both in the same PR. + */ + +/** Full role set on a space. `owner` is implicit (stored on the space). */ +export type MemoryRole = 'owner' | 'editor' | 'viewer'; + +/** The two grantable roles (a share is never `owner`). */ +export type ShareRole = 'viewer' | 'editor'; + +/** Entry kinds: mutable entity, append-only episodic, flat fact. */ +export type EntryType = 'entity' | 'episodic' | 'fact'; + +/** A template a space can be seeded from (Blank / Chief of Staff / Research Notebook). */ +export interface SpaceTemplate { + templateId: string; + name: string; + description: string; +} + +/** A space as it appears in the list (no index/entries body). */ +export interface MemorySpaceSummary { + spaceId: string; + name: string; + template: string; + role: MemoryRole; + ownerId: string; + createdAt: string; + updatedAt: string; +} + +/** GET /memory/spaces */ +export interface SpacesListResponse { + spaces: MemorySpaceSummary[]; + templates: SpaceTemplate[]; +} + +/** A manifest entry ref (the catalog row; the body is fetched on demand). */ +export interface MemoryEntryRef { + slug: string; + type: EntryType; + description: string; + size: number; + updated: string; + updatedBy: string; + indexed: Record; +} + +/** GET /memory/spaces/{id} β€” summary + the MEMORY.md index text + entry refs. */ +export interface MemorySpaceDetail extends MemorySpaceSummary { + index: string; + entries: MemoryEntryRef[]; +} + +/** GET /memory/spaces/{id}/entries/{slug} */ +export interface EntryContent { + slug: string; + content: string; +} + +/** One shared grant on a space. */ +export interface SpaceMember { + email: string; + permission: ShareRole; + createdAt: string; +} + +/** GET /memory/spaces/{id}/shares */ +export interface MembersListResponse { + members: SpaceMember[]; +} + +// ---- requests ---------------------------------------------------------- + +export interface CreateSpaceRequest { + name: string; + template: string; +} + +export interface UpsertEntryRequest { + body: string; + type?: EntryType; + description?: string; + indexed?: Record; +} + +export interface ShareRequest { + email: string; + permission: ShareRole; +} diff --git a/frontend/ai.client/src/app/memory-spaces/services/memory-space-api.service.ts b/frontend/ai.client/src/app/memory-spaces/services/memory-space-api.service.ts new file mode 100644 index 00000000..5ed548ba --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/services/memory-space-api.service.ts @@ -0,0 +1,132 @@ +import { Injectable, inject, computed } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ConfigService } from '../../services/config.service'; +import { + CreateSpaceRequest, + EntryContent, + EntryType, + MembersListResponse, + MemoryEntryRef, + MemorySpaceDetail, + MemorySpaceSummary, + ShareRequest, + ShareRole, + SpaceMember, + SpacesListResponse, + UpsertEntryRequest, +} from '../models/memory-space.model'; + +/** + * HTTP surface for the Memory Spaces user API (`/memory/spaces/*`). + * + * Thin and untyped-error by design (mirrors AssistantApiService): returns raw + * Observables and leaves state, error translation, and cache upkeep to + * MemorySpaceService. The whole surface 404s while `MEMORY_SPACES_ENABLED` is + * off on the backend β€” the facade treats that as "feature unavailable". + */ +@Injectable({ providedIn: 'root' }) +export class MemorySpaceApiService { + private http = inject(HttpClient); + private config = inject(ConfigService); + + private readonly baseUrl = computed(() => `${this.config.appApiUrl()}/memory/spaces`); + + // ---- spaces ---------------------------------------------------------- + + list(): Observable { + return this.http.get(this.baseUrl()); + } + + create(request: CreateSpaceRequest): Observable { + return this.http.post(this.baseUrl(), request); + } + + get(spaceId: string): Observable { + return this.http.get(`${this.baseUrl()}/${spaceId}`); + } + + /** Owner deletes the space; a member drops their own grant (leave). */ + remove(spaceId: string): Observable { + return this.http.delete(`${this.baseUrl()}/${spaceId}`); + } + + /** The whole space as a `.zip` of raw markdown (viewer+). */ + export(spaceId: string): Observable { + return this.http.get(`${this.baseUrl()}/${spaceId}/export`, { + responseType: 'blob', + }); + } + + // ---- index (MEMORY.md) ---------------------------------------------- + + updateIndex(spaceId: string, content: string): Observable<{ content: string }> { + return this.http.put<{ content: string }>( + `${this.baseUrl()}/${spaceId}/index`, + { content }, + ); + } + + // ---- entries --------------------------------------------------------- + + readEntry(spaceId: string, slug: string): Observable { + return this.http.get( + `${this.baseUrl()}/${spaceId}/entries/${encodeURIComponent(slug)}`, + ); + } + + upsertEntry( + spaceId: string, + slug: string, + request: UpsertEntryRequest, + ): Observable { + return this.http.put( + `${this.baseUrl()}/${spaceId}/entries/${encodeURIComponent(slug)}`, + request, + ); + } + + deleteEntry(spaceId: string, slug: string): Observable { + return this.http.delete( + `${this.baseUrl()}/${spaceId}/entries/${encodeURIComponent(slug)}`, + ); + } + + listEntries(spaceId: string, type?: EntryType): Observable<{ entries: MemoryEntryRef[] }> { + let params = new HttpParams(); + if (type) { + params = params.set('type', type); + } + return this.http.get<{ entries: MemoryEntryRef[] }>( + `${this.baseUrl()}/${spaceId}/entries`, + { params }, + ); + } + + // ---- sharing --------------------------------------------------------- + + listShares(spaceId: string): Observable { + return this.http.get(`${this.baseUrl()}/${spaceId}/shares`); + } + + addShare(spaceId: string, request: ShareRequest): Observable { + return this.http.post(`${this.baseUrl()}/${spaceId}/shares`, request); + } + + updateShare( + spaceId: string, + email: string, + permission: ShareRole, + ): Observable { + return this.http.patch( + `${this.baseUrl()}/${spaceId}/shares/${encodeURIComponent(email)}`, + { permission }, + ); + } + + removeShare(spaceId: string, email: string): Observable { + return this.http.delete( + `${this.baseUrl()}/${spaceId}/shares/${encodeURIComponent(email)}`, + ); + } +} diff --git a/frontend/ai.client/src/app/memory-spaces/services/memory-space.service.spec.ts b/frontend/ai.client/src/app/memory-spaces/services/memory-space.service.spec.ts new file mode 100644 index 00000000..133720db --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/services/memory-space.service.spec.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { MemorySpaceService } from './memory-space.service'; +import { MemorySpaceApiService } from './memory-space-api.service'; +import { MemorySpaceSummary, SpaceTemplate } from '../models/memory-space.model'; + +function stubSpace(overrides: Partial = {}): MemorySpaceSummary { + return { + spaceId: 'spc_abc123', + name: 'My Brain', + template: 'chief-of-staff', + role: 'owner', + ownerId: 'user-1', + createdAt: '2026-07-07T00:00:00Z', + updatedAt: '2026-07-07T00:00:00Z', + ...overrides, + }; +} + +const TEMPLATES: SpaceTemplate[] = [ + { templateId: 'blank', name: 'Blank', description: 'An empty wiki' }, +]; + +describe('MemorySpaceService', () => { + let service: MemorySpaceService; + let mockApi: { + list: ReturnType; + create: ReturnType; + get: ReturnType; + remove: ReturnType; + export: ReturnType; + updateIndex: ReturnType; + readEntry: ReturnType; + upsertEntry: ReturnType; + deleteEntry: ReturnType; + listShares: ReturnType; + addShare: ReturnType; + updateShare: ReturnType; + removeShare: ReturnType; + }; + + beforeEach(() => { + TestBed.resetTestingModule(); + mockApi = { + list: vi.fn(), + create: vi.fn(), + get: vi.fn(), + remove: vi.fn(), + export: vi.fn(), + updateIndex: vi.fn(), + readEntry: vi.fn(), + upsertEntry: vi.fn(), + deleteEntry: vi.fn(), + listShares: vi.fn(), + addShare: vi.fn(), + updateShare: vi.fn(), + removeShare: vi.fn(), + }; + TestBed.configureTestingModule({ + providers: [MemorySpaceService, { provide: MemorySpaceApiService, useValue: mockApi }], + }); + service = TestBed.inject(MemorySpaceService); + }); + + it('loads spaces and templates and marks the feature accessible', async () => { + const space = stubSpace(); + mockApi.list.mockReturnValue(of({ spaces: [space], templates: TEMPLATES })); + + await service.loadSpaces(); + + expect(service.spaces$()).toEqual([space]); + expect(service.templates$()).toEqual(TEMPLATES); + expect(service.accessible$()).toBe(true); + expect(service.loading$()).toBe(false); + }); + + it('marks the feature inaccessible on a 404 (kill switch off) without an error', async () => { + mockApi.list.mockReturnValue(throwError(() => ({ status: 404 }))); + + await service.loadSpaces(); + + expect(service.accessible$()).toBe(false); + expect(service.spaces$()).toEqual([]); + expect(service.error$()).toBeNull(); + }); + + it('surfaces a real error for non-gating failures', async () => { + mockApi.list.mockReturnValue(throwError(() => new Error('network blip'))); + + await expect(service.loadSpaces()).rejects.toThrow('network blip'); + expect(service.error$()).toBe('network blip'); + // accessible stays null (unresolved) so the nav entry doesn't flash in + expect(service.accessible$()).toBeNull(); + }); + + it('appends a created space to local state', async () => { + mockApi.list.mockReturnValue(of({ spaces: [], templates: TEMPLATES })); + await service.loadSpaces(); + + const created = stubSpace({ spaceId: 'spc_new', name: 'Research' }); + mockApi.create.mockReturnValue(of(created)); + + const result = await service.createSpace({ name: 'Research', template: 'blank' }); + + expect(result).toEqual(created); + expect(service.spaces$()).toContain(created); + }); + + it('removes a space from local state on delete/leave', async () => { + const space = stubSpace(); + mockApi.list.mockReturnValue(of({ spaces: [space], templates: [] })); + await service.loadSpaces(); + + mockApi.remove.mockReturnValue(of(undefined)); + await service.deleteOrLeave(space.spaceId); + + expect(service.spaces$()).toEqual([]); + }); + + it('returns the export blob', async () => { + const blob = new Blob(['zip-bytes'], { type: 'application/zip' }); + mockApi.export.mockReturnValue(of(blob)); + + const result = await service.exportSpace('spc_abc123'); + + expect(result).toBe(blob); + expect(mockApi.export).toHaveBeenCalledWith('spc_abc123'); + }); + + it('delegates share operations to the api', async () => { + mockApi.addShare.mockReturnValue(of({ email: 'a@b.edu', permission: 'viewer', createdAt: '' })); + mockApi.updateShare.mockReturnValue(of({ email: 'a@b.edu', permission: 'editor', createdAt: '' })); + mockApi.removeShare.mockReturnValue(of(undefined)); + + await service.addShare('spc_1', { email: 'a@b.edu', permission: 'viewer' }); + await service.updateShare('spc_1', 'a@b.edu', 'editor'); + await service.removeShare('spc_1', 'a@b.edu'); + + expect(mockApi.addShare).toHaveBeenCalledWith('spc_1', { email: 'a@b.edu', permission: 'viewer' }); + expect(mockApi.updateShare).toHaveBeenCalledWith('spc_1', 'a@b.edu', 'editor'); + expect(mockApi.removeShare).toHaveBeenCalledWith('spc_1', 'a@b.edu'); + }); +}); diff --git a/frontend/ai.client/src/app/memory-spaces/services/memory-space.service.ts b/frontend/ai.client/src/app/memory-spaces/services/memory-space.service.ts new file mode 100644 index 00000000..67bc1625 --- /dev/null +++ b/frontend/ai.client/src/app/memory-spaces/services/memory-space.service.ts @@ -0,0 +1,141 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { + CreateSpaceRequest, + EntryContent, + MembersListResponse, + MemoryEntryRef, + MemorySpaceDetail, + MemorySpaceSummary, + ShareRequest, + ShareRole, + SpaceMember, + SpaceTemplate, + UpsertEntryRequest, +} from '../models/memory-space.model'; +import { MemorySpaceApiService } from './memory-space-api.service'; + +/** + * Signal-based state for the Memory Spaces feature. Mirrors the assistants / + * schedules facades: private mutable signals, readonly public views, and async + * methods that round-trip through the API service and keep local state in sync. + * + * `accessible$` rides the list call the same way ScheduleService does: a + * successful (even empty) list means the caller can use the feature and the + * `MEMORY_SPACES_ENABLED` kill switch is on; a 404 flips it false so the nav + * entry and page hide gracefully instead of showing an error. + */ +@Injectable({ providedIn: 'root' }) +export class MemorySpaceService { + private apiService = inject(MemorySpaceApiService); + + private spaces = signal([]); + private templates = signal([]); + private loading = signal(false); + private error = signal(null); + private accessible = signal(null); + + readonly spaces$ = this.spaces.asReadonly(); + readonly templates$ = this.templates.asReadonly(); + readonly loading$ = this.loading.asReadonly(); + readonly error$ = this.error.asReadonly(); + readonly accessible$ = this.accessible.asReadonly(); + + async loadSpaces(): Promise { + this.loading.set(true); + this.error.set(null); + + try { + const response = await firstValueFrom(this.apiService.list()); + this.spaces.set(response?.spaces ?? []); + this.templates.set(response?.templates ?? []); + this.accessible.set(true); + } catch (err: unknown) { + const status = (err as { status?: number } | null)?.status; + if (status === 404) { + // Kill switch off β€” fail gracefully rather than surfacing an error. + this.accessible.set(false); + this.spaces.set([]); + this.templates.set([]); + return; + } + this.error.set(err instanceof Error ? err.message : 'Failed to load memory spaces'); + throw err; + } finally { + this.loading.set(false); + } + } + + async createSpace(request: CreateSpaceRequest): Promise { + this.error.set(null); + try { + const space = await firstValueFrom(this.apiService.create(request)); + this.spaces.update((current) => [...current, space]); + return space; + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to create memory space'); + throw err; + } + } + + getSpace(spaceId: string): Promise { + return firstValueFrom(this.apiService.get(spaceId)); + } + + /** Owner deletes the whole space; a member drops their own grant (leave). */ + async deleteOrLeave(spaceId: string): Promise { + this.error.set(null); + try { + await firstValueFrom(this.apiService.remove(spaceId)); + this.spaces.update((current) => current.filter((s) => s.spaceId !== spaceId)); + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to remove memory space'); + throw err; + } + } + + /** Fetch the `.zip` export blob for a space. */ + exportSpace(spaceId: string): Promise { + return firstValueFrom(this.apiService.export(spaceId)); + } + + // ---- index (MEMORY.md) + entries ------------------------------------ + + updateIndex(spaceId: string, content: string): Promise<{ content: string }> { + return firstValueFrom(this.apiService.updateIndex(spaceId, content)); + } + + readEntry(spaceId: string, slug: string): Promise { + return firstValueFrom(this.apiService.readEntry(spaceId, slug)); + } + + upsertEntry( + spaceId: string, + slug: string, + request: UpsertEntryRequest, + ): Promise { + return firstValueFrom(this.apiService.upsertEntry(spaceId, slug, request)); + } + + deleteEntry(spaceId: string, slug: string): Promise { + return firstValueFrom(this.apiService.deleteEntry(spaceId, slug)); + } + + // ---- sharing --------------------------------------------------------- + + listShares(spaceId: string): Promise { + return firstValueFrom(this.apiService.listShares(spaceId)); + } + + addShare(spaceId: string, request: ShareRequest): Promise { + return firstValueFrom(this.apiService.addShare(spaceId, request)); + } + + updateShare(spaceId: string, email: string, permission: ShareRole): Promise { + return firstValueFrom(this.apiService.updateShare(spaceId, email, permission)); + } + + removeShare(spaceId: string, email: string): Promise { + return firstValueFrom(this.apiService.removeShare(spaceId, email)); + } +} diff --git a/frontend/ai.client/src/app/schedules/models/grant.model.ts b/frontend/ai.client/src/app/schedules/models/grant.model.ts new file mode 100644 index 00000000..09c1e6a1 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/models/grant.model.ts @@ -0,0 +1,20 @@ +/** + * Front-end model for the headless-grant status surfaced by + * `apis/app_api/runs/routes.py` (`GET/POST/DELETE /runs/grant`). + * + * The grant is the platform's standing permission to act as the user + * without a live session β€” required before any of the user's schedules + * can actually fire. `enabled: false` means no active grant exists. + */ +export interface GrantStatus { + enabled: boolean; + grantId?: string | null; + createdAt?: number | null; + updatedAt?: number | null; + expiresAt?: number | null; + lastUsedAt?: number | null; +} + +export interface GrantRevokeResponse { + revoked: boolean; +} diff --git a/frontend/ai.client/src/app/schedules/models/schedule.model.ts b/frontend/ai.client/src/app/schedules/models/schedule.model.ts new file mode 100644 index 00000000..9e646cce --- /dev/null +++ b/frontend/ai.client/src/app/schedules/models/schedule.model.ts @@ -0,0 +1,119 @@ +/** + * Front-end models for scheduled agent runs (`/schedules/*`). + * + * Mirrors the backend contract from `apis/app_api/schedules/` β€” field names + * are the camelCase aliases the backend serializes. Bounded cadence set + * only (daily / weekday / weekly) β€” no cron UI, per + * docs/specs/scheduled-agent-runs.md Β§3. + */ + +export type ScheduleCadence = 'daily' | 'weekday' | 'weekly' | 'interval'; +export type IntervalUnit = 'minutes' | 'hours'; +export type ScheduledPromptState = 'active' | 'paused' | 'paused_error'; + +/** Floor for the custom "every N" cadence (mirrors backend MIN_INTERVAL_MINUTES). */ +export const MIN_INTERVAL_MINUTES = 15; + +/** Public view of a scheduled prompt (backend ScheduledPromptResponse). */ +export interface ScheduledPrompt { + scheduleId: string; + assistantId?: string | null; + label: string; + promptText: string; + cadence: ScheduleCadence; + hourLocal: number; + weekday?: number | null; + intervalValue?: number | null; + intervalUnit?: IntervalUnit | null; + timezone: string; + state: ScheduledPromptState; + stateReason?: string | null; + nextRunAt?: string | null; + lastRunAt?: string | null; + lastRunStatus?: string | null; + lastRunSessionId?: string | null; + lastError?: string | null; + runsToday: number; + maxRunsPerDay: number; + enabledTools?: string[] | null; + deliverEmail: boolean; + createdAt: string; + updatedAt: string; +} + +/** Request body for POST /schedules. */ +export interface CreateScheduleRequest { + label: string; + promptText: string; + cadence: ScheduleCadence; + hourLocal: number; + weekday?: number | null; + intervalValue?: number | null; + intervalUnit?: IntervalUnit | null; + timezone: string; + assistantId?: string | null; + enabledTools?: string[] | null; + deliverEmail?: boolean; +} + +/** + * Request body for PATCH /schedules/{id}. + * + * The backend's `update_scheduled_prompt` treats `None`/absent fields as + * "leave unchanged" (it only appends a SET clause when a value is not + * None) β€” so there is currently NO way to send a null over the wire and + * have it clear `assistantId` or `enabledTools`. The form works around + * this client-side with explicit "clear" checkboxes that omit the field + * from the outgoing JSON instead of sending null (same effective + * no-op from the backend's point of view) β€” see + * ScheduleFormPage.buildUpdateRequest. A real "detach assistant" / + * "reset tools to defaults" action requires a backend follow-up (a + * sentinel value or a separate PATCH endpoint) β€” flagged in the PR. + */ +export interface UpdateScheduleRequest { + label?: string; + promptText?: string; + cadence?: ScheduleCadence; + hourLocal?: number; + weekday?: number | null; + intervalValue?: number | null; + intervalUnit?: IntervalUnit | null; + timezone?: string; + assistantId?: string | null; + enabledTools?: string[] | null; + deliverEmail?: boolean; + state?: 'active' | 'paused'; + // A bare null cannot clear assistantId/enabledTools (the backend reads null + // as "leave unchanged"), so clearing is an explicit intent. clearTools + // re-snapshots the caller's current RBAC-allowed tools; clearAssistant + // reverts to the default agent. Not combinable with the value field. + clearAssistant?: boolean; + clearTools?: boolean; +} + +/** Response from GET /schedules. */ +export interface ScheduledPromptsListResponse { + schedules: ScheduledPrompt[]; +} + +/** + * Request body for POST /runs/now β€” fire one attended agent turn immediately + * through the headless harness (the "Run now" button). Distinct surface from + * schedules: it does not create a saved schedule, it just executes once. + */ +export interface RunNowRequest { + prompt: string; + title?: string | null; + enabledTools?: string[] | null; +} + +/** Response from POST /runs/now. The run also lands as a session. */ +export interface RunNowResponse { + runId: string; + sessionId: string; + status: string; + finalMessage: string; + stopReason?: string | null; + error?: string | null; + title?: string | null; +} diff --git a/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.html b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.html new file mode 100644 index 00000000..8ca6b32c --- /dev/null +++ b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.html @@ -0,0 +1,293 @@ +
+
+
+ + +
+
+

+ {{ mode() === 'create' ? 'New scheduled run' : 'Edit scheduled run' }} +

+

+ Run a prompt automatically on a cadence you choose. Results land in your conversation list. +

+
+ + @if (!loadingSchedule() && !loadError()) { +
+ + Runs once now β€” doesn’t create a schedule +
+ } +
+
+ + @if (loadingSchedule()) { +
+

Loading…

+
+ } @else if (loadError()) { +
+

{{ loadError() }}

+
+ } @else { +
+ +
+

Basics

+ +
+ + + @if (getFieldError('label'); as error) { +

{{ error }}

+ } +
+ +
+ + + @if (getFieldError('promptText'); as error) { +

{{ error }}

+ } +
+
+ + +
+

When it runs

+

+ Daily, weekdays, a specific day each week, or a custom interval. +

+ +
+
+ + +
+ + @if (isWeekly()) { +
+ + +
+ } + + @if (isInterval()) { +
+ + +
+
+ + +
+ } @else { +
+ + +
+ } +
+ + @if (isInterval()) { + @if (form.controls.intervalValue.touched && form.controls.intervalValue.errors?.['min']) { +

+ Interval must be at least {{ minIntervalMinutes }} minutes. +

+ } +

+ Runs on a fixed interval from the previous run β€” no fixed clock time. Minimum + {{ minIntervalMinutes }} minutes between runs. +

+ } @else { +
+ + +
+ } +
+ + +
+

Target (optional)

+

+ Point this run at a specific assistant, and/or limit which tools it may use. Leave both + unset to use your normal defaults. +

+ +
+ + + + @if (mode() === 'edit') { + + } +
+ +
+
+ Tools + @if (mode() === 'edit') { + + } +
+

+ The tool set is snapshotted when the schedule is created β€” later changes to your tool + access won't silently expand what a sleeping schedule can do. +

+
+ @for (tool of tools(); track tool.toolId) { + + } @empty { +

No tools available.

+ } +
+
+
+ + +
+ + +
+
+ } +
+
diff --git a/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.spec.ts b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.spec.ts new file mode 100644 index 00000000..3d729a3b --- /dev/null +++ b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.spec.ts @@ -0,0 +1,290 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { provideRouter, ActivatedRoute, Router } from '@angular/router'; +import { ReactiveFormsModule } from '@angular/forms'; +import { signal } from '@angular/core'; +import { ScheduleFormPage } from './schedule-form.page'; +import { ScheduleService } from '../services/schedule.service'; +import { RunNowService } from '../services/run-now.service'; +import { AssistantService } from '../../assistants/services/assistant.service'; +import { ToolService } from '../../services/tool/tool.service'; +import { ToastService } from '../../services/toast/toast.service'; +import { ScheduledPrompt } from '../models/schedule.model'; + +function stubSchedule(overrides: Partial = {}): ScheduledPrompt { + return { + scheduleId: 'sched-abc123def456', + assistantId: 'ast-1', + label: 'Morning Briefing', + promptText: 'Summarize my classes', + cadence: 'daily', + hourLocal: 7, + weekday: null, + timezone: 'America/Boise', + state: 'active', + stateReason: null, + nextRunAt: '2026-07-06T14:00:00Z', + lastRunAt: null, + lastRunStatus: null, + lastRunSessionId: null, + lastError: null, + runsToday: 0, + maxRunsPerDay: 24, + enabledTools: ['class_search'], + deliverEmail: false, + createdAt: '2026-07-05T00:00:00Z', + updatedAt: '2026-07-05T00:00:00Z', + ...overrides, + }; +} + +describe('ScheduleFormPage', () => { + let component: ScheduleFormPage; + let fixture: ComponentFixture; + + const mockScheduleService = { + getSchedule: vi.fn().mockResolvedValue(undefined), + createSchedule: vi.fn().mockResolvedValue(stubSchedule()), + updateSchedule: vi.fn().mockResolvedValue(stubSchedule()), + }; + + const mockAssistantService = { + assistants$: signal([]), + loadAssistants: vi.fn().mockResolvedValue(undefined), + }; + + const mockToolService = { + tools: signal([]), + initialized: signal(true), + loadTools: vi.fn().mockResolvedValue(undefined), + }; + + const mockToast = { success: vi.fn(), error: vi.fn(), info: vi.fn() }; + + const mockRunNow = { + run: vi.fn().mockReturnValue('bgtask-1'), + }; + + function configure(routeParams: Record = {}) { + TestBed.resetTestingModule(); + vi.clearAllMocks(); + mockScheduleService.getSchedule.mockResolvedValue(undefined); + mockRunNow.run.mockReturnValue('bgtask-1'); + + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule], + providers: [ + // Register the route the component navigates to on save/cancel + // (`router.navigate(['/schedules'])`); an empty `provideRouter([])` + // makes that navigation reject with NG04002 as an unhandled + // rejection after the test body, failing the whole vitest process. + provideRouter([{ path: 'schedules', children: [] }]), + { provide: ScheduleService, useValue: mockScheduleService }, + { provide: RunNowService, useValue: mockRunNow }, + { provide: AssistantService, useValue: mockAssistantService }, + { provide: ToolService, useValue: mockToolService }, + { provide: ToastService, useValue: mockToast }, + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: { get: (key: string) => routeParams[key] ?? null } } }, + }, + ], + }) + .overrideComponent(ScheduleFormPage, { set: { template: '
' } }) + .compileComponents(); + + fixture = TestBed.createComponent(ScheduleFormPage); + component = fixture.componentInstance; + } + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + describe('create mode', () => { + beforeEach(() => { + configure(); + component.ngOnInit(); + }); + + it('is in create mode with no scheduleId', () => { + expect(component.mode()).toBe('create'); + expect(component.scheduleId()).toBeNull(); + }); + + it('defaults weekday to Monday the first time weekly is chosen', () => { + expect(component.form.controls.weekday.value).toBeNull(); + component.form.controls.cadence.setValue('weekly'); + expect(component.form.controls.weekday.value).toBe(1); + }); + + it('rejects submit when required fields are missing', async () => { + await component.onSubmit(); + expect(mockScheduleService.createSchedule).not.toHaveBeenCalled(); + }); + + it('creates a schedule with the selected tools', async () => { + component.form.patchValue({ + label: 'Test', + promptText: 'Do the thing', + cadence: 'daily', + hourLocal: 8, + timezone: 'UTC', + }); + component.toggleTool('class_search'); + + await component.onSubmit(); + + expect(mockScheduleService.createSchedule).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'Test', + promptText: 'Do the thing', + cadence: 'daily', + hourLocal: 8, + timezone: 'UTC', + enabledTools: ['class_search'], + }), + ); + }); + + it('requires a weekday when cadence is weekly', async () => { + component.form.patchValue({ + label: 'Test', + promptText: 'Do the thing', + cadence: 'weekly', + hourLocal: 8, + timezone: 'UTC', + weekday: null, + }); + + await component.onSubmit(); + + expect(mockScheduleService.createSchedule).not.toHaveBeenCalled(); + expect(component.form.controls.weekday.errors).toEqual({ required: true }); + }); + + it('defaults the interval to every 6 hours the first time interval is chosen', () => { + expect(component.isInterval()).toBe(false); + component.form.controls.cadence.setValue('interval'); + expect(component.isInterval()).toBe(true); + expect(component.form.controls.intervalValue.value).toBe(6); + expect(component.form.controls.intervalUnit.value).toBe('hours'); + }); + + it('creates an interval schedule with value and unit', async () => { + component.form.patchValue({ + label: 'Every 6h', + promptText: 'Check in', + cadence: 'interval', + intervalValue: 6, + intervalUnit: 'hours', + }); + + await component.onSubmit(); + + expect(mockScheduleService.createSchedule).toHaveBeenCalledWith( + expect.objectContaining({ + cadence: 'interval', + intervalValue: 6, + intervalUnit: 'hours', + }), + ); + }); + + it('rejects an interval below the minimum floor', async () => { + component.form.patchValue({ + label: 'Too frequent', + promptText: 'Check in', + cadence: 'interval', + intervalValue: 5, + intervalUnit: 'minutes', + }); + + await component.onSubmit(); + + expect(mockScheduleService.createSchedule).not.toHaveBeenCalled(); + expect(component.form.controls.intervalValue.errors).toEqual({ min: true }); + }); + + it('run now hands the prompt to the background runner without saving or navigating', () => { + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + component.form.patchValue({ label: 'Test', promptText: 'Do the thing' }); + component.toggleTool('class_search'); + + component.runNow(); + + expect(mockRunNow.run).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'Do the thing', + title: 'Test', + enabledTools: ['class_search'], + }), + ); + expect(mockScheduleService.createSchedule).not.toHaveBeenCalled(); + // The view must not change β€” tracking happens in the corner toast. + expect(navigateSpy).not.toHaveBeenCalled(); + }); + + it('run now requires a prompt', () => { + component.runNow(); + expect(mockRunNow.run).not.toHaveBeenCalled(); + expect(component.form.controls.promptText.errors).toEqual({ required: true }); + }); + }); + + describe('edit mode', () => { + beforeEach(() => { + configure({ scheduleId: 'sched-abc123def456' }); + mockScheduleService.getSchedule.mockResolvedValue(stubSchedule()); + }); + + it('loads the existing schedule into the form', async () => { + component.ngOnInit(); + await Promise.resolve(); + await Promise.resolve(); + + expect(component.form.controls.label.value).toBe('Morning Briefing'); + expect(component.form.controls.assistantId.value).toBe('ast-1'); + expect(component.selectedToolIds()).toEqual(new Set(['class_search'])); + }); + + it('omits assistantId from the PATCH when unchanged and non-empty', async () => { + component.ngOnInit(); + await Promise.resolve(); + await Promise.resolve(); + + await component.onSubmit(); + + const [, request] = mockScheduleService.updateSchedule.mock.calls[0]; + expect(request.assistantId).toBe('ast-1'); + }); + + it('clearing the assistant checkbox sends clearAssistant instead of assistantId', async () => { + component.ngOnInit(); + await Promise.resolve(); + await Promise.resolve(); + + component.onClearAssistantChange(true); + await component.onSubmit(); + + const [, request] = mockScheduleService.updateSchedule.mock.calls[0]; + expect(request.clearAssistant).toBe(true); + expect(request.assistantId).toBeUndefined(); + }); + + it('clearing the tools checkbox sends clearTools instead of enabledTools', async () => { + component.ngOnInit(); + await Promise.resolve(); + await Promise.resolve(); + + component.onClearToolsChange(true); + await component.onSubmit(); + + const [, request] = mockScheduleService.updateSchedule.mock.calls[0]; + expect(request.clearTools).toBe(true); + expect(request.enabledTools).toBeUndefined(); + expect(component.selectedToolIds().size).toBe(0); + }); + }); +}); diff --git a/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.ts b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.ts new file mode 100644 index 00000000..343e7ddb --- /dev/null +++ b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.ts @@ -0,0 +1,376 @@ +import { + ChangeDetectionStrategy, + Component, + OnInit, + computed, + inject, + signal, +} from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroArrowLeft } from '@ng-icons/heroicons/outline'; +import { ScheduleService } from '../services/schedule.service'; +import { RunNowService } from '../services/run-now.service'; +import { AssistantService } from '../../assistants/services/assistant.service'; +import { Assistant } from '../../assistants/models/assistant.model'; +import { ToolService } from '../../services/tool/tool.service'; +import { + CreateScheduleRequest, + IntervalUnit, + MIN_INTERVAL_MINUTES, + RunNowRequest, + ScheduleCadence, + UpdateScheduleRequest, +} from '../models/schedule.model'; +import { ToastService } from '../../services/toast/toast.service'; + +const WEEKDAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +/** All 24 hours, labeled 12-hour-with-suffix for the select. */ +function hourOptions(): { value: number; label: string }[] { + return Array.from({ length: 24 }, (_, hour) => ({ + value: hour, + label: + hour === 0 + ? '12:00 AM' + : hour < 12 + ? `${hour}:00 AM` + : hour === 12 + ? '12:00 PM' + : `${hour - 12}:00 PM`, + })); +} + +/** IANA timezone names, via the runtime's own database when available. */ +function timezoneOptions(): string[] { + const intlWithSupport = Intl as typeof Intl & { + supportedValuesOf?: (key: string) => string[]; + }; + if (typeof intlWithSupport.supportedValuesOf === 'function') { + try { + return intlWithSupport.supportedValuesOf('timeZone'); + } catch { + // fall through to the static fallback below + } + } + return [ + 'America/Boise', + 'America/Denver', + 'America/Los_Angeles', + 'America/Chicago', + 'America/New_York', + 'America/Anchorage', + 'Pacific/Honolulu', + 'UTC', + ]; +} + +/** + * Create/edit page for a scheduled agent run. Bounded cadence UI only + * (daily / weekday / weekly at an hour + IANA timezone) β€” no cron field, + * per docs/specs/scheduled-agent-runs.md Β§3. + * + * Optional fields (assistant, tool selection) use explicit "clear" + * checkboxes rather than relying on sending null: the backend's + * `update_scheduled_prompt` reads a bare `null` as "leave unchanged", so a + * clear is signalled with the explicit `clearAssistant` / `clearTools` + * booleans instead. `clearAssistant` reverts to the default agent; + * `clearTools` re-snapshots the caller's current RBAC-allowed tools. See + * `buildUpdateRequest`. + */ +@Component({ + selector: 'app-schedule-form-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, NgIcon, RouterLink], + providers: [provideIcons({ heroArrowLeft })], + templateUrl: './schedule-form.page.html', +}) +export class ScheduleFormPage implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly fb = inject(FormBuilder); + private readonly scheduleService = inject(ScheduleService); + private readonly runNowService = inject(RunNowService); + private readonly assistantService = inject(AssistantService); + private readonly toolService = inject(ToolService); + private readonly toast = inject(ToastService); + + readonly scheduleId = signal(null); + readonly mode = computed<'create' | 'edit'>(() => (this.scheduleId() ? 'edit' : 'create')); + readonly saving = signal(false); + readonly loadError = signal(null); + readonly loadingSchedule = signal(false); + + readonly minIntervalMinutes = MIN_INTERVAL_MINUTES; + readonly intervalUnitOptions: IntervalUnit[] = ['minutes', 'hours']; + + readonly assistants = this.assistantService.assistants$; + readonly tools = this.toolService.tools; + + readonly hourOptions = hourOptions(); + readonly timezoneOptions = timezoneOptions(); + readonly weekdayLabels = WEEKDAY_LABELS; + + /** + * Whether the assistant/tools sections should send a clear intent on + * submit. Only meaningful in edit mode β€” create simply omits the field + * when unchecked and nothing has been picked. + */ + readonly clearAssistant = signal(false); + readonly clearTools = signal(false); + + readonly selectedToolIds = signal>(new Set()); + + readonly form = this.fb.group({ + label: ['', [Validators.required, Validators.maxLength(200)]], + promptText: ['', [Validators.required, Validators.maxLength(20000)]], + cadence: ['daily' as ScheduleCadence, [Validators.required]], + hourLocal: [7, [Validators.required]], + weekday: new FormControl(null), + intervalValue: new FormControl(null), + intervalUnit: ['hours' as IntervalUnit, [Validators.required]], + timezone: [this.guessTimezone(), [Validators.required]], + assistantId: new FormControl(null), + }); + + private readonly cadenceValue = signal('daily'); + readonly isWeekly = computed(() => this.cadenceValue() === 'weekly'); + readonly isInterval = computed(() => this.cadenceValue() === 'interval'); + + ngOnInit(): void { + const id = this.route.snapshot.paramMap.get('scheduleId'); + this.scheduleId.set(id); + + void this.assistantService.loadAssistants(false, false); + if (!this.toolService.initialized()) { + void this.toolService.loadTools(); + } + + if (id) { + void this.loadSchedule(id); + } + + // Keep the cadence-driven signals in sync (form values aren't signals, so + // the isWeekly/isInterval computeds read this instead of the control). + this.cadenceValue.set(this.form.controls.cadence.value ?? 'daily'); + this.form.controls.cadence.valueChanges.subscribe((cadence) => { + this.cadenceValue.set(cadence ?? 'daily'); + // "weekly" requires a weekday β€” default to Monday the first time it's chosen. + if (cadence === 'weekly' && this.form.controls.weekday.value === null) { + this.form.controls.weekday.setValue(1); + } + // "interval" needs a magnitude β€” default to every 6 hours the first time. + if (cadence === 'interval' && this.form.controls.intervalValue.value === null) { + this.form.controls.intervalValue.setValue(6); + this.form.controls.intervalUnit.setValue('hours'); + } + }); + } + + private async loadSchedule(id: string): Promise { + this.loadingSchedule.set(true); + this.loadError.set(null); + try { + const schedule = await this.scheduleService.getSchedule(id); + if (!schedule) { + this.loadError.set('This schedule no longer exists.'); + return; + } + this.form.patchValue({ + label: schedule.label, + promptText: schedule.promptText, + cadence: schedule.cadence, + hourLocal: schedule.hourLocal, + weekday: schedule.weekday ?? null, + intervalValue: schedule.intervalValue ?? null, + intervalUnit: schedule.intervalUnit ?? 'hours', + timezone: schedule.timezone, + assistantId: schedule.assistantId ?? null, + }); + this.cadenceValue.set(schedule.cadence); + this.selectedToolIds.set(new Set(schedule.enabledTools ?? [])); + } catch { + this.loadError.set('Failed to load this schedule.'); + } finally { + this.loadingSchedule.set(false); + } + } + + private guessTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch { + return 'UTC'; + } + } + + assistantName(assistantId: string | null): string { + if (!assistantId) return ''; + const match = (this.assistants() as Assistant[]).find((a) => a.assistantId === assistantId); + return match?.name ?? assistantId; + } + + toggleTool(toolId: string): void { + this.selectedToolIds.update((set) => { + const next = new Set(set); + if (next.has(toolId)) { + next.delete(toolId); + } else { + next.add(toolId); + } + return next; + }); + this.clearTools.set(false); + } + + isToolSelected(toolId: string): boolean { + return this.selectedToolIds().has(toolId); + } + + onClearAssistantChange(checked: boolean): void { + this.clearAssistant.set(checked); + if (checked) { + this.form.controls.assistantId.setValue(null); + } + } + + onClearToolsChange(checked: boolean): void { + this.clearTools.set(checked); + if (checked) { + this.selectedToolIds.set(new Set()); + } + } + + getFieldError(fieldName: 'label' | 'promptText'): string | null { + const field = this.form.get(fieldName); + if (!field || !field.touched || !field.errors) { + return null; + } + if (field.errors['required']) return 'This field is required'; + if (field.errors['maxlength']) { + return `Maximum length is ${field.errors['maxlength'].requiredLength} characters`; + } + return null; + } + + async onSubmit(): Promise { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + const value = this.form.getRawValue(); + if (value.cadence === 'weekly' && value.weekday === null) { + this.form.controls.weekday.setErrors({ required: true }); + return; + } + if (value.cadence === 'interval' && !this.isIntervalValid(value.intervalValue, value.intervalUnit)) { + this.form.controls.intervalValue.setErrors({ min: true }); + this.form.controls.intervalValue.markAsTouched(); + return; + } + + this.saving.set(true); + try { + const toolIds = Array.from(this.selectedToolIds()); + if (this.mode() === 'create') { + const request: CreateScheduleRequest = { + label: value.label!, + promptText: value.promptText!, + cadence: value.cadence!, + hourLocal: value.hourLocal!, + weekday: value.cadence === 'weekly' ? value.weekday : null, + intervalValue: value.cadence === 'interval' ? value.intervalValue : null, + intervalUnit: value.cadence === 'interval' ? value.intervalUnit : null, + timezone: value.timezone!, + assistantId: value.assistantId ?? null, + enabledTools: toolIds.length > 0 ? toolIds : null, + }; + await this.scheduleService.createSchedule(request); + this.toast.success('Schedule created', `"${request.label}" will run on the cadence you set.`); + } else { + const request = this.buildUpdateRequest(value, toolIds); + await this.scheduleService.updateSchedule(this.scheduleId()!, request); + this.toast.success('Schedule updated'); + } + this.router.navigate(['/schedules']); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save the schedule'; + this.toast.error('Could not save schedule', message); + } finally { + this.saving.set(false); + } + } + + /** + * Builds the PATCH body for edit mode. `assistantId` and `enabledTools` + * cannot be cleared with a bare null (the backend reads null as "leave + * unchanged"), so the "clear" checkboxes send an explicit `clearAssistant` + * / `clearTools` intent instead. A clear flag and a value are mutually + * exclusive (the backend rejects both), so each pair is either a clear, a + * set, or omitted. + */ + private buildUpdateRequest( + value: ReturnType, + toolIds: string[], + ): UpdateScheduleRequest { + const request: UpdateScheduleRequest = { + label: value.label!, + promptText: value.promptText!, + cadence: value.cadence!, + hourLocal: value.hourLocal!, + weekday: value.cadence === 'weekly' ? value.weekday : null, + intervalValue: value.cadence === 'interval' ? value.intervalValue : null, + intervalUnit: value.cadence === 'interval' ? value.intervalUnit : null, + timezone: value.timezone!, + }; + if (this.clearAssistant()) { + request.clearAssistant = true; + } else if (value.assistantId) { + request.assistantId = value.assistantId; + } + if (this.clearTools()) { + request.clearTools = true; + } else if (toolIds.length > 0) { + request.enabledTools = toolIds; + } + return request; + } + + /** True when value+unit clear the backend's minimum-interval floor. */ + private isIntervalValid(value: number | null, unit: IntervalUnit | null): boolean { + if (value === null || value < 1 || unit === null) { + return false; + } + const minutes = unit === 'hours' ? value * 60 : value; + return minutes >= this.minIntervalMinutes; + } + + /** + * Fire the prompt once immediately via POST /runs/now β€” the attended test + * surface. Does NOT save a schedule and does NOT change the view: the run is + * handed to `RunNowService`, which tracks it as a persistent background-task + * toast and, when it finishes, links to the session-detail conversation + * where the result renders with full formatting. Only the prompt is required. + */ + runNow(): void { + const promptText = this.form.controls.promptText.value?.trim(); + if (!promptText) { + this.form.controls.promptText.setErrors({ required: true }); + this.form.controls.promptText.markAsTouched(); + return; + } + + const toolIds = Array.from(this.selectedToolIds()); + const request: RunNowRequest = { + prompt: promptText, + title: this.form.controls.label.value?.trim() || null, + enabledTools: toolIds.length > 0 ? toolIds : null, + }; + this.runNowService.run(request); + } + + onCancel(): void { + this.router.navigate(['/schedules']); + } +} diff --git a/frontend/ai.client/src/app/schedules/schedules.page.html b/frontend/ai.client/src/app/schedules/schedules.page.html new file mode 100644 index 00000000..6bcc8591 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/schedules.page.html @@ -0,0 +1,178 @@ +
+
+ +
+
+
+

+ Scheduled runs +

+

+ Run a prompt automatically, on your own cadence, as you. +

+
+ + @if (accessible() !== false) { + + } +
+
+ + @if (accessible() === false) { +
+

Scheduled runs aren't available for your account

+

+ This feature is in a limited beta. Reach out if you'd like access. +

+
+ } @else { + + @if (!loading() && needsEnablement()) { +
+
+

Scheduled runs are not enabled

+

+ Turn this on to let your active schedules run unattended, even when you're logged out. +

+
+ +
+ } + + + @if (loading()) { +
+ @for (i of [1, 2, 3]; track i) { +
+ } +
+ } @else if (sortedSchedules().length === 0) { +
+

No schedules yet

+

+ Create your first scheduled run to have the agent work for you on a cadence. +

+
+ } @else { +
    + @for (schedule of sortedSchedules(); track schedule.scheduleId) { +
  • +
    +
    +
    + + {{ stateLabel(schedule) }} +
    +

    + {{ cadenceSummary(schedule) }} Β· {{ schedule.timezone }} +

    +

    + Next run: {{ nextRunLabel(schedule) }} +

    +

    + Last run: + @if (schedule.lastRunSessionId) { + {{ lastRunLabel(schedule) }} + } @else { + {{ lastRunLabel(schedule) }} + } +

    + + @if (needsReauth(schedule)) { +
    +
    + } @else if (needsOAuthReconnect(schedule)) { +
    +
    + } @else if (schedule.state === 'paused_error' && schedule.lastError) { +

    {{ schedule.lastError }}

    + } +
    + +
    + @if (schedule.state === 'active') { + + } @else if (schedule.state === 'paused') { + + } + + +
    +
    +
  • + } +
+ } + } +
+
diff --git a/frontend/ai.client/src/app/schedules/schedules.page.spec.ts b/frontend/ai.client/src/app/schedules/schedules.page.spec.ts new file mode 100644 index 00000000..06c03512 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/schedules.page.spec.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { signal } from '@angular/core'; +import { Dialog } from '@angular/cdk/dialog'; +import { of } from 'rxjs'; +import { SchedulesPage } from './schedules.page'; +import { ScheduleService } from './services/schedule.service'; +import { GrantService } from './services/grant.service'; +import { ToastService } from '../services/toast/toast.service'; +import { ScheduledPrompt } from './models/schedule.model'; +import { GrantStatus } from './models/grant.model'; + +function stubSchedule(overrides: Partial = {}): ScheduledPrompt { + return { + scheduleId: 'sched-abc123def456', + assistantId: null, + label: 'Morning Briefing', + promptText: 'Summarize my classes', + cadence: 'daily', + hourLocal: 7, + weekday: null, + timezone: 'America/Boise', + state: 'active', + stateReason: null, + nextRunAt: '2026-07-06T14:00:00Z', + lastRunAt: null, + lastRunStatus: null, + lastRunSessionId: null, + lastError: null, + runsToday: 0, + maxRunsPerDay: 24, + enabledTools: null, + deliverEmail: false, + createdAt: '2026-07-05T00:00:00Z', + updatedAt: '2026-07-05T00:00:00Z', + ...overrides, + }; +} + +describe('SchedulesPage', () => { + let mockScheduleService: { + schedules$: ReturnType>; + loading$: ReturnType>; + accessible$: ReturnType>; + loadSchedules: ReturnType; + pauseSchedule: ReturnType; + resumeSchedule: ReturnType; + deleteSchedule: ReturnType; + }; + let mockGrantService: { + status$: ReturnType>; + loading$: ReturnType>; + loadStatus: ReturnType; + enable: ReturnType; + }; + let mockToast: { success: ReturnType; error: ReturnType }; + + beforeEach(() => { + TestBed.resetTestingModule(); + + mockScheduleService = { + schedules$: signal([]), + loading$: signal(false), + accessible$: signal(true), + loadSchedules: vi.fn().mockResolvedValue(undefined), + pauseSchedule: vi.fn().mockResolvedValue(undefined), + resumeSchedule: vi.fn().mockResolvedValue(undefined), + deleteSchedule: vi.fn().mockResolvedValue(undefined), + }; + mockGrantService = { + status$: signal({ enabled: false }), + loading$: signal(false), + loadStatus: vi.fn().mockResolvedValue(undefined), + enable: vi.fn().mockResolvedValue({ enabled: true }), + }; + mockToast = { success: vi.fn(), error: vi.fn() }; + + TestBed.configureTestingModule({ + providers: [ + // Register the routes the component navigates to (new / edit); an + // empty provideRouter([]) would make those navigations reject with + // NG04002 as unhandled rejections and fail the vitest process. + provideRouter([ + { path: 'schedules/new', children: [] }, + { path: 'schedules/:scheduleId/edit', children: [] }, + ]), + { provide: ScheduleService, useValue: mockScheduleService }, + { provide: GrantService, useValue: mockGrantService }, + { provide: ToastService, useValue: mockToast }, + ], + }); + TestBed.overrideComponent(SchedulesPage, { set: { template: '
' } }); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + function createComponent() { + const fixture = TestBed.createComponent(SchedulesPage); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it('loads schedules and grant status on init', () => { + createComponent(); + expect(mockScheduleService.loadSchedules).toHaveBeenCalled(); + expect(mockGrantService.loadStatus).toHaveBeenCalled(); + }); + + it('needsEnablement is true when the grant is disabled', () => { + mockGrantService.status$.set({ enabled: false }); + const component = createComponent(); + expect(component.needsEnablement()).toBe(true); + }); + + it('needsEnablement is false when the grant is active', () => { + mockGrantService.status$.set({ enabled: true, grantId: 'hlg-1' }); + const component = createComponent(); + expect(component.needsEnablement()).toBe(false); + }); + + it('onEnableScheduledRuns calls grant.enable() and toasts success', async () => { + const component = createComponent(); + await component.onEnableScheduledRuns(); + expect(mockGrantService.enable).toHaveBeenCalled(); + expect(mockToast.success).toHaveBeenCalled(); + }); + + it('onEnableScheduledRuns toasts an error on failure', async () => { + mockGrantService.enable.mockRejectedValueOnce(new Error('409')); + const component = createComponent(); + await component.onEnableScheduledRuns(); + expect(mockToast.error).toHaveBeenCalled(); + }); + + it('needsReauth is true only for paused_error with reason reauth_required', () => { + const component = createComponent(); + expect( + component.needsReauth(stubSchedule({ state: 'paused_error', stateReason: 'reauth_required' })), + ).toBe(true); + expect( + component.needsReauth(stubSchedule({ state: 'paused_error', stateReason: 'oauth_required' })), + ).toBe(false); + expect(component.needsReauth(stubSchedule({ state: 'active' }))).toBe(false); + }); + + it('needsOAuthReconnect is true only for paused_error with reason oauth_required', () => { + const component = createComponent(); + expect( + component.needsOAuthReconnect( + stubSchedule({ state: 'paused_error', stateReason: 'oauth_required' }), + ), + ).toBe(true); + expect( + component.needsOAuthReconnect( + stubSchedule({ state: 'paused_error', stateReason: 'reauth_required' }), + ), + ).toBe(false); + }); + + it('onReauthenticate re-enables the grant then resumes the schedule', async () => { + const component = createComponent(); + const schedule = stubSchedule({ state: 'paused_error', stateReason: 'reauth_required' }); + + await component.onReauthenticate(schedule); + + expect(mockGrantService.enable).toHaveBeenCalled(); + expect(mockScheduleService.resumeSchedule).toHaveBeenCalledWith(schedule.scheduleId); + expect(mockToast.success).toHaveBeenCalled(); + }); + + it('onPause pauses the schedule', async () => { + const component = createComponent(); + const schedule = stubSchedule(); + await component.onPause(schedule); + expect(mockScheduleService.pauseSchedule).toHaveBeenCalledWith(schedule.scheduleId); + }); + + it('onResume resumes the schedule', async () => { + const component = createComponent(); + const schedule = stubSchedule({ state: 'paused' }); + await component.onResume(schedule); + expect(mockScheduleService.resumeSchedule).toHaveBeenCalledWith(schedule.scheduleId); + }); + + it('onDelete does nothing when the confirm dialog is cancelled', async () => { + const component = createComponent(); + const dialog = TestBed.inject(Dialog); + vi.spyOn(dialog, 'open').mockReturnValue({ closed: of(false) } as ReturnType); + + await component.onDelete(stubSchedule()); + + expect(mockScheduleService.deleteSchedule).not.toHaveBeenCalled(); + }); + + it('onDelete deletes the schedule when confirmed', async () => { + const component = createComponent(); + const dialog = TestBed.inject(Dialog); + vi.spyOn(dialog, 'open').mockReturnValue({ closed: of(true) } as ReturnType); + + const schedule = stubSchedule(); + await component.onDelete(schedule); + + expect(mockScheduleService.deleteSchedule).toHaveBeenCalledWith(schedule.scheduleId); + expect(mockToast.success).toHaveBeenCalled(); + }); + + it('cadenceSummary formats daily/weekday/weekly cadences', () => { + const component = createComponent(); + expect(component.cadenceSummary(stubSchedule({ cadence: 'daily', hourLocal: 7 }))).toBe( + 'Every day at 7:00 AM', + ); + expect(component.cadenceSummary(stubSchedule({ cadence: 'weekday', hourLocal: 13 }))).toBe( + 'Every weekday at 1:00 PM', + ); + expect( + component.cadenceSummary(stubSchedule({ cadence: 'weekly', hourLocal: 0, weekday: 1 })), + ).toBe('Every Monday at 12:00 AM'); + }); + + it('stateLabel maps states to display text', () => { + const component = createComponent(); + expect(component.stateLabel(stubSchedule({ state: 'active' }))).toBe('Active'); + expect(component.stateLabel(stubSchedule({ state: 'paused' }))).toBe('Paused'); + expect(component.stateLabel(stubSchedule({ state: 'paused_error' }))).toBe('Needs attention'); + }); +}); diff --git a/frontend/ai.client/src/app/schedules/schedules.page.ts b/frontend/ai.client/src/app/schedules/schedules.page.ts new file mode 100644 index 00000000..9618e43a --- /dev/null +++ b/frontend/ai.client/src/app/schedules/schedules.page.ts @@ -0,0 +1,265 @@ +import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; +import { Dialog } from '@angular/cdk/dialog'; +import { firstValueFrom } from 'rxjs'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroPlus, heroExclamationTriangle } from '@ng-icons/heroicons/outline'; +import { ScheduleService } from './services/schedule.service'; +import { GrantService } from './services/grant.service'; +import { ScheduledPrompt } from './models/schedule.model'; +import { + ConfirmationDialogComponent, + ConfirmationDialogData, +} from '../components/confirmation-dialog/confirmation-dialog.component'; +import { ToastService } from '../services/toast/toast.service'; + +const WEEKDAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +/** + * Schedules management page β€” list, create/edit (routed), pause/resume, + * delete, and the grant-enablement UX (docs/specs/scheduled-runs-phase-b-brief.md + * Β§2 B3). + * + * Visibility gating: this page (and the nav entry that links to it) is only + * meaningful for users with the `scheduled-runs` RBAC capability. There is + * no client-side capability signal yet (see UserPermissions), so gating + * rides the `/schedules` list call itself β€” a 403/404 (kill switch off or + * capability missing) flips `ScheduleService.accessible$` to false and this + * page renders a graceful "not available" state instead of an error. + */ +@Component({ + selector: 'app-schedules-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, NgIcon], + providers: [provideIcons({ heroPlus, heroExclamationTriangle })], + templateUrl: './schedules.page.html', +}) +export class SchedulesPage implements OnInit { + private readonly router = inject(Router); + private readonly scheduleService = inject(ScheduleService); + private readonly grantService = inject(GrantService); + private readonly dialog = inject(Dialog); + private readonly toast = inject(ToastService); + + readonly schedules = this.scheduleService.schedules$; + readonly loading = this.scheduleService.loading$; + readonly accessible = this.scheduleService.accessible$; + readonly grantStatus = this.grantService.status$; + readonly grantBusy = this.grantService.loading$; + + readonly busyScheduleIds = signal>(new Set()); + + readonly sortedSchedules = computed(() => + [...this.schedules()].sort((a, b) => a.label.localeCompare(b.label)), + ); + + readonly needsEnablement = computed(() => !this.grantStatus().enabled); + + async ngOnInit(): Promise { + await Promise.allSettled([ + this.scheduleService.loadSchedules(), + this.grantService.loadStatus(), + ]); + } + + onCreateNew(): void { + this.router.navigate(['/schedules/new']); + } + + onEdit(schedule: ScheduledPrompt): void { + this.router.navigate(['/schedules', schedule.scheduleId, 'edit']); + } + + async onEnableScheduledRuns(): Promise { + try { + await this.grantService.enable(); + this.toast.success('Scheduled runs enabled', 'Your active schedules can now run unattended.'); + } catch { + this.toast.error( + 'Could not enable scheduled runs', + 'Please try again, or refresh the page and log in again.', + ); + } + } + + /** Re-enable path for a paused_error schedule whose reason is reauth_required. */ + async onReauthenticate(schedule: ScheduledPrompt): Promise { + try { + await this.grantService.enable(); + await this.onResume(schedule); + this.toast.success('Access renewed', `"${schedule.label}" will resume on its normal cadence.`); + } catch { + this.toast.error('Could not renew access', 'Please log in again and retry.'); + } + } + + async onPause(schedule: ScheduledPrompt): Promise { + this.setBusy(schedule.scheduleId, true); + try { + await this.scheduleService.pauseSchedule(schedule.scheduleId); + } catch { + this.toast.error('Could not pause schedule'); + } finally { + this.setBusy(schedule.scheduleId, false); + } + } + + async onResume(schedule: ScheduledPrompt): Promise { + this.setBusy(schedule.scheduleId, true); + try { + await this.scheduleService.resumeSchedule(schedule.scheduleId); + } catch { + this.toast.error('Could not resume schedule'); + } finally { + this.setBusy(schedule.scheduleId, false); + } + } + + async onDelete(schedule: ScheduledPrompt): Promise { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Delete schedule', + message: `Are you sure you want to delete "${schedule.label}"? This cannot be undone.`, + confirmText: 'Delete', + cancelText: 'Cancel', + destructive: true, + } as ConfirmationDialogData, + }); + + const confirmed = await firstValueFrom(dialogRef.closed); + if (!confirmed) { + return; + } + + this.setBusy(schedule.scheduleId, true); + try { + await this.scheduleService.deleteSchedule(schedule.scheduleId); + this.toast.success('Schedule deleted'); + } catch { + this.toast.error('Could not delete schedule'); + this.setBusy(schedule.scheduleId, false); + } + } + + isBusy(scheduleId: string): boolean { + return this.busyScheduleIds().has(scheduleId); + } + + private setBusy(scheduleId: string, busy: boolean): void { + this.busyScheduleIds.update((set) => { + const next = new Set(set); + if (busy) { + next.add(scheduleId); + } else { + next.delete(scheduleId); + } + return next; + }); + } + + /** "Every day at 7:00 AM (America/Boise)" style summary. */ + cadenceSummary(schedule: ScheduledPrompt): string { + const time = this.formatHour(schedule.hourLocal); + switch (schedule.cadence) { + case 'daily': + return `Every day at ${time}`; + case 'weekday': + return `Every weekday at ${time}`; + case 'weekly': { + const day = schedule.weekday !== null && schedule.weekday !== undefined + ? WEEKDAY_LABELS[schedule.weekday] + : ''; + return `Every ${day} at ${time}`; + } + case 'interval': { + const value = schedule.intervalValue ?? 0; + const unit = schedule.intervalUnit ?? 'hours'; + const singular = value === 1 ? unit.replace(/s$/, '') : unit; + return value === 1 ? `Every ${singular}` : `Every ${value} ${singular}`; + } + default: + return time; + } + } + + private formatHour(hour: number): string { + if (hour === 0) return '12:00 AM'; + if (hour < 12) return `${hour}:00 AM`; + if (hour === 12) return '12:00 PM'; + return `${hour - 12}:00 PM`; + } + + nextRunLabel(schedule: ScheduledPrompt): string { + if (schedule.state !== 'active' || !schedule.nextRunAt) { + return 'β€”'; + } + return this.formatRelativeFuture(schedule.nextRunAt); + } + + private formatRelativeFuture(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return 'β€”'; + const diffMins = Math.round((then - Date.now()) / 60_000); + if (diffMins <= 0) return 'due now'; + if (diffMins < 60) return `in ${diffMins}m`; + const diffHours = Math.round(diffMins / 60); + if (diffHours < 24) return `in ${diffHours}h`; + return `in ${Math.round(diffHours / 24)}d`; + } + + stateBadgeClasses(schedule: ScheduledPrompt): string { + const base = 'inline-flex items-center gap-1.5 rounded-2xl px-2.5 py-0.5 text-xs/5 font-medium'; + switch (schedule.state) { + case 'active': + return `${base} bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300`; + case 'paused': + return `${base} bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300`; + case 'paused_error': + return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300`; + default: + return `${base} bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300`; + } + } + + stateLabel(schedule: ScheduledPrompt): string { + switch (schedule.state) { + case 'active': + return 'Active'; + case 'paused': + return 'Paused'; + case 'paused_error': + return 'Needs attention'; + default: + return schedule.state; + } + } + + /** True for a paused_error schedule whose stateReason marks it as needing re-auth. */ + needsReauth(schedule: ScheduledPrompt): boolean { + return schedule.state === 'paused_error' && schedule.stateReason === 'reauth_required'; + } + + /** True for a paused_error schedule whose stateReason marks it as needing an OAuth reconnect. */ + needsOAuthReconnect(schedule: ScheduledPrompt): boolean { + return schedule.state === 'paused_error' && schedule.stateReason === 'oauth_required'; + } + + lastRunLabel(schedule: ScheduledPrompt): string { + if (!schedule.lastRunAt) { + return 'Never run'; + } + const status = schedule.lastRunStatus ?? 'unknown'; + return `${status} Β· ${this.formatRelativePast(schedule.lastRunAt)}`; + } + + private formatRelativePast(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const diffMins = Math.floor((Date.now() - then) / 60_000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + return `${Math.floor(diffHours / 24)}d ago`; + } +} diff --git a/frontend/ai.client/src/app/schedules/services/grant-api.service.spec.ts b/frontend/ai.client/src/app/schedules/services/grant-api.service.spec.ts new file mode 100644 index 00000000..d7d5f114 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/grant-api.service.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { GrantApiService } from './grant-api.service'; +import { ConfigService } from '../../services/config.service'; + +const BASE = 'http://localhost:8000/runs/grant'; + +describe('GrantApiService', () => { + let service: GrantApiService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + GrantApiService, + { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, + ], + }); + service = TestBed.inject(GrantApiService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.match(() => true); + TestBed.resetTestingModule(); + }); + + it('fetches grant status', async () => { + const status = { enabled: true, grantId: 'hlg-1' }; + const promise = firstValueFrom(service.status()); + + await vi.waitFor(() => { + const req = httpMock.expectOne(BASE); + expect(req.request.method).toBe('GET'); + req.flush(status); + }); + + expect(await promise).toEqual(status); + }); + + it('enables (creates/refreshes) the grant via POST', async () => { + const status = { enabled: true, grantId: 'hlg-1' }; + const promise = firstValueFrom(service.enable()); + + await vi.waitFor(() => { + const req = httpMock.expectOne(BASE); + expect(req.request.method).toBe('POST'); + req.flush(status); + }); + + expect(await promise).toEqual(status); + }); + + it('revokes the grant via DELETE', async () => { + const promise = firstValueFrom(service.revoke()); + + await vi.waitFor(() => { + const req = httpMock.expectOne(BASE); + expect(req.request.method).toBe('DELETE'); + req.flush({ revoked: true }); + }); + + expect(await promise).toEqual({ revoked: true }); + }); +}); diff --git a/frontend/ai.client/src/app/schedules/services/grant-api.service.ts b/frontend/ai.client/src/app/schedules/services/grant-api.service.ts new file mode 100644 index 00000000..737e2120 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/grant-api.service.ts @@ -0,0 +1,32 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, computed, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ConfigService } from '../../services/config.service'; +import { GrantRevokeResponse, GrantStatus } from '../models/grant.model'; + +/** + * Thin HTTP layer over `/runs/grant` (app-api, cookie-authed). Backs the + * schedules page's enablement UX β€” a schedule cannot fire unattended + * without an active grant. + */ +@Injectable({ + providedIn: 'root', +}) +export class GrantApiService { + private http = inject(HttpClient); + private config = inject(ConfigService); + private readonly baseUrl = computed(() => `${this.config.appApiUrl()}/runs/grant`); + + status(): Observable { + return this.http.get(this.baseUrl()); + } + + /** Create-on-enable: mints/refreshes the grant from the caller's live session. */ + enable(): Observable { + return this.http.post(this.baseUrl(), {}); + } + + revoke(): Observable { + return this.http.delete(this.baseUrl()); + } +} diff --git a/frontend/ai.client/src/app/schedules/services/grant.service.spec.ts b/frontend/ai.client/src/app/schedules/services/grant.service.spec.ts new file mode 100644 index 00000000..ee309553 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/grant.service.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { GrantService } from './grant.service'; +import { GrantApiService } from './grant-api.service'; + +describe('GrantService', () => { + let service: GrantService; + let mockApi: { + status: ReturnType; + enable: ReturnType; + revoke: ReturnType; + }; + + beforeEach(() => { + TestBed.resetTestingModule(); + mockApi = { status: vi.fn(), enable: vi.fn(), revoke: vi.fn() }; + TestBed.configureTestingModule({ + providers: [GrantService, { provide: GrantApiService, useValue: mockApi }], + }); + service = TestBed.inject(GrantService); + }); + + it('defaults to disabled before any load', () => { + expect(service.status$()).toEqual({ enabled: false }); + }); + + it('loads the grant status', async () => { + mockApi.status.mockReturnValue(of({ enabled: true, grantId: 'hlg-1' })); + + await service.loadStatus(); + + expect(service.status$()).toEqual({ enabled: true, grantId: 'hlg-1' }); + }); + + it('falls back to disabled (without throwing) when status load fails', async () => { + mockApi.status.mockReturnValue(throwError(() => new Error('boom'))); + + await service.loadStatus(); + + expect(service.status$()).toEqual({ enabled: false }); + expect(service.error$()).toBe('boom'); + }); + + it('enables the grant and updates status', async () => { + mockApi.enable.mockReturnValue(of({ enabled: true, grantId: 'hlg-2' })); + + const result = await service.enable(); + + expect(result).toEqual({ enabled: true, grantId: 'hlg-2' }); + expect(service.status$()).toEqual({ enabled: true, grantId: 'hlg-2' }); + }); + + it('propagates an enable failure (e.g. 409 no session to pin)', async () => { + mockApi.enable.mockReturnValue(throwError(() => new Error('409'))); + + await expect(service.enable()).rejects.toThrow('409'); + }); + + it('revokes and resets status to disabled', async () => { + mockApi.status.mockReturnValue(of({ enabled: true, grantId: 'hlg-1' })); + await service.loadStatus(); + + mockApi.revoke.mockReturnValue(of({ revoked: true })); + await service.revoke(); + + expect(service.status$()).toEqual({ enabled: false }); + }); +}); diff --git a/frontend/ai.client/src/app/schedules/services/grant.service.ts b/frontend/ai.client/src/app/schedules/services/grant.service.ts new file mode 100644 index 00000000..8b654b79 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/grant.service.ts @@ -0,0 +1,71 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { GrantApiService } from './grant-api.service'; +import { GrantStatus } from '../models/grant.model'; + +const DISABLED_STATUS: GrantStatus = { enabled: false }; + +/** Signal-based state for the caller's headless-grant status. */ +@Injectable({ + providedIn: 'root', +}) +export class GrantService { + private apiService = inject(GrantApiService); + + private status = signal(DISABLED_STATUS); + private loading = signal(false); + private error = signal(null); + + readonly status$ = this.status.asReadonly(); + readonly loading$ = this.loading.asReadonly(); + readonly error$ = this.error.asReadonly(); + + async loadStatus(): Promise { + this.loading.set(true); + this.error.set(null); + try { + const status = await firstValueFrom(this.apiService.status()); + this.status.set(status); + } catch (err) { + // A 403/404 here means the caller shouldn't be on this page at all β€” + // the schedules list call is the source of truth for that gate, so + // just leave the grant looking disabled rather than erroring twice. + this.status.set(DISABLED_STATUS); + const errorMessage = err instanceof Error ? err.message : 'Failed to load grant status'; + this.error.set(errorMessage); + } finally { + this.loading.set(false); + } + } + + async enable(): Promise { + this.loading.set(true); + this.error.set(null); + try { + const status = await firstValueFrom(this.apiService.enable()); + this.status.set(status); + return status; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to enable scheduled runs'; + this.error.set(errorMessage); + throw err; + } finally { + this.loading.set(false); + } + } + + async revoke(): Promise { + this.loading.set(true); + this.error.set(null); + try { + await firstValueFrom(this.apiService.revoke()); + this.status.set(DISABLED_STATUS); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to revoke scheduled runs'; + this.error.set(errorMessage); + throw err; + } finally { + this.loading.set(false); + } + } +} diff --git a/frontend/ai.client/src/app/schedules/services/run-api.service.spec.ts b/frontend/ai.client/src/app/schedules/services/run-api.service.spec.ts new file mode 100644 index 00000000..7a207ef6 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/run-api.service.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { RunApiService } from './run-api.service'; +import { ConfigService } from '../../services/config.service'; +import { RunNowResponse } from '../models/schedule.model'; + +const BASE = 'http://localhost:8000/runs'; + +function stubRunResponse(overrides: Partial = {}): RunNowResponse { + return { + runId: 'run-1', + sessionId: 'sess-1', + status: 'completed', + finalMessage: 'done', + stopReason: null, + error: null, + title: null, + ...overrides, + }; +} + +describe('RunApiService', () => { + let service: RunApiService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + RunApiService, + { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, + ], + }); + service = TestBed.inject(RunApiService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.match(() => true); + TestBed.resetTestingModule(); + }); + + it('POSTs to /runs/now with the prompt payload', async () => { + const result = stubRunResponse(); + const promise = firstValueFrom(service.runNow({ prompt: 'Do the thing', title: 'Test' })); + + await vi.waitFor(() => { + const req = httpMock.expectOne(`${BASE}/now`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ prompt: 'Do the thing', title: 'Test' }); + req.flush(result); + }); + + expect(await promise).toEqual(result); + }); +}); diff --git a/frontend/ai.client/src/app/schedules/services/run-api.service.ts b/frontend/ai.client/src/app/schedules/services/run-api.service.ts new file mode 100644 index 00000000..140b6681 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/run-api.service.ts @@ -0,0 +1,25 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, computed, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ConfigService } from '../../services/config.service'; +import { RunNowRequest, RunNowResponse } from '../models/schedule.model'; + +/** + * Thin HTTP layer over `/runs/*` (app-api, cookie-authed). The "Run now" + * surface fires one attended agent turn through the exact headless machinery + * a scheduled run will use β€” it does not create a saved schedule. Synchronous + * from the caller's perspective (the response is the full run result once the + * turn drains, bounded by the harness's 300s budget). + */ +@Injectable({ + providedIn: 'root', +}) +export class RunApiService { + private http = inject(HttpClient); + private config = inject(ConfigService); + private readonly baseUrl = computed(() => `${this.config.appApiUrl()}/runs`); + + runNow(request: RunNowRequest): Observable { + return this.http.post(`${this.baseUrl()}/now`, request); + } +} diff --git a/frontend/ai.client/src/app/schedules/services/run-now.service.spec.ts b/frontend/ai.client/src/app/schedules/services/run-now.service.spec.ts new file mode 100644 index 00000000..0effb034 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/run-now.service.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { RunNowService } from './run-now.service'; +import { RunApiService } from './run-api.service'; +import { BackgroundTaskService } from '../../services/background-tasks/background-task.service'; +import { SessionService as SessionListService } from '../../session/services/session/session.service'; +import { SessionMetadata } from '../../session/services/models/session-metadata.model'; +import { RunNowResponse } from '../models/schedule.model'; + +const EMPTY_SESSION: SessionMetadata = { + sessionId: '', + userId: '', + title: '', + status: 'active', + createdAt: '', + lastMessageAt: '', + messageCount: 0, +}; + +function response(overrides: Partial = {}): RunNowResponse { + return { + runId: 'run-1', + sessionId: 'sess-1', + status: 'completed', + finalMessage: 'done', + stopReason: null, + error: null, + title: null, + ...overrides, + }; +} + +describe('RunNowService', () => { + let service: RunNowService; + let tasks: BackgroundTaskService; + const mockRunApi = { runNow: vi.fn() }; + const mockSessions = { refreshSessions: vi.fn(), currentSession: signal(EMPTY_SESSION) }; + const mockRouter = { navigate: vi.fn() }; + + beforeEach(() => { + TestBed.resetTestingModule(); + vi.clearAllMocks(); + mockSessions.currentSession.set(EMPTY_SESSION); + TestBed.configureTestingModule({ + providers: [ + RunNowService, + BackgroundTaskService, + { provide: RunApiService, useValue: mockRunApi }, + { provide: SessionListService, useValue: mockSessions }, + { provide: Router, useValue: mockRouter }, + ], + }); + service = TestBed.inject(RunNowService); + tasks = TestBed.inject(BackgroundTaskService); + }); + + it('registers a processing task immediately and fires the request', () => { + mockRunApi.runNow.mockReturnValue(of(response())); + service.run({ prompt: 'Go', title: 'My run' }); + + expect(mockRunApi.runNow).toHaveBeenCalledWith({ prompt: 'Go', title: 'My run' }); + const [task] = tasks.tasks(); + expect(task.title).toContain('My run'); + }); + + it('completes the task with a session-detail route on success and refreshes the sidebar list', () => { + mockRunApi.runNow.mockReturnValue(of(response({ sessionId: 'sess-9' }))); + service.run({ prompt: 'Go' }); + + const [task] = tasks.tasks(); + expect(task.status).toBe('completed'); + expect(task.route).toEqual(['/s', 'sess-9']); + expect(mockSessions.refreshSessions).toHaveBeenCalled(); + }); + + it('onView optimistically seeds the header title before navigating', () => { + mockRunApi.runNow.mockReturnValue(of(response({ sessionId: 'sess-9', title: 'My Briefing' }))); + service.run({ prompt: 'Go', title: 'My Briefing' }); + + const [task] = tasks.tasks(); + task.onView?.(); + + // Header title reflects the run's session immediately (not the previous one). + expect(mockSessions.currentSession().sessionId).toBe('sess-9'); + expect(mockSessions.currentSession().title).toBe('My Briefing'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/s', 'sess-9']); + }); + + it('fails the task but keeps a route when a run errors with a session', () => { + mockRunApi.runNow.mockReturnValue(of(response({ status: 'error', error: 'boom', sessionId: 'sess-3' }))); + service.run({ prompt: 'Go' }); + + const [task] = tasks.tasks(); + expect(task.status).toBe('error'); + expect(task.detail).toBe('boom'); + expect(task.route).toEqual(['/s', 'sess-3']); + }); + + it('marks oauth_required as an error needing account connection', () => { + mockRunApi.runNow.mockReturnValue(of(response({ status: 'oauth_required' }))); + service.run({ prompt: 'Go' }); + + const [task] = tasks.tasks(); + expect(task.status).toBe('error'); + expect(task.detail).toContain('connect an account'); + }); + + it('fails the task on a transport error', () => { + mockRunApi.runNow.mockReturnValue(throwError(() => new Error('network down'))); + service.run({ prompt: 'Go' }); + + const [task] = tasks.tasks(); + expect(task.status).toBe('error'); + expect(task.detail).toBe('network down'); + expect(task.route).toBeNull(); + expect(mockSessions.refreshSessions).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/ai.client/src/app/schedules/services/run-now.service.ts b/frontend/ai.client/src/app/schedules/services/run-now.service.ts new file mode 100644 index 00000000..6a3e209e --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/run-now.service.ts @@ -0,0 +1,83 @@ +import { Injectable, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { BackgroundTaskService } from '../../services/background-tasks/background-task.service'; +import { SessionService as SessionListService } from '../../session/services/session/session.service'; +import { RunNowRequest, RunNowResponse } from '../models/schedule.model'; +import { RunApiService } from './run-api.service'; + +/** + * Fires a headless "Run now" and tracks it as a persistent background task. + * + * Fire-and-forget from the caller's view: `run()` returns immediately and the + * corner toast reports progress. The `/runs/now` request is subscribed here in + * a root service (not the component) so it β€” and the toast resolution β€” survive + * the originating form being destroyed on navigation. When the run finishes, + * the task gets a "View" route to the session-detail conversation, where the + * result renders with full formatting. + */ +@Injectable({ providedIn: 'root' }) +export class RunNowService { + private readonly runApi = inject(RunApiService); + private readonly tasks = inject(BackgroundTaskService); + private readonly sessions = inject(SessionListService); + private readonly router = inject(Router); + + /** Start a headless run in the background. Returns the task id. */ + run(request: RunNowRequest): string { + const label = request.title?.trim() || 'your prompt'; + const taskId = this.tasks.start(`Running "${label}"`, 'The agent is working…'); + + this.runApi.runNow(request).subscribe({ + next: (result) => { + const route = result.sessionId ? ['/s', result.sessionId] : null; + // The run materializes as a real session server-side. Refresh the + // sidebar list so it appears like any other conversation (with its + // server-generated title + unread dot), not just behind the toast link. + if (result.sessionId) { + this.sessions.refreshSessions(); + } + if (result.status === 'completed') { + this.tasks.complete(taskId, { + detail: 'Run finished β€” added to your conversations', + route, + viewLabel: 'View result', + onView: () => this.openResult(result), + }); + } else if (result.status === 'oauth_required') { + this.tasks.fail(taskId, 'A tool needs you to connect an account first.', route); + } else { + this.tasks.fail(taskId, result.error ?? `Run ${result.status}.`, route); + } + }, + error: (err: unknown) => { + const message = err instanceof Error ? err.message : 'Run failed.'; + this.tasks.fail(taskId, message); + }, + }); + + return taskId; + } + + /** + * Open a finished run's conversation. Optimistically seeds `currentSession` + * with the id + known title *before* routing so the session-detail top-nav + * shows the right title immediately, instead of lingering on the previously + * viewed conversation's title until the metadata fetch resolves. The + * ConversationPage's metadata load then reconciles the full record. Mirrors + * `SessionListComponent.onSessionClick`. + */ + private openResult(result: RunNowResponse): void { + // Minimal optimistic record β€” the ConversationPage metadata fetch fills in + // the authoritative timestamps/counts moments later. + this.sessions.currentSession.set({ + sessionId: result.sessionId, + userId: '', + title: result.title ?? '', + status: 'active', + createdAt: '', + lastMessageAt: '', + messageCount: 0, + }); + this.router.navigate(['/s', result.sessionId]); + } +} diff --git a/frontend/ai.client/src/app/schedules/services/schedule-api.service.spec.ts b/frontend/ai.client/src/app/schedules/services/schedule-api.service.spec.ts new file mode 100644 index 00000000..5e3cb27a --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/schedule-api.service.spec.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { ScheduleApiService } from './schedule-api.service'; +import { ConfigService } from '../../services/config.service'; +import { ScheduledPrompt } from '../models/schedule.model'; + +const BASE = 'http://localhost:8000/schedules'; + +function stubSchedule(overrides: Partial = {}): ScheduledPrompt { + return { + scheduleId: 'sched-abc123def456', + assistantId: null, + label: 'Morning Briefing', + promptText: 'Summarize my classes', + cadence: 'daily', + hourLocal: 7, + weekday: null, + timezone: 'America/Boise', + state: 'active', + stateReason: null, + nextRunAt: '2026-07-06T14:00:00Z', + lastRunAt: null, + lastRunStatus: null, + lastRunSessionId: null, + lastError: null, + runsToday: 0, + maxRunsPerDay: 24, + enabledTools: null, + deliverEmail: false, + createdAt: '2026-07-05T00:00:00Z', + updatedAt: '2026-07-05T00:00:00Z', + ...overrides, + }; +} + +describe('ScheduleApiService', () => { + let service: ScheduleApiService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ScheduleApiService, + { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, + ], + }); + service = TestBed.inject(ScheduleApiService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.match(() => true); + TestBed.resetTestingModule(); + }); + + it('lists schedules', async () => { + const schedules = [stubSchedule()]; + const promise = firstValueFrom(service.list()); + + await vi.waitFor(() => { + const req = httpMock.expectOne(BASE); + expect(req.request.method).toBe('GET'); + req.flush({ schedules }); + }); + + expect(await promise).toEqual({ schedules }); + }); + + it('creates a schedule', async () => { + const created = stubSchedule(); + const promise = firstValueFrom(service.create({ + label: 'Morning Briefing', + promptText: 'Summarize my classes', + cadence: 'daily', + hourLocal: 7, + timezone: 'America/Boise', + })); + + await vi.waitFor(() => { + const req = httpMock.expectOne(BASE); + expect(req.request.method).toBe('POST'); + req.flush(created); + }); + + expect(await promise).toEqual(created); + }); + + it('updates a schedule via PATCH', async () => { + const updated = stubSchedule({ label: 'Updated' }); + const promise = firstValueFrom(service.update('sched-abc123def456', { label: 'Updated' })); + + await vi.waitFor(() => { + const req = httpMock.expectOne(`${BASE}/sched-abc123def456`); + expect(req.request.method).toBe('PATCH'); + req.flush(updated); + }); + + expect(await promise).toEqual(updated); + }); + + it('pauses a schedule', async () => { + const paused = stubSchedule({ state: 'paused' }); + const promise = firstValueFrom(service.pause('sched-abc123def456')); + + await vi.waitFor(() => { + const req = httpMock.expectOne(`${BASE}/sched-abc123def456/pause`); + expect(req.request.method).toBe('POST'); + req.flush(paused); + }); + + expect(await promise).toEqual(paused); + }); + + it('resumes a schedule', async () => { + const resumed = stubSchedule({ state: 'active' }); + const promise = firstValueFrom(service.resume('sched-abc123def456')); + + await vi.waitFor(() => { + const req = httpMock.expectOne(`${BASE}/sched-abc123def456/resume`); + expect(req.request.method).toBe('POST'); + req.flush(resumed); + }); + + expect(await promise).toEqual(resumed); + }); + + it('deletes a schedule', async () => { + const promise = firstValueFrom(service.delete('sched-abc123def456')); + + await vi.waitFor(() => { + const req = httpMock.expectOne(`${BASE}/sched-abc123def456`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + + await promise; + }); +}); diff --git a/frontend/ai.client/src/app/schedules/services/schedule-api.service.ts b/frontend/ai.client/src/app/schedules/services/schedule-api.service.ts new file mode 100644 index 00000000..de24f9f3 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/schedule-api.service.ts @@ -0,0 +1,51 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, computed, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ConfigService } from '../../services/config.service'; +import { + CreateScheduleRequest, + ScheduledPrompt, + ScheduledPromptsListResponse, + UpdateScheduleRequest, +} from '../models/schedule.model'; + +/** + * Thin HTTP layer over `/schedules/*` (app-api, cookie-authed). Mirrors + * `AssistantApiService` β€” no state, just the wire calls. + */ +@Injectable({ + providedIn: 'root', +}) +export class ScheduleApiService { + private http = inject(HttpClient); + private config = inject(ConfigService); + private readonly baseUrl = computed(() => `${this.config.appApiUrl()}/schedules`); + + list(): Observable { + return this.http.get(this.baseUrl()); + } + + get(scheduleId: string): Observable { + return this.http.get(`${this.baseUrl()}/${scheduleId}`); + } + + create(request: CreateScheduleRequest): Observable { + return this.http.post(this.baseUrl(), request); + } + + update(scheduleId: string, request: UpdateScheduleRequest): Observable { + return this.http.patch(`${this.baseUrl()}/${scheduleId}`, request); + } + + pause(scheduleId: string): Observable { + return this.http.post(`${this.baseUrl()}/${scheduleId}/pause`, {}); + } + + resume(scheduleId: string): Observable { + return this.http.post(`${this.baseUrl()}/${scheduleId}/resume`, {}); + } + + delete(scheduleId: string): Observable { + return this.http.delete(`${this.baseUrl()}/${scheduleId}`); + } +} diff --git a/frontend/ai.client/src/app/schedules/services/schedule.service.spec.ts b/frontend/ai.client/src/app/schedules/services/schedule.service.spec.ts new file mode 100644 index 00000000..3ae28e7a --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/schedule.service.spec.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { ScheduleService } from './schedule.service'; +import { ScheduleApiService } from './schedule-api.service'; +import { ScheduledPrompt } from '../models/schedule.model'; + +function stubSchedule(overrides: Partial = {}): ScheduledPrompt { + return { + scheduleId: 'sched-abc123def456', + assistantId: null, + label: 'Morning Briefing', + promptText: 'Summarize my classes', + cadence: 'daily', + hourLocal: 7, + weekday: null, + timezone: 'America/Boise', + state: 'active', + stateReason: null, + nextRunAt: '2026-07-06T14:00:00Z', + lastRunAt: null, + lastRunStatus: null, + lastRunSessionId: null, + lastError: null, + runsToday: 0, + maxRunsPerDay: 24, + enabledTools: null, + deliverEmail: false, + createdAt: '2026-07-05T00:00:00Z', + updatedAt: '2026-07-05T00:00:00Z', + ...overrides, + }; +} + +describe('ScheduleService', () => { + let service: ScheduleService; + let mockApi: { + list: ReturnType; + get: ReturnType; + create: ReturnType; + update: ReturnType; + pause: ReturnType; + resume: ReturnType; + delete: ReturnType; + }; + + beforeEach(() => { + TestBed.resetTestingModule(); + mockApi = { + list: vi.fn(), + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + delete: vi.fn(), + }; + TestBed.configureTestingModule({ + providers: [ScheduleService, { provide: ScheduleApiService, useValue: mockApi }], + }); + service = TestBed.inject(ScheduleService); + }); + + it('loads schedules and marks the feature accessible', async () => { + const schedule = stubSchedule(); + mockApi.list.mockReturnValue(of({ schedules: [schedule] })); + + await service.loadSchedules(); + + expect(service.schedules$()).toEqual([schedule]); + expect(service.accessible$()).toBe(true); + expect(service.loading$()).toBe(false); + }); + + it('marks the feature inaccessible (not an error) on a 403', async () => { + mockApi.list.mockReturnValue(throwError(() => ({ status: 403 }))); + + await service.loadSchedules(); + + expect(service.accessible$()).toBe(false); + expect(service.schedules$()).toEqual([]); + expect(service.error$()).toBeNull(); + }); + + it('marks the feature inaccessible on a 404 (kill switch off)', async () => { + mockApi.list.mockReturnValue(throwError(() => ({ status: 404 }))); + + await service.loadSchedules(); + + expect(service.accessible$()).toBe(false); + }); + + it('surfaces a real error for non-gating failures', async () => { + mockApi.list.mockReturnValue(throwError(() => new Error('network blip'))); + + await expect(service.loadSchedules()).rejects.toThrow('network blip'); + expect(service.error$()).toBe('network blip'); + }); + + it('creates a schedule and appends it locally', async () => { + const created = stubSchedule(); + mockApi.create.mockReturnValue(of(created)); + + const result = await service.createSchedule({ + label: created.label, + promptText: created.promptText, + cadence: created.cadence, + hourLocal: created.hourLocal, + timezone: created.timezone, + }); + + expect(result).toEqual(created); + expect(service.schedules$()).toEqual([created]); + }); + + it('replaces the schedule in the list on update', async () => { + const original = stubSchedule(); + mockApi.list.mockReturnValue(of({ schedules: [original] })); + await service.loadSchedules(); + + const updated = { ...original, label: 'Renamed' }; + mockApi.update.mockReturnValue(of(updated)); + + const result = await service.updateSchedule(original.scheduleId, { label: 'Renamed' }); + + expect(result.label).toBe('Renamed'); + expect(service.schedules$()).toEqual([updated]); + }); + + it('removes the schedule from the list on delete', async () => { + const schedule = stubSchedule(); + mockApi.list.mockReturnValue(of({ schedules: [schedule] })); + await service.loadSchedules(); + + mockApi.delete.mockReturnValue(of(undefined)); + await service.deleteSchedule(schedule.scheduleId); + + expect(service.schedules$()).toEqual([]); + }); + + it('pause/resume update the schedule state locally', async () => { + const schedule = stubSchedule(); + mockApi.list.mockReturnValue(of({ schedules: [schedule] })); + await service.loadSchedules(); + + mockApi.pause.mockReturnValue(of({ ...schedule, state: 'paused' })); + await service.pauseSchedule(schedule.scheduleId); + expect(service.schedules$()[0].state).toBe('paused'); + + mockApi.resume.mockReturnValue(of({ ...schedule, state: 'active' })); + await service.resumeSchedule(schedule.scheduleId); + expect(service.schedules$()[0].state).toBe('active'); + }); +}); diff --git a/frontend/ai.client/src/app/schedules/services/schedule.service.ts b/frontend/ai.client/src/app/schedules/services/schedule.service.ts new file mode 100644 index 00000000..bb3bc3f4 --- /dev/null +++ b/frontend/ai.client/src/app/schedules/services/schedule.service.ts @@ -0,0 +1,126 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { + CreateScheduleRequest, + ScheduledPrompt, + UpdateScheduleRequest, +} from '../models/schedule.model'; +import { ScheduleApiService } from './schedule-api.service'; + +/** + * Signal-based state for the schedules feature. Mirrors AssistantService's + * shape: a private mutable signal, a readonly public view, and async + * methods that round-trip through the API service and keep local state in + * sync so the list page never needs a manual reload after a mutation. + */ +@Injectable({ + providedIn: 'root', +}) +export class ScheduleService { + private apiService = inject(ScheduleApiService); + + private schedules = signal([]); + private loading = signal(false); + private error = signal(null); + /** + * Set once the schedules list call has resolved (success OR a + * capability-shaped failure). Lets the page distinguish "still loading" + * from "loaded, and the feature isn't available to this user" without a + * separate capability signal (see gating note in schedules.page.ts). + */ + private accessible = signal(null); + + readonly schedules$ = this.schedules.asReadonly(); + readonly loading$ = this.loading.asReadonly(); + readonly error$ = this.error.asReadonly(); + readonly accessible$ = this.accessible.asReadonly(); + + async loadSchedules(): Promise { + this.loading.set(true); + this.error.set(null); + + try { + const response = await firstValueFrom(this.apiService.list()); + this.schedules.set(response?.schedules ?? []); + this.accessible.set(true); + } catch (err: unknown) { + const status = (err as { status?: number } | null)?.status; + if (status === 403 || status === 404) { + // Kill switch off or caller lacks the scheduled-runs capability β€” + // fail gracefully rather than surfacing an error state. + this.accessible.set(false); + this.schedules.set([]); + return; + } + const errorMessage = err instanceof Error ? err.message : 'Failed to load schedules'; + this.error.set(errorMessage); + throw err; + } finally { + this.loading.set(false); + } + } + + async createSchedule(request: CreateScheduleRequest): Promise { + this.error.set(null); + try { + const schedule = await firstValueFrom(this.apiService.create(request)); + this.schedules.update((current) => [...current, schedule]); + return schedule; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create schedule'; + this.error.set(errorMessage); + throw err; + } + } + + async updateSchedule(scheduleId: string, request: UpdateScheduleRequest): Promise { + this.error.set(null); + try { + const updated = await firstValueFrom(this.apiService.update(scheduleId, request)); + this.replaceInList(updated); + return updated; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to update schedule'; + this.error.set(errorMessage); + throw err; + } + } + + async pauseSchedule(scheduleId: string): Promise { + const updated = await firstValueFrom(this.apiService.pause(scheduleId)); + this.replaceInList(updated); + return updated; + } + + async resumeSchedule(scheduleId: string): Promise { + const updated = await firstValueFrom(this.apiService.resume(scheduleId)); + this.replaceInList(updated); + return updated; + } + + async deleteSchedule(scheduleId: string): Promise { + await firstValueFrom(this.apiService.delete(scheduleId)); + this.schedules.update((current) => current.filter((s) => s.scheduleId !== scheduleId)); + } + + async getSchedule(scheduleId: string): Promise { + const cached = this.schedules().find((s) => s.scheduleId === scheduleId); + if (cached) { + return cached; + } + const schedule = await firstValueFrom(this.apiService.get(scheduleId)); + return schedule; + } + + private replaceInList(schedule: ScheduledPrompt): void { + this.schedules.update((current) => { + const idx = current.findIndex((s) => s.scheduleId === schedule.scheduleId); + if (idx === -1) { + return [...current, schedule]; + } + const next = [...current]; + next[idx] = schedule; + return next; + }); + } +} diff --git a/frontend/ai.client/src/app/services/background-tasks/background-task.service.spec.ts b/frontend/ai.client/src/app/services/background-tasks/background-task.service.spec.ts new file mode 100644 index 00000000..36fbc57e --- /dev/null +++ b/frontend/ai.client/src/app/services/background-tasks/background-task.service.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { BackgroundTaskService } from './background-task.service'; + +describe('BackgroundTaskService', () => { + let service: BackgroundTaskService; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [BackgroundTaskService] }); + service = TestBed.inject(BackgroundTaskService); + }); + + it('starts a processing task and returns its id', () => { + const id = service.start('Running "X"', 'Working…'); + const [task] = service.tasks(); + expect(task.id).toBe(id); + expect(task.status).toBe('processing'); + expect(task.title).toBe('Running "X"'); + expect(task.detail).toBe('Working…'); + expect(task.route).toBeNull(); + }); + + it('completes a task with a view route and detail', () => { + const id = service.start('Run'); + service.complete(id, { detail: 'Done', route: ['/s', 'sess-1'], viewLabel: 'View result' }); + const [task] = service.tasks(); + expect(task.status).toBe('completed'); + expect(task.detail).toBe('Done'); + expect(task.route).toEqual(['/s', 'sess-1']); + expect(task.viewLabel).toBe('View result'); + }); + + it('fails a task, optionally keeping a route to a partial session', () => { + const id = service.start('Run'); + service.fail(id, 'It broke', ['/s', 'sess-2']); + const [task] = service.tasks(); + expect(task.status).toBe('error'); + expect(task.detail).toBe('It broke'); + expect(task.route).toEqual(['/s', 'sess-2']); + }); + + it('dismisses a task by id and leaves others intact', () => { + const a = service.start('A'); + const b = service.start('B'); + service.dismiss(a); + expect(service.tasks().map((t) => t.id)).toEqual([b]); + }); + + it('only patches the targeted task', () => { + const a = service.start('A'); + const b = service.start('B'); + service.complete(a, { detail: 'done' }); + const byId = Object.fromEntries(service.tasks().map((t) => [t.id, t.status])); + expect(byId[a]).toBe('completed'); + expect(byId[b]).toBe('processing'); + }); +}); diff --git a/frontend/ai.client/src/app/services/background-tasks/background-task.service.ts b/frontend/ai.client/src/app/services/background-tasks/background-task.service.ts new file mode 100644 index 00000000..19c25c16 --- /dev/null +++ b/frontend/ai.client/src/app/services/background-tasks/background-task.service.ts @@ -0,0 +1,88 @@ +import { Injectable, signal } from '@angular/core'; + +/** Lifecycle of a tracked background task. */ +export type BackgroundTaskStatus = 'processing' | 'completed' | 'error'; + +/** + * A long-running, out-of-band process surfaced to the user as a persistent + * corner toast (e.g. a headless "Run now", an export, an ingestion job). The + * toast lives at the app-shell level so it survives navigation between views. + */ +export interface BackgroundTask { + id: string; + /** Short headline, e.g. `Running "Morning Briefing"`. */ + title: string; + /** Optional one-line status detail (e.g. "The agent is working…"). */ + detail?: string | null; + status: BackgroundTaskStatus; + /** + * Router commands for a "View" action. When set, the toast shows a button + * that navigates here β€” e.g. `['/s', sessionId]` to open a headless run's + * conversation in the session-detail view. Reusable for any result target. + */ + route?: string[] | null; + /** Label for the view button; defaults to "View". */ + viewLabel?: string; + /** + * Optional custom handler for the "View" button. When set, the toast runs + * this instead of navigating to `route` β€” lets a consumer do side work + * first (e.g. optimistically seed the session header title before routing, + * mirroring a sidebar click). `route` is still used to *show* the button. + */ + onView?: () => void; + createdAt: number; +} + +/** + * Tracks background tasks and exposes them as a signal for a shell-level toast + * stack. Deliberately generic β€” "Run now" is the first consumer, but exports, + * ingestion jobs, and other headless work can register here too. + * + * A root service (not a component) owns task lifecycle so the tracking β€” and + * any subscription that resolves it β€” survives the originating component being + * destroyed on navigation. + */ +@Injectable({ providedIn: 'root' }) +export class BackgroundTaskService { + private tasksSignal = signal([]); + + /** Readonly signal of the current tasks, oldest first. */ + readonly tasks = this.tasksSignal.asReadonly(); + + /** Register a new in-progress task; returns its id for later resolution. */ + start(title: string, detail?: string): string { + const id = this.generateId(); + this.tasksSignal.update((tasks) => [ + ...tasks, + { id, title, detail: detail ?? null, status: 'processing', route: null, createdAt: Date.now() }, + ]); + return id; + } + + /** Mark a task finished, optionally attaching a "View" route + detail. */ + complete( + id: string, + patch: { detail?: string; route?: string[] | null; viewLabel?: string; onView?: () => void } = {}, + ): void { + this.patch(id, { status: 'completed', ...patch }); + } + + /** Mark a task failed. A route may still be attached (a failed run can + * materialize a partial session worth viewing). */ + fail(id: string, detail: string, route?: string[] | null): void { + this.patch(id, { status: 'error', detail, route: route ?? null }); + } + + /** Remove a task from the stack. */ + dismiss(id: string): void { + this.tasksSignal.update((tasks) => tasks.filter((t) => t.id !== id)); + } + + private patch(id: string, changes: Partial): void { + this.tasksSignal.update((tasks) => tasks.map((t) => (t.id === id ? { ...t, ...changes } : t))); + } + + private generateId(): string { + return `bgtask-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + } +} diff --git a/frontend/ai.client/src/app/session/components/assistant-indicator/assistant-indicator.component.ts b/frontend/ai.client/src/app/session/components/assistant-indicator/assistant-indicator.component.ts index a95348ec..7e7a5203 100644 --- a/frontend/ai.client/src/app/session/components/assistant-indicator/assistant-indicator.component.ts +++ b/frontend/ai.client/src/app/session/components/assistant-indicator/assistant-indicator.component.ts @@ -38,43 +38,66 @@ import { }, template: `
-
- - -
- {{ name() }} - @if (ownerName()) { - by {{ ownerName() }} - } -
- - -