manigent-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- manigent/_catalog/mcps/chess/README.md +38 -0
- manigent/_catalog/mcps/chess/mcp.json +9 -0
- manigent/_catalog/mcps/registry.json +9 -0
- manigent/_catalog/skills/brief/SKILL.md +339 -0
- manigent/_catalog/skills/broaden-options/SKILL.md +133 -0
- manigent/_catalog/skills/calendar/SKILL.md +44 -0
- manigent/_catalog/skills/calendar/starter_config.jsonc +25 -0
- manigent/_catalog/skills/gmail/SKILL.md +73 -0
- manigent/_catalog/skills/google-calendar/SKILL.md +44 -0
- manigent/_catalog/skills/google-drive/SKILL.md +42 -0
- manigent/_catalog/skills/google-drive-write/SKILL.md +45 -0
- manigent/_catalog/skills/google-drive-write/starter_config.jsonc +45 -0
- manigent/_catalog/skills/grill-me/SKILL.md +173 -0
- manigent/_catalog/skills/homekit/SKILL.md +37 -0
- manigent/_catalog/skills/http-api/SKILL.md +117 -0
- manigent/_catalog/skills/ib/SKILL.md +89 -0
- manigent/_catalog/skills/ib/starter_config.jsonc +35 -0
- manigent/_catalog/skills/mailbox/SKILL.md +47 -0
- manigent/_catalog/skills/mailbox/starter_config.jsonc +38 -0
- manigent/_catalog/skills/media/SKILL.md +93 -0
- manigent/_catalog/skills/media/starter_config.jsonc +63 -0
- manigent/_catalog/skills/osxphotos/SKILL.md +41 -0
- manigent/_catalog/skills/photo-story/SKILL.md +285 -0
- manigent/_catalog/skills/playwright-browser/SKILL.md +291 -0
- manigent/_catalog/skills/playwright-browser/starter_config.jsonc +44 -0
- manigent/_catalog/skills/playwright-save-skill/SKILL.md +149 -0
- manigent/_catalog/skills/registry.json +166 -0
- manigent/_catalog/skills/web-artifacts/SKILL.md +194 -0
- manigent/api/action_executor.py +404 -0
- manigent/api/action_validator.py +531 -0
- manigent/api/actions.py +221 -0
- manigent/api/client.py +558 -0
- manigent/api/control.py +806 -0
- manigent/api/edge_protocol.py +123 -0
- manigent/api/fs_protocol.py +450 -0
- manigent/api/host_protocol.py +298 -0
- manigent/api/managed_job_client.py +124 -0
- manigent/api/responses.py +342 -0
- manigent/api/retry_transport.py +178 -0
- manigent/api/turn_protocol.py +78 -0
- manigent/cli_lifecycle.py +467 -0
- manigent/cli_server.py +978 -0
- manigent/doctor.py +1167 -0
- manigent/hub_protocol/canonical.py +375 -0
- manigent/hub_protocol/codes.py +171 -0
- manigent/hub_protocol/constants.py +71 -0
- manigent/hub_protocol/envelope.py +218 -0
- manigent/hub_protocol/frames.py +788 -0
- manigent/hub_protocol/http.py +260 -0
- manigent/hub_protocol/ids.py +170 -0
- manigent/hub_protocol/keys.py +195 -0
- manigent/hub_protocol/text.py +75 -0
- manigent/llm/base.py +1072 -0
- manigent/llm/prompt_builder.py +2450 -0
- manigent/llm/triggers.py +68 -0
- manigent/models/agent.py +2905 -0
- manigent/models/agent_bundle.py +298 -0
- manigent/models/brief_tab.py +311 -0
- manigent/models/chat_message.py +231 -0
- manigent/models/chatroom.py +1108 -0
- manigent/models/context.py +1159 -0
- manigent/models/desk_label.py +836 -0
- manigent/models/desk_read_state.py +220 -0
- manigent/models/desk_sign_off.py +267 -0
- manigent/models/desk_sop.py +965 -0
- manigent/models/desk_todo.py +1603 -0
- manigent/models/desk_todo_link.py +404 -0
- manigent/models/file_cache.py +75 -0
- manigent/models/host.py +732 -0
- manigent/models/install_token.py +401 -0
- manigent/models/invitation_code.py +162 -0
- manigent/models/knowledge_pack.py +552 -0
- manigent/models/managed_job.py +295 -0
- manigent/models/model_config.py +564 -0
- manigent/models/model_defaults.py +238 -0
- manigent/models/onboarding_plan.py +161 -0
- manigent/models/participant.py +449 -0
- manigent/models/persistable.py +892 -0
- manigent/models/plan_markdown.py +1774 -0
- manigent/models/project.py +1343 -0
- manigent/models/project_plan.py +8104 -0
- manigent/models/project_report.py +145 -0
- manigent/models/provenance.py +261 -0
- manigent/models/provider_agents.py +101 -0
- manigent/models/public_request.py +108 -0
- manigent/models/reflection_schedule.py +191 -0
- manigent/models/scheduled_message.py +285 -0
- manigent/models/share_token.py +190 -0
- manigent/models/skill_version.py +31 -0
- manigent/models/sop_template.py +187 -0
- manigent/models/thinking_partner_plan.py +187 -0
- manigent/models/tunnel_binding.py +129 -0
- manigent/models/user.py +1276 -0
- manigent/models/work_tracker.py +510 -0
- manigent/runner/invocation_registry.py +187 -0
- manigent/server/agent_capabilities.py +119 -0
- manigent/server/agent_visibility.py +119 -0
- manigent/server/announcements.py +119 -0
- manigent/server/app.py +807 -0
- manigent/server/auth.py +355 -0
- manigent/server/batch_timeout.py +94 -0
- manigent/server/channels/access.py +194 -0
- manigent/server/channels/adapter.py +295 -0
- manigent/server/channels/config.py +693 -0
- manigent/server/channels/contacts.py +39 -0
- manigent/server/channels/email_adapter.py +436 -0
- manigent/server/channels/email_address.py +145 -0
- manigent/server/channels/email_form.py +242 -0
- manigent/server/channels/email_format.py +285 -0
- manigent/server/channels/email_mail.py +427 -0
- manigent/server/channels/email_mailgun.py +164 -0
- manigent/server/channels/email_text.py +280 -0
- manigent/server/channels/file_links.py +127 -0
- manigent/server/channels/gateway.py +2998 -0
- manigent/server/channels/hub_delivery.py +211 -0
- manigent/server/channels/hub_store.py +1730 -0
- manigent/server/channels/line.py +645 -0
- manigent/server/channels/line_format.py +589 -0
- manigent/server/channels/log_redaction.py +145 -0
- manigent/server/channels/store.py +3316 -0
- manigent/server/channels/telegram.py +422 -0
- manigent/server/channels/telegram_format.py +312 -0
- manigent/server/channels/webchat.py +261 -0
- manigent/server/channels/webchat_widget.js +1310 -0
- manigent/server/channels/whatsapp.py +707 -0
- manigent/server/channels/whatsapp_format.py +511 -0
- manigent/server/chat_search_index.py +1203 -0
- manigent/server/config.py +61 -0
- manigent/server/context.py +121 -0
- manigent/server/coordinator_rule.py +115 -0
- manigent/server/desk_todo_notify.py +300 -0
- manigent/server/edge_hub.py +577 -0
- manigent/server/email.py +354 -0
- manigent/server/errors.py +83 -0
- manigent/server/home_fs_relay.py +392 -0
- manigent/server/hub/adapter.py +1395 -0
- manigent/server/hub/agent_send_guard.py +196 -0
- manigent/server/hub/avatar.py +342 -0
- manigent/server/hub/browser.py +778 -0
- manigent/server/hub/client.py +859 -0
- manigent/server/hub/demand.py +49 -0
- manigent/server/hub/directory.py +385 -0
- manigent/server/hub/enroll.py +400 -0
- manigent/server/hub/http.py +134 -0
- manigent/server/hub/identity.py +61 -0
- manigent/server/hub/keys.py +588 -0
- manigent/server/hub/mirror.py +802 -0
- manigent/server/hub/operator.py +817 -0
- manigent/server/hub/retain.py +361 -0
- manigent/server/hub/room_bridge.py +741 -0
- manigent/server/hub/runtime.py +691 -0
- manigent/server/hub/trust.py +182 -0
- manigent/server/hub/views.py +316 -0
- manigent/server/idempotency.py +205 -0
- manigent/server/in_flight_restore.py +181 -0
- manigent/server/inspection.py +816 -0
- manigent/server/isolation_gate.py +516 -0
- manigent/server/isolation_notice.py +310 -0
- manigent/server/managed_job_events.py +242 -0
- manigent/server/mcp_registry.py +118 -0
- manigent/server/message_forward.py +78 -0
- manigent/server/message_scheduler.py +176 -0
- manigent/server/notification_assets.py +164 -0
- manigent/server/oauth_login/cli_state.py +120 -0
- manigent/server/oauth_login/config.py +90 -0
- manigent/server/oauth_login/errors.py +78 -0
- manigent/server/oauth_login/flow.py +223 -0
- manigent/server/oauth_login/handoff.py +63 -0
- manigent/server/oauth_login/providers.py +45 -0
- manigent/server/oauth_state.py +81 -0
- manigent/server/owner_isolation.py +466 -0
- manigent/server/participant_liveness.py +82 -0
- manigent/server/public_profile.py +149 -0
- manigent/server/public_requests/candidates.py +116 -0
- manigent/server/public_requests/dispatch.py +121 -0
- manigent/server/public_requests/ranking.py +153 -0
- manigent/server/recent_activity.py +250 -0
- manigent/server/reflection_completion_subscriber.py +151 -0
- manigent/server/reflection_scheduler.py +239 -0
- manigent/server/requester_key_migration.py +261 -0
- manigent/server/room_flow.py +139 -0
- manigent/server/routes/_batch.py +218 -0
- manigent/server/routes/_fd_routing.py +155 -0
- manigent/server/routes/_mentions.py +70 -0
- manigent/server/routes/agent_env.py +188 -0
- manigent/server/routes/agent_fs.py +450 -0
- manigent/server/routes/agents.py +1206 -0
- manigent/server/routes/announcements.py +79 -0
- manigent/server/routes/auth.py +387 -0
- manigent/server/routes/brief_tabs.py +404 -0
- manigent/server/routes/changelog.py +237 -0
- manigent/server/routes/channels.py +993 -0
- manigent/server/routes/chatrooms.py +871 -0
- manigent/server/routes/desk_labels.py +298 -0
- manigent/server/routes/desk_latest_messages.py +116 -0
- manigent/server/routes/desk_read_state.py +110 -0
- manigent/server/routes/desk_sign_off.py +88 -0
- manigent/server/routes/desk_sops.py +216 -0
- manigent/server/routes/desk_todos.py +692 -0
- manigent/server/routes/desk_tour.py +44 -0
- manigent/server/routes/files.py +572 -0
- manigent/server/routes/hosts.py +484 -0
- manigent/server/routes/hub.py +534 -0
- manigent/server/routes/hub_channels.py +234 -0
- manigent/server/routes/inbox.py +581 -0
- manigent/server/routes/install.ps1 +436 -0
- manigent/server/routes/install.py +324 -0
- manigent/server/routes/install.sh +566 -0
- manigent/server/routes/isolation_check.py +145 -0
- manigent/server/routes/knowledge_packs.py +477 -0
- manigent/server/routes/managed_jobs.py +196 -0
- manigent/server/routes/mcps.py +271 -0
- manigent/server/routes/messages.py +1972 -0
- manigent/server/routes/model_defaults.py +279 -0
- manigent/server/routes/oauth.py +237 -0
- manigent/server/routes/oauth_login.py +351 -0
- manigent/server/routes/owner_isolation.py +91 -0
- manigent/server/routes/project_plans.py +1521 -0
- manigent/server/routes/project_reports.py +235 -0
- manigent/server/routes/projects.py +2047 -0
- manigent/server/routes/public_agents.py +151 -0
- manigent/server/routes/public_requests.py +181 -0
- manigent/server/routes/reflection.py +93 -0
- manigent/server/routes/scheduled_messages.py +263 -0
- manigent/server/routes/search.py +94 -0
- manigent/server/routes/sharing.py +156 -0
- manigent/server/routes/skills.py +406 -0
- manigent/server/routes/templates.py +219 -0
- manigent/server/routes/turn_tokens.py +119 -0
- manigent/server/routes/webchat.py +406 -0
- manigent/server/routes/websocket.py +431 -0
- manigent/server/serving_only.py +200 -0
- manigent/server/signup_policy.py +120 -0
- manigent/server/skill_registry.py +164 -0
- manigent/server/socket_events.py +858 -0
- manigent/server/timeout_checker.py +446 -0
- manigent/server/tunnel_subscriber.py +1000 -0
- manigent/server/turn_tokens.py +285 -0
- manigent/server/ws_hub.py +556 -0
- manigent/server_cli.py +31 -0
- manigent/sync/changelog.py +885 -0
- manigent/sync/console_subscriber.py +222 -0
- manigent/sync/runloop.py +619 -0
- manigent/sync/runloop_manager.py +197 -0
- manigent/sync/subscriber.py +98 -0
- manigent/utils/agent_processes.py +276 -0
- manigent/utils/agent_storage.py +125 -0
- manigent/utils/email.py +563 -0
- manigent/utils/env_store.py +174 -0
- manigent/utils/extra_dirs.py +85 -0
- manigent/utils/file_io.py +485 -0
- manigent/utils/invoke_timeout.py +87 -0
- manigent/utils/jsonc.py +78 -0
- manigent/utils/knowledge_index.py +148 -0
- manigent/utils/nofollow_fs.py +279 -0
- manigent/utils/notification_center.py +67 -0
- manigent/utils/version.py +57 -0
- manigent/web/frontend/.design-sync-entry.tsx +74 -0
- manigent/web/frontend/.design-sync-provider.tsx +46 -0
- manigent/web/frontend/.eslintrc.cjs +183 -0
- manigent/web/frontend/.gitignore +6 -0
- manigent/web/frontend/app/page.tsx +39 -0
- manigent/web/frontend/hub-terms.html +192 -0
- manigent/web/frontend/index.html +15 -0
- manigent/web/frontend/landing.html +15 -0
- manigent/web/frontend/package-lock.json +8028 -0
- manigent/web/frontend/package.json +55 -0
- manigent/web/frontend/postcss.config.js +6 -0
- manigent/web/frontend/privacy.html +217 -0
- manigent/web/frontend/private-beta-announcement.html +19 -0
- manigent/web/frontend/public/apple-touch-icon.png +0 -0
- manigent/web/frontend/public/brand-hero-banner.png +0 -0
- manigent/web/frontend/public/brand-hero.png +0 -0
- manigent/web/frontend/public/favicon.png +0 -0
- manigent/web/frontend/public/favicon.svg +8 -0
- manigent/web/frontend/public/icon-transparent-sm.png +0 -0
- manigent/web/frontend/public/icon.png +0 -0
- manigent/web/frontend/public/manigent-mark.svg +7 -0
- manigent/web/frontend/public/vite.svg +5 -0
- manigent/web/frontend/public.html +15 -0
- manigent/web/frontend/src/App.activeAccountRedirect.test.tsx +236 -0
- manigent/web/frontend/src/App.hubHostDm.test.tsx +119 -0
- manigent/web/frontend/src/App.hubHostProject.test.tsx +142 -0
- manigent/web/frontend/src/App.hubRoutes.test.tsx +187 -0
- manigent/web/frontend/src/App.oauthAddCallback.test.tsx +263 -0
- manigent/web/frontend/src/App.tsx +246 -0
- manigent/web/frontend/src/api/attachments.ts +32 -0
- manigent/web/frontend/src/api/batchLoader.test.ts +212 -0
- manigent/web/frontend/src/api/batchLoader.ts +124 -0
- manigent/web/frontend/src/api/chatroomsBatch.test.tsx +392 -0
- manigent/web/frontend/src/api/client.ts +321 -0
- manigent/web/frontend/src/api/crossAccountAuth.test.ts +388 -0
- manigent/web/frontend/src/api/endpoints/agentEnv.ts +24 -0
- manigent/web/frontend/src/api/endpoints/agentFs.ts +125 -0
- manigent/web/frontend/src/api/endpoints/agentSearch.ts +19 -0
- manigent/web/frontend/src/api/endpoints/agents.ts +66 -0
- manigent/web/frontend/src/api/endpoints/announcements.ts +26 -0
- manigent/web/frontend/src/api/endpoints/auth.ts +361 -0
- manigent/web/frontend/src/api/endpoints/authLogin.test.ts +236 -0
- manigent/web/frontend/src/api/endpoints/batch.ts +64 -0
- manigent/web/frontend/src/api/endpoints/briefTabs.ts +81 -0
- manigent/web/frontend/src/api/endpoints/channels.ts +285 -0
- manigent/web/frontend/src/api/endpoints/computers.ts +212 -0
- manigent/web/frontend/src/api/endpoints/deskLabels.ts +151 -0
- manigent/web/frontend/src/api/endpoints/deskLatestMessages.ts +23 -0
- manigent/web/frontend/src/api/endpoints/deskReadState.ts +41 -0
- manigent/web/frontend/src/api/endpoints/deskSignOffs.ts +74 -0
- manigent/web/frontend/src/api/endpoints/deskSops.ts +53 -0
- manigent/web/frontend/src/api/endpoints/deskTodoAttachments.ts +69 -0
- manigent/web/frontend/src/api/endpoints/deskTodoProjects.test.ts +99 -0
- manigent/web/frontend/src/api/endpoints/deskTodoProjects.ts +114 -0
- manigent/web/frontend/src/api/endpoints/deskTodos.ts +164 -0
- manigent/web/frontend/src/api/endpoints/deskTour.ts +16 -0
- manigent/web/frontend/src/api/endpoints/dm.ts +137 -0
- manigent/web/frontend/src/api/endpoints/files.ts +55 -0
- manigent/web/frontend/src/api/endpoints/hub.contract.test.ts +94 -0
- manigent/web/frontend/src/api/endpoints/hub.keys.ts +260 -0
- manigent/web/frontend/src/api/endpoints/hub.test.ts +203 -0
- manigent/web/frontend/src/api/endpoints/hub.ts +654 -0
- manigent/web/frontend/src/api/endpoints/index.ts +15 -0
- manigent/web/frontend/src/api/endpoints/install.ts +74 -0
- manigent/web/frontend/src/api/endpoints/isolationCheck.ts +36 -0
- manigent/web/frontend/src/api/endpoints/knowledgePacks.ts +179 -0
- manigent/web/frontend/src/api/endpoints/mcps.ts +36 -0
- manigent/web/frontend/src/api/endpoints/messages.held.test.ts +43 -0
- manigent/web/frontend/src/api/endpoints/messages.ts +95 -0
- manigent/web/frontend/src/api/endpoints/modelConfigs.ts +232 -0
- manigent/web/frontend/src/api/endpoints/oauthApi.test.ts +110 -0
- manigent/web/frontend/src/api/endpoints/projectPlans.ts +608 -0
- manigent/web/frontend/src/api/endpoints/projectReports.ts +36 -0
- manigent/web/frontend/src/api/endpoints/projects.ts +124 -0
- manigent/web/frontend/src/api/endpoints/publicRequests.ts +75 -0
- manigent/web/frontend/src/api/endpoints/reflection.ts +32 -0
- manigent/web/frontend/src/api/endpoints/roomFlow.ts +14 -0
- manigent/web/frontend/src/api/endpoints/scheduledMessages.ts +60 -0
- manigent/web/frontend/src/api/endpoints/search.ts +27 -0
- manigent/web/frontend/src/api/endpoints/sharing.ts +42 -0
- manigent/web/frontend/src/api/endpoints/skills.ts +67 -0
- manigent/web/frontend/src/api/endpoints/templates.ts +72 -0
- manigent/web/frontend/src/api/loaders.ts +22 -0
- manigent/web/frontend/src/api/singleIssuer.test.ts +148 -0
- manigent/web/frontend/src/api/transientRetry.test.ts +176 -0
- manigent/web/frontend/src/api/transientRetry.ts +140 -0
- manigent/web/frontend/src/api/websocket.resync.test.ts +111 -0
- manigent/web/frontend/src/api/websocket.ts +335 -0
- manigent/web/frontend/src/components/agent/AgentNameLink.tsx +24 -0
- manigent/web/frontend/src/components/agent/AgentViewActions.test.tsx +41 -0
- manigent/web/frontend/src/components/agent/AgentViewActions.tsx +89 -0
- manigent/web/frontend/src/components/agent/CreateAgentPage.tsx +535 -0
- manigent/web/frontend/src/components/agent/JsonConfigEditor.tsx +33 -0
- manigent/web/frontend/src/components/agent/KnowledgePackList.tsx +103 -0
- manigent/web/frontend/src/components/agent/McpCatalog.tsx +261 -0
- manigent/web/frontend/src/components/agent/McpConfigModal.tsx +224 -0
- manigent/web/frontend/src/components/agent/PreConsentModal.tsx +172 -0
- manigent/web/frontend/src/components/agent/SkillCatalog.tsx +364 -0
- manigent/web/frontend/src/components/agent/SkillConfigModal.tsx +221 -0
- manigent/web/frontend/src/components/auth/AddAccountPage.test.tsx +181 -0
- manigent/web/frontend/src/components/auth/AddAccountPage.tsx +201 -0
- manigent/web/frontend/src/components/auth/AuthShell.tsx +462 -0
- manigent/web/frontend/src/components/auth/LoginPage.test.tsx +384 -0
- manigent/web/frontend/src/components/auth/LoginPage.tsx +129 -0
- manigent/web/frontend/src/components/auth/OAuthCallbackPage.test.tsx +185 -0
- manigent/web/frontend/src/components/auth/OAuthCallbackPage.tsx +154 -0
- manigent/web/frontend/src/components/auth/OAuthLinkedBanner.tsx +44 -0
- manigent/web/frontend/src/components/auth/ProviderButtons.tsx +94 -0
- manigent/web/frontend/src/components/auth/SignupPage.tsx +437 -0
- manigent/web/frontend/src/components/auth/StepProgress.tsx +60 -0
- manigent/web/frontend/src/components/auth/VerifyEmailPage.test.tsx +155 -0
- manigent/web/frontend/src/components/auth/VerifyEmailPage.tsx +211 -0
- manigent/web/frontend/src/components/auth/index.ts +3 -0
- manigent/web/frontend/src/components/auth/oauthErrors.test.ts +107 -0
- manigent/web/frontend/src/components/auth/oauthErrors.ts +82 -0
- manigent/web/frontend/src/components/auth/useUsernameCheck.test.tsx +68 -0
- manigent/web/frontend/src/components/auth/useUsernameCheck.ts +86 -0
- manigent/web/frontend/src/components/beta/BetaPage.tsx +21 -0
- manigent/web/frontend/src/components/beta/DayAtMyDesk.tsx +120 -0
- manigent/web/frontend/src/components/beta/Footer.tsx +82 -0
- manigent/web/frontend/src/components/beta/Hero.tsx +224 -0
- manigent/web/frontend/src/components/beta/Nav.tsx +51 -0
- manigent/web/frontend/src/components/beta/Pillars.tsx +172 -0
- manigent/web/frontend/src/components/beta/TwoModels.tsx +146 -0
- manigent/web/frontend/src/components/beta/constants.ts +3 -0
- manigent/web/frontend/src/components/briefing/BriefingFrame.test.tsx +208 -0
- manigent/web/frontend/src/components/briefing/BriefingFrame.tsx +151 -0
- manigent/web/frontend/src/components/briefing/BriefingPage.test.tsx +176 -0
- manigent/web/frontend/src/components/briefing/BriefingPage.tsx +157 -0
- manigent/web/frontend/src/components/briefing/BriefingPage.wsDelete.test.tsx +177 -0
- manigent/web/frontend/src/components/briefing/briefing.css +147 -0
- manigent/web/frontend/src/components/briefing/heightBootstrap.test.ts +121 -0
- manigent/web/frontend/src/components/briefing/heightBootstrap.ts +224 -0
- manigent/web/frontend/src/components/briefing/parseBriefMessage.test.ts +90 -0
- manigent/web/frontend/src/components/briefing/sampleBriefing.ts +148 -0
- manigent/web/frontend/src/components/channels/ChannelBadge.tsx +35 -0
- manigent/web/frontend/src/components/chatroom/ChannelThreadControls.tsx +127 -0
- manigent/web/frontend/src/components/chatroom/ChannelThreadNotice.test.tsx +207 -0
- manigent/web/frontend/src/components/chatroom/ChannelThreadNotice.tsx +80 -0
- manigent/web/frontend/src/components/chatroom/ChatroomView.channel.test.tsx +550 -0
- manigent/web/frontend/src/components/chatroom/ChatroomView.flow.test.tsx +186 -0
- manigent/web/frontend/src/components/chatroom/ChatroomView.isolation.test.tsx +70 -0
- manigent/web/frontend/src/components/chatroom/ChatroomView.tsx +450 -0
- manigent/web/frontend/src/components/chatroom/DelegationRow.test.tsx +170 -0
- manigent/web/frontend/src/components/chatroom/DelegationRow.tsx +217 -0
- manigent/web/frontend/src/components/chatroom/DeliveryStatusLine.test.tsx +33 -0
- manigent/web/frontend/src/components/chatroom/DeliveryStatusLine.tsx +46 -0
- manigent/web/frontend/src/components/chatroom/ForwardModal.test.tsx +109 -0
- manigent/web/frontend/src/components/chatroom/ForwardModal.tsx +304 -0
- manigent/web/frontend/src/components/chatroom/HeldNotice.tsx +77 -0
- manigent/web/frontend/src/components/chatroom/MentionDropdown.tsx +97 -0
- manigent/web/frontend/src/components/chatroom/MessageInput.tsx +408 -0
- manigent/web/frontend/src/components/chatroom/MessageItem.tsx +425 -0
- manigent/web/frontend/src/components/chatroom/MessageList.outside.test.tsx +98 -0
- manigent/web/frontend/src/components/chatroom/MessageList.test.tsx +245 -0
- manigent/web/frontend/src/components/chatroom/MessageList.tsx +406 -0
- manigent/web/frontend/src/components/chatroom/OfflineRemedyNote.test.tsx +211 -0
- manigent/web/frontend/src/components/chatroom/OfflineRemedyNote.tsx +182 -0
- manigent/web/frontend/src/components/chatroom/OutsideBadge.tsx +18 -0
- manigent/web/frontend/src/components/chatroom/RoomFlowControl.tsx +49 -0
- manigent/web/frontend/src/components/chatroom/RoomFlowStrip.test.tsx +47 -0
- manigent/web/frontend/src/components/chatroom/RoomFlowStrip.tsx +25 -0
- manigent/web/frontend/src/components/chatroom/SaveToPackModal.tsx +350 -0
- manigent/web/frontend/src/components/chatroom/StagedAttachments.test.tsx +56 -0
- manigent/web/frontend/src/components/chatroom/StagedAttachments.tsx +160 -0
- manigent/web/frontend/src/components/chatroom/SystemNote.tsx +18 -0
- manigent/web/frontend/src/components/chatroom/contactSender.test.tsx +130 -0
- manigent/web/frontend/src/components/chatroom/index.ts +3 -0
- manigent/web/frontend/src/components/chatroom/recipientStatus.test.ts +344 -0
- manigent/web/frontend/src/components/chatroom/recipientStatus.ts +197 -0
- manigent/web/frontend/src/components/common/AnnouncementBanners.test.tsx +221 -0
- manigent/web/frontend/src/components/common/AnnouncementBanners.tsx +180 -0
- manigent/web/frontend/src/components/common/Avatar.test.tsx +50 -0
- manigent/web/frontend/src/components/common/Avatar.tsx +102 -0
- manigent/web/frontend/src/components/common/Badge.tsx +30 -0
- manigent/web/frontend/src/components/common/BrandName.tsx +16 -0
- manigent/web/frontend/src/components/common/ClearHistoryButton.tsx +74 -0
- manigent/web/frontend/src/components/common/CollapsibleHeader.tsx +64 -0
- manigent/web/frontend/src/components/common/ConnectionStatus.test.tsx +56 -0
- manigent/web/frontend/src/components/common/ConnectionStatus.tsx +28 -0
- manigent/web/frontend/src/components/common/DropOverlay.tsx +27 -0
- manigent/web/frontend/src/components/common/InlineEditableTitle.test.tsx +369 -0
- manigent/web/frontend/src/components/common/InlineEditableTitle.tsx +287 -0
- manigent/web/frontend/src/components/common/IsolationUnavailablePanel.tsx +19 -0
- manigent/web/frontend/src/components/common/Loading.tsx +22 -0
- manigent/web/frontend/src/components/common/ModelSelector.tsx +248 -0
- manigent/web/frontend/src/components/common/Overlay.tsx +15 -0
- manigent/web/frontend/src/components/common/ResizeHandle.tsx +52 -0
- manigent/web/frontend/src/components/common/TagAutocompleteInput.test.tsx +92 -0
- manigent/web/frontend/src/components/common/TagAutocompleteInput.tsx +246 -0
- manigent/web/frontend/src/components/common/TagPillInput.tsx +181 -0
- manigent/web/frontend/src/components/common/TeamAndAgentPicker.tsx +83 -0
- manigent/web/frontend/src/components/common/Timestamp.tsx +61 -0
- manigent/web/frontend/src/components/common/VerifiedBadge.tsx +20 -0
- manigent/web/frontend/src/components/common/index.ts +8 -0
- manigent/web/frontend/src/components/composer/PhPopover.tsx +239 -0
- manigent/web/frontend/src/components/composer/PromptEditor.test.tsx +472 -0
- manigent/web/frontend/src/components/composer/PromptEditor.tsx +540 -0
- manigent/web/frontend/src/components/composer/caret.ts +159 -0
- manigent/web/frontend/src/components/composer/placeholders.test.ts +352 -0
- manigent/web/frontend/src/components/composer/placeholders.ts +354 -0
- manigent/web/frontend/src/components/composer/promptEditor.css +161 -0
- manigent/web/frontend/src/components/computer/ComputerPage.test.tsx +452 -0
- manigent/web/frontend/src/components/computer/ComputerPage.tsx +811 -0
- manigent/web/frontend/src/components/computer/ConnectComputerModal.tsx +135 -0
- manigent/web/frontend/src/components/computer/CopyCommand.tsx +77 -0
- manigent/web/frontend/src/components/computer/DisconnectComputerModal.tsx +121 -0
- manigent/web/frontend/src/components/computer/SoftwareCard.tsx +145 -0
- manigent/web/frontend/src/components/computer/TerminalCard.test.tsx +114 -0
- manigent/web/frontend/src/components/computer/TerminalCard.tsx +131 -0
- manigent/web/frontend/src/components/computer/TerminalPanel.tsx +226 -0
- manigent/web/frontend/src/components/computer/TrustPanel.test.tsx +58 -0
- manigent/web/frontend/src/components/computer/TrustPanel.tsx +143 -0
- manigent/web/frontend/src/components/computer/computerCopy.test.ts +322 -0
- manigent/web/frontend/src/components/computer/computerCopy.ts +413 -0
- manigent/web/frontend/src/components/computer/sections.ts +21 -0
- manigent/web/frontend/src/components/computer/terminalProtocol.test.ts +135 -0
- manigent/web/frontend/src/components/computer/terminalProtocol.ts +170 -0
- manigent/web/frontend/src/components/context/ContextGuide.test.tsx +26 -0
- manigent/web/frontend/src/components/context/ContextGuide.tsx +144 -0
- manigent/web/frontend/src/components/context/ContextLanding.tsx +46 -0
- manigent/web/frontend/src/components/desk/CardBand.a11y.test.tsx +138 -0
- manigent/web/frontend/src/components/desk/CardBand.tsx +132 -0
- manigent/web/frontend/src/components/desk/DashTile.tsx +81 -0
- manigent/web/frontend/src/components/desk/DeskAgentPicker.retained.test.tsx +79 -0
- manigent/web/frontend/src/components/desk/DeskAgentPicker.tsx +151 -0
- manigent/web/frontend/src/components/desk/DeskComposer.detachTodo.test.tsx +233 -0
- manigent/web/frontend/src/components/desk/DeskComposer.dispatch.test.tsx +208 -0
- manigent/web/frontend/src/components/desk/DeskComposer.loadTodoDraft.test.tsx +242 -0
- manigent/web/frontend/src/components/desk/DeskComposer.restage.test.tsx +258 -0
- manigent/web/frontend/src/components/desk/DeskComposer.saveTodo.test.tsx +265 -0
- manigent/web/frontend/src/components/desk/DeskComposer.tsx +828 -0
- manigent/web/frontend/src/components/desk/DeskPage.crossHighlight.test.tsx +427 -0
- manigent/web/frontend/src/components/desk/DeskPage.link.test.tsx +271 -0
- manigent/web/frontend/src/components/desk/DeskPage.tsx +1066 -0
- manigent/web/frontend/src/components/desk/DeskPage.welcomeTour.test.tsx +262 -0
- manigent/web/frontend/src/components/desk/DeskSideRail.tsx +100 -0
- manigent/web/frontend/src/components/desk/DeskWelcomeTour.tsx +513 -0
- manigent/web/frontend/src/components/desk/DeskZeroState.tsx +45 -0
- manigent/web/frontend/src/components/desk/LabelChip.tsx +147 -0
- manigent/web/frontend/src/components/desk/LabelFilter.tsx +474 -0
- manigent/web/frontend/src/components/desk/LabelInput.test.tsx +439 -0
- manigent/web/frontend/src/components/desk/LabelInput.tsx +477 -0
- manigent/web/frontend/src/components/desk/LabelManageSheet.test.tsx +384 -0
- manigent/web/frontend/src/components/desk/LabelManageSheet.tsx +498 -0
- manigent/web/frontend/src/components/desk/PhaseRail.test.tsx +170 -0
- manigent/web/frontend/src/components/desk/PhaseRail.tsx +120 -0
- manigent/web/frontend/src/components/desk/PlanBand.test.tsx +655 -0
- manigent/web/frontend/src/components/desk/PlanBand.tsx +216 -0
- manigent/web/frontend/src/components/desk/SaveTodoModal.tsx +126 -0
- manigent/web/frontend/src/components/desk/SopEditor.tsx +280 -0
- manigent/web/frontend/src/components/desk/SopItem.tsx +171 -0
- manigent/web/frontend/src/components/desk/SopRail.test.tsx +517 -0
- manigent/web/frontend/src/components/desk/SopRail.tsx +250 -0
- manigent/web/frontend/src/components/desk/TicketChip.tsx +51 -0
- manigent/web/frontend/src/components/desk/TodoItem.attachments.test.tsx +96 -0
- manigent/web/frontend/src/components/desk/TodoItem.menu.test.tsx +142 -0
- manigent/web/frontend/src/components/desk/TodoItem.tsx +573 -0
- manigent/web/frontend/src/components/desk/TodoRail.longpress.test.tsx +396 -0
- manigent/web/frontend/src/components/desk/TodoRail.test.tsx +1155 -0
- manigent/web/frontend/src/components/desk/TodoRail.tsx +918 -0
- manigent/web/frontend/src/components/desk/TodoRowMenu.tsx +147 -0
- manigent/web/frontend/src/components/desk/UpdateCard.bands.test.tsx +308 -0
- manigent/web/frontend/src/components/desk/UpdateCard.sendBack.test.tsx +444 -0
- manigent/web/frontend/src/components/desk/UpdateCard.tsx +943 -0
- manigent/web/frontend/src/components/desk/UpdateCard.untrusted.test.tsx +161 -0
- manigent/web/frontend/src/components/desk/desk.css +1921 -0
- manigent/web/frontend/src/components/desk/desk.css.test.ts +582 -0
- manigent/web/frontend/src/components/desk/deskBands.test.ts +389 -0
- manigent/web/frontend/src/components/desk/deskBands.ts +226 -0
- manigent/web/frontend/src/components/desk/m16Surfaces.test.ts +134 -0
- manigent/web/frontend/src/components/desk/sopSyntax.ts +73 -0
- manigent/web/frontend/src/components/discovery/AgentDirectory.tsx +71 -0
- manigent/web/frontend/src/components/discovery/AgentFilterBar.tsx +46 -0
- manigent/web/frontend/src/components/discovery/AgentSearchBar.tsx +56 -0
- manigent/web/frontend/src/components/dm/AgentSummaryCard.tsx +154 -0
- manigent/web/frontend/src/components/dm/ComposeFields.tsx +145 -0
- manigent/web/frontend/src/components/dm/DMChatView.flow.test.tsx +374 -0
- manigent/web/frontend/src/components/dm/DMChatView.tsx +841 -0
- manigent/web/frontend/src/components/dm/DMZeroState.tsx +204 -0
- manigent/web/frontend/src/components/dm/DmComposeModal.tsx +301 -0
- manigent/web/frontend/src/components/dm/PersonalizeModal.tsx +366 -0
- manigent/web/frontend/src/components/dm/index.ts +1 -0
- manigent/web/frontend/src/components/files/FileItem.tsx +138 -0
- manigent/web/frontend/src/components/files/FileList.outside.test.tsx +29 -0
- manigent/web/frontend/src/components/files/FileList.tsx +48 -0
- manigent/web/frontend/src/components/files/FilePreview.test.tsx +75 -0
- manigent/web/frontend/src/components/files/FilePreview.tsx +325 -0
- manigent/web/frontend/src/components/files/FileViewerModal.test.tsx +157 -0
- manigent/web/frontend/src/components/files/FileViewerModal.tsx +261 -0
- manigent/web/frontend/src/components/files/HighlightedCode.tsx +39 -0
- manigent/web/frontend/src/components/files/SaveToAgentPanel.test.tsx +156 -0
- manigent/web/frontend/src/components/files/SaveToAgentPanel.tsx +545 -0
- manigent/web/frontend/src/components/files/index.ts +3 -0
- manigent/web/frontend/src/components/hero/AgentAvatar.tsx +66 -0
- manigent/web/frontend/src/components/hero/Button.tsx +66 -0
- manigent/web/frontend/src/components/hero/Hero.tsx +133 -0
- manigent/web/frontend/src/components/homefs/HomeFolderBrowser.test.tsx +393 -0
- manigent/web/frontend/src/components/homefs/HomeFolderBrowser.tsx +792 -0
- manigent/web/frontend/src/components/hub/BridgeDeliveryLine.test.tsx +40 -0
- manigent/web/frontend/src/components/hub/BridgeDeliveryLine.tsx +36 -0
- manigent/web/frontend/src/components/hub/HubAvatar.test.tsx +93 -0
- manigent/web/frontend/src/components/hub/HubAvatar.tsx +33 -0
- manigent/web/frontend/src/components/hub/HubChatView.test.tsx +695 -0
- manigent/web/frontend/src/components/hub/HubChatView.tsx +504 -0
- manigent/web/frontend/src/components/hub/HubContactLabel.test.tsx +92 -0
- manigent/web/frontend/src/components/hub/HubContactLabel.tsx +47 -0
- manigent/web/frontend/src/components/hub/HubDeliveryLine.tsx +49 -0
- manigent/web/frontend/src/components/hub/HubDisclosure.tsx +49 -0
- manigent/web/frontend/src/components/hub/HubEnrollmentDialog.test.tsx +144 -0
- manigent/web/frontend/src/components/hub/HubEnrollmentDialog.tsx +244 -0
- manigent/web/frontend/src/components/hub/HubMessageBody.test.tsx +26 -0
- manigent/web/frontend/src/components/hub/HubMessageBody.tsx +14 -0
- manigent/web/frontend/src/components/hub/HubOfflineConfirm.tsx +45 -0
- manigent/web/frontend/src/components/hub/HubThreadBanner.tsx +33 -0
- manigent/web/frontend/src/components/hub/HubThreadGuard.tsx +59 -0
- manigent/web/frontend/src/components/hub/RemoteAgentCard.test.tsx +50 -0
- manigent/web/frontend/src/components/hub/RemoteAgentCard.tsx +174 -0
- manigent/web/frontend/src/components/hub/RemoteAgentSection.test.tsx +446 -0
- manigent/web/frontend/src/components/hub/RemoteAgentSection.tsx +219 -0
- manigent/web/frontend/src/components/hub/ReportDialog.test.tsx +96 -0
- manigent/web/frontend/src/components/hub/ReportDialog.tsx +162 -0
- manigent/web/frontend/src/components/hub/RetainButton.test.tsx +103 -0
- manigent/web/frontend/src/components/hub/RetainButton.tsx +114 -0
- manigent/web/frontend/src/components/hub/RetainDialog.test.tsx +68 -0
- manigent/web/frontend/src/components/hub/RetainDialog.tsx +115 -0
- manigent/web/frontend/src/components/hub/RetainedList.test.tsx +94 -0
- manigent/web/frontend/src/components/hub/RetainedList.tsx +32 -0
- manigent/web/frontend/src/components/hub/RetainedRow.tsx +129 -0
- manigent/web/frontend/src/components/hub/StandInChip.test.tsx +37 -0
- manigent/web/frontend/src/components/hub/StandInChip.tsx +43 -0
- manigent/web/frontend/src/components/hub/UnusualNameChip.test.tsx +81 -0
- manigent/web/frontend/src/components/hub/UnusualNameChip.tsx +22 -0
- manigent/web/frontend/src/components/hub/hubLint.test.ts +265 -0
- manigent/web/frontend/src/components/landing/ComparisonSection.tsx +92 -0
- manigent/web/frontend/src/components/landing/ConsoleSection.tsx +402 -0
- manigent/web/frontend/src/components/landing/CrewSection.tsx +224 -0
- manigent/web/frontend/src/components/landing/FAQ.tsx +68 -0
- manigent/web/frontend/src/components/landing/Footer.tsx +58 -0
- manigent/web/frontend/src/components/landing/HowItWorks.tsx +117 -0
- manigent/web/frontend/src/components/landing/KnowledgeSection.tsx +91 -0
- manigent/web/frontend/src/components/landing/LandingPage.test.tsx +134 -0
- manigent/web/frontend/src/components/landing/LearningSection.tsx +486 -0
- manigent/web/frontend/src/components/landing/Nav.tsx +68 -0
- manigent/web/frontend/src/components/landing/Pricing.tsx +263 -0
- manigent/web/frontend/src/components/landing/Section.tsx +98 -0
- manigent/web/frontend/src/components/landing/StaffingSection.tsx +122 -0
- manigent/web/frontend/src/components/landing/WhyNowSection.tsx +92 -0
- manigent/web/frontend/src/components/landing/WhyTeamSection.tsx +73 -0
- manigent/web/frontend/src/components/landing/earlyAccess.ts +23 -0
- manigent/web/frontend/src/components/layout/AppLayout.tsx +77 -0
- manigent/web/frontend/src/components/layout/Sidebar.tsx +172 -0
- manigent/web/frontend/src/components/layout/index.ts +2 -0
- manigent/web/frontend/src/components/project/ActivityFeed.tsx +247 -0
- manigent/web/frontend/src/components/project/CreateProjectPage.tsx +543 -0
- manigent/web/frontend/src/components/project/DeleteProjectButton.tsx +39 -0
- manigent/web/frontend/src/components/project/ProjectPlanTab.phase.test.tsx +107 -0
- manigent/web/frontend/src/components/project/ProjectPlanTab.test.tsx +994 -0
- manigent/web/frontend/src/components/project/ProjectPlanTab.tsx +166 -0
- manigent/web/frontend/src/components/project/ProjectReportTab.tsx +139 -0
- manigent/web/frontend/src/components/project/ProjectView.tsx +390 -0
- manigent/web/frontend/src/components/project/index.ts +2 -0
- manigent/web/frontend/src/components/project/plan/PlanAcceptance.test.tsx +460 -0
- manigent/web/frontend/src/components/project/plan/PlanAcceptance.tsx +111 -0
- manigent/web/frontend/src/components/project/plan/PlanBlockEdit.tsx +111 -0
- manigent/web/frontend/src/components/project/plan/PlanBlockEditing.test.tsx +1132 -0
- manigent/web/frontend/src/components/project/plan/PlanBody.test.tsx +495 -0
- manigent/web/frontend/src/components/project/plan/PlanBody.tsx +992 -0
- manigent/web/frontend/src/components/project/plan/PlanCollide.tsx +254 -0
- manigent/web/frontend/src/components/project/plan/PlanCommentList.test.tsx +420 -0
- manigent/web/frontend/src/components/project/plan/PlanCommentList.tsx +391 -0
- manigent/web/frontend/src/components/project/plan/PlanDecisionNudge.test.tsx +226 -0
- manigent/web/frontend/src/components/project/plan/PlanDecisionNudge.tsx +128 -0
- manigent/web/frontend/src/components/project/plan/PlanDestructiveAccept.test.tsx +209 -0
- manigent/web/frontend/src/components/project/plan/PlanDiff.test.tsx +126 -0
- manigent/web/frontend/src/components/project/plan/PlanDiff.tsx +131 -0
- manigent/web/frontend/src/components/project/plan/PlanDiffSeries.test.tsx +500 -0
- manigent/web/frontend/src/components/project/plan/PlanDiffSeries.tsx +125 -0
- manigent/web/frontend/src/components/project/plan/PlanEditor.mounts.test.tsx +504 -0
- manigent/web/frontend/src/components/project/plan/PlanEditor.tsx +2159 -0
- manigent/web/frontend/src/components/project/plan/PlanHistory.tsx +69 -0
- manigent/web/frontend/src/components/project/plan/PlanInlineNotes.test.tsx +2398 -0
- manigent/web/frontend/src/components/project/plan/PlanNoteBase.test.tsx +215 -0
- manigent/web/frontend/src/components/project/plan/PlanNoteRow.test.tsx +562 -0
- manigent/web/frontend/src/components/project/plan/PlanNoteRow.tsx +908 -0
- manigent/web/frontend/src/components/project/plan/PlanQuotePop.tsx +254 -0
- manigent/web/frontend/src/components/project/plan/PlanStaleReconcile.test.tsx +244 -0
- manigent/web/frontend/src/components/project/plan/PlanSubmitWindow.test.tsx +281 -0
- manigent/web/frontend/src/components/project/plan/PlanTray.tsx +184 -0
- manigent/web/frontend/src/components/project/plan/PlanTrayMirror.test.tsx +242 -0
- manigent/web/frontend/src/components/project/plan/plan.css +1151 -0
- manigent/web/frontend/src/components/project/plan/planAnchor.test.tsx +459 -0
- manigent/web/frontend/src/components/project/plan/planAnchor.tsx +277 -0
- manigent/web/frontend/src/components/project/plan/planBlocks.test.tsx +470 -0
- manigent/web/frontend/src/components/project/plan/planBlocks.ts +242 -0
- manigent/web/frontend/src/components/project/plan/planContrast.test.ts +229 -0
- manigent/web/frontend/src/components/project/plan/planDiffs.test.ts +455 -0
- manigent/web/frontend/src/components/project/plan/planDiffs.ts +358 -0
- manigent/web/frontend/src/components/project/plan/planTestServer.ts +741 -0
- manigent/web/frontend/src/components/project/plan/planTransport.test.ts +160 -0
- manigent/web/frontend/src/components/project/plan/planTrayLogic.test.ts +1201 -0
- manigent/web/frontend/src/components/project/plan/planTrayLogic.ts +1164 -0
- manigent/web/frontend/src/components/publicAgents/PublicAgentsAccount.tsx +162 -0
- manigent/web/frontend/src/components/publicAgents/api.ts +55 -0
- manigent/web/frontend/src/components/publicAgents/parts.tsx +54 -0
- manigent/web/frontend/src/components/publicAgents/publicAgents.css +53 -0
- manigent/web/frontend/src/components/publicAgents/publicAgents.test.tsx +86 -0
- manigent/web/frontend/src/components/publicAgents/widget.test.ts +148 -0
- manigent/web/frontend/src/components/publicAgents/widget.ts +40 -0
- manigent/web/frontend/src/components/publicRequests/AgentDrawer.tsx +101 -0
- manigent/web/frontend/src/components/publicRequests/AgentPanel.tsx +173 -0
- manigent/web/frontend/src/components/publicRequests/AgentRankList.tsx +61 -0
- manigent/web/frontend/src/components/publicRequests/MatchRetainButton.tsx +67 -0
- manigent/web/frontend/src/components/publicRequests/ProgressBoard.tsx +74 -0
- manigent/web/frontend/src/components/publicRequests/PublicRequestsPage.tsx +57 -0
- manigent/web/frontend/src/components/publicRequests/RequestHome.tsx +99 -0
- manigent/web/frontend/src/components/publicRequests/RequestRoom.tsx +126 -0
- manigent/web/frontend/src/components/publicRequests/agentView.ts +65 -0
- manigent/web/frontend/src/components/publicRequests/publicRequests.css +209 -0
- manigent/web/frontend/src/components/publicRequests/publicRequests.test.tsx +172 -0
- manigent/web/frontend/src/components/publicRequests/requestCopy.ts +121 -0
- manigent/web/frontend/src/components/sample-request/SampleIdeaList.test.tsx +190 -0
- manigent/web/frontend/src/components/sample-request/SampleIdeaList.tsx +180 -0
- manigent/web/frontend/src/components/sample-request/SampleRequestBody.tsx +276 -0
- manigent/web/frontend/src/components/sample-request/assistantStarterSamples.ts +62 -0
- manigent/web/frontend/src/components/sample-request/sampleIdeaList.css +106 -0
- manigent/web/frontend/src/components/scheduled/CronPicker.tsx +80 -0
- manigent/web/frontend/src/components/scheduled/ScheduleMessageModal.tsx +341 -0
- manigent/web/frontend/src/components/scheduled/ScheduledMessagesList.tsx +157 -0
- manigent/web/frontend/src/components/settings/AgentSettingsPage.tsx +366 -0
- manigent/web/frontend/src/components/settings/ConnectedAccounts.tsx +277 -0
- manigent/web/frontend/src/components/settings/DefaultModelSection.test.tsx +96 -0
- manigent/web/frontend/src/components/settings/DefaultModelSection.tsx +212 -0
- manigent/web/frontend/src/components/settings/DisconnectConfirmModal.tsx +93 -0
- manigent/web/frontend/src/components/settings/FrontDeskSettingsPage.isolation.test.tsx +66 -0
- manigent/web/frontend/src/components/settings/FrontDeskSettingsPage.tsx +134 -0
- manigent/web/frontend/src/components/settings/InvitableAgentsPicker.tsx +182 -0
- manigent/web/frontend/src/components/settings/KnowledgePacksSection.tsx +867 -0
- manigent/web/frontend/src/components/settings/ModelConfigDialog.catalogDriven.test.tsx +79 -0
- manigent/web/frontend/src/components/settings/ModelConfigDialog.tsx +343 -0
- manigent/web/frontend/src/components/settings/ModelConfigSection.smartRouting.test.tsx +109 -0
- manigent/web/frontend/src/components/settings/ModelConfigSection.tsx +599 -0
- manigent/web/frontend/src/components/settings/ReflectionScheduleSection.tsx +170 -0
- manigent/web/frontend/src/components/settings/UserSettingsPage.tsx +168 -0
- manigent/web/frontend/src/components/settings/agent/AgentStatusStrip.tsx +71 -0
- manigent/web/frontend/src/components/settings/agent/BlockedDeploymentsCard.test.tsx +81 -0
- manigent/web/frontend/src/components/settings/agent/BlockedDeploymentsCard.tsx +62 -0
- manigent/web/frontend/src/components/settings/agent/ChannelIsolationLine.tsx +21 -0
- manigent/web/frontend/src/components/settings/agent/ChannelsSection.test.tsx +879 -0
- manigent/web/frontend/src/components/settings/agent/ChannelsSection.tsx +1124 -0
- manigent/web/frontend/src/components/settings/agent/EnvVarsCard.test.ts +15 -0
- manigent/web/frontend/src/components/settings/agent/EnvVarsCard.tsx +301 -0
- manigent/web/frontend/src/components/settings/agent/ExtraDirsList.tsx +85 -0
- manigent/web/frontend/src/components/settings/agent/GeneralSection.tsx +88 -0
- manigent/web/frontend/src/components/settings/agent/HostDisclaimerSlot.test.tsx +32 -0
- manigent/web/frontend/src/components/settings/agent/HostDisclaimerSlot.tsx +30 -0
- manigent/web/frontend/src/components/settings/agent/HubChannelCard.test.tsx +449 -0
- manigent/web/frontend/src/components/settings/agent/HubChannelCard.tsx +312 -0
- manigent/web/frontend/src/components/settings/agent/HubInvitableAgentsCard.test.tsx +83 -0
- manigent/web/frontend/src/components/settings/agent/HubInvitableAgentsCard.tsx +64 -0
- manigent/web/frontend/src/components/settings/agent/HubSendUsageCard.test.tsx +69 -0
- manigent/web/frontend/src/components/settings/agent/HubSendUsageCard.tsx +42 -0
- manigent/web/frontend/src/components/settings/agent/HubTurnOffDialog.tsx +88 -0
- manigent/web/frontend/src/components/settings/agent/IsolationRecheckButton.test.tsx +26 -0
- manigent/web/frontend/src/components/settings/agent/IsolationRecheckButton.tsx +68 -0
- manigent/web/frontend/src/components/settings/agent/KnowledgeSection.tsx +68 -0
- manigent/web/frontend/src/components/settings/agent/LineChannelCard.test.tsx +428 -0
- manigent/web/frontend/src/components/settings/agent/LineChannelCard.tsx +343 -0
- manigent/web/frontend/src/components/settings/agent/RuntimeSection.test.tsx +164 -0
- manigent/web/frontend/src/components/settings/agent/RuntimeSection.tsx +118 -0
- manigent/web/frontend/src/components/settings/agent/StorageSection.tsx +78 -0
- manigent/web/frontend/src/components/settings/agent/WebChatChannelCard.test.tsx +253 -0
- manigent/web/frontend/src/components/settings/agent/WebChatChannelCard.tsx +401 -0
- manigent/web/frontend/src/components/settings/agent/WhatsAppChannelCard.test.tsx +462 -0
- manigent/web/frontend/src/components/settings/agent/WhatsAppChannelCard.tsx +289 -0
- manigent/web/frontend/src/components/settings/agent/agentSettings.test.tsx +109 -0
- manigent/web/frontend/src/components/settings/agent/channelSetupGuides.test.tsx +162 -0
- manigent/web/frontend/src/components/settings/agent/channelSetupGuides.tsx +119 -0
- manigent/web/frontend/src/components/settings/agent/channelsShared.ts +83 -0
- manigent/web/frontend/src/components/settings/agent/fields.tsx +67 -0
- manigent/web/frontend/src/components/settings/agent/sections.ts +36 -0
- manigent/web/frontend/src/components/settings/agent/useAgentSettingsDraft.ts +129 -0
- manigent/web/frontend/src/components/settings/agent/useInstallSelection.ts +38 -0
- manigent/web/frontend/src/components/settings/primitives.tsx +98 -0
- manigent/web/frontend/src/components/shared/ExpandToggle.tsx +45 -0
- manigent/web/frontend/src/components/shared/MarkdownBody.tsx +218 -0
- manigent/web/frontend/src/components/shared/MarkdownBody.untrusted.test.tsx +97 -0
- manigent/web/frontend/src/components/shared/RenderBody.tsx +90 -0
- manigent/web/frontend/src/components/shared/rehypeHideComments.test.tsx +115 -0
- manigent/web/frontend/src/components/shared/rehypeHideComments.ts +124 -0
- manigent/web/frontend/src/components/sharing/AcceptSharePage.tsx +141 -0
- manigent/web/frontend/src/components/sharing/ShareLandingPage.tsx +89 -0
- manigent/web/frontend/src/components/sidebar/AccountChip.test.tsx +337 -0
- manigent/web/frontend/src/components/sidebar/AccountChip.tsx +407 -0
- manigent/web/frontend/src/components/sidebar/ActiveWorkDot.tsx +9 -0
- manigent/web/frontend/src/components/sidebar/AgentProfile.tsx +44 -0
- manigent/web/frontend/src/components/sidebar/AgentRow.tsx +71 -0
- manigent/web/frontend/src/components/sidebar/AgentsPanel.retained.test.tsx +105 -0
- manigent/web/frontend/src/components/sidebar/AgentsPanel.tsx +231 -0
- manigent/web/frontend/src/components/sidebar/AssistantHero.tsx +48 -0
- manigent/web/frontend/src/components/sidebar/ChatroomItem.tsx +48 -0
- manigent/web/frontend/src/components/sidebar/CommandPalette.channelThreads.test.tsx +76 -0
- manigent/web/frontend/src/components/sidebar/CommandPalette.reopen.test.tsx +206 -0
- manigent/web/frontend/src/components/sidebar/CommandPalette.test.tsx +725 -0
- manigent/web/frontend/src/components/sidebar/CommandPalette.tsx +774 -0
- manigent/web/frontend/src/components/sidebar/ComputersPanel.tsx +144 -0
- manigent/web/frontend/src/components/sidebar/DmIcon.tsx +46 -0
- manigent/web/frontend/src/components/sidebar/DmsPanel.tsx +226 -0
- manigent/web/frontend/src/components/sidebar/FrontDeskPanel.channels.test.tsx +582 -0
- manigent/web/frontend/src/components/sidebar/FrontDeskPanel.test.tsx +151 -0
- manigent/web/frontend/src/components/sidebar/FrontDeskPanel.tsx +454 -0
- manigent/web/frontend/src/components/sidebar/HotkeysHelp.tsx +101 -0
- manigent/web/frontend/src/components/sidebar/MessageHitRow.test.tsx +84 -0
- manigent/web/frontend/src/components/sidebar/MessageHitRow.tsx +57 -0
- manigent/web/frontend/src/components/sidebar/MyDeskRailPanel.crossHighlight.test.tsx +247 -0
- manigent/web/frontend/src/components/sidebar/MyDeskRailPanel.delete.test.tsx +273 -0
- manigent/web/frontend/src/components/sidebar/MyDeskRailPanel.tsx +351 -0
- manigent/web/frontend/src/components/sidebar/PanelShell.tsx +48 -0
- manigent/web/frontend/src/components/sidebar/ProjectItem.tsx +161 -0
- manigent/web/frontend/src/components/sidebar/ProjectRow.tsx +151 -0
- manigent/web/frontend/src/components/sidebar/ProjectsPanel.tsx +80 -0
- manigent/web/frontend/src/components/sidebar/PublicPanel.counterpart.test.tsx +85 -0
- manigent/web/frontend/src/components/sidebar/PublicPanel.hub.test.tsx +147 -0
- manigent/web/frontend/src/components/sidebar/PublicPanel.tsx +271 -0
- manigent/web/frontend/src/components/sidebar/RecencyHeader.tsx +17 -0
- manigent/web/frontend/src/components/sidebar/ScheduledPanel.tsx +58 -0
- manigent/web/frontend/src/components/sidebar/SidebarAvatar.tsx +128 -0
- manigent/web/frontend/src/components/sidebar/SidebarRail.tsx +163 -0
- manigent/web/frontend/src/components/sidebar/TeamGroup.tsx +67 -0
- manigent/web/frontend/src/components/sidebar/dmPrefill.test.ts +69 -0
- manigent/web/frontend/src/components/sidebar/dmPrefill.ts +80 -0
- manigent/web/frontend/src/components/sidebar/index.ts +18 -0
- manigent/web/frontend/src/components/sidebar/railShortcuts.ts +18 -0
- manigent/web/frontend/src/components/today/lib.test.ts +70 -0
- manigent/web/frontend/src/components/today/lib.ts +296 -0
- manigent/web/frontend/src/components/today/today.css +113 -0
- manigent/web/frontend/src/components/welcome/RunnerSetup.css +253 -0
- manigent/web/frontend/src/components/welcome/RunnerSetup.tsx +148 -0
- manigent/web/frontend/src/components/welcome/WelcomePage.css +383 -0
- manigent/web/frontend/src/components/welcome/WelcomePage.tsx +329 -0
- manigent/web/frontend/src/components/welcome/welcomeCopy.test.ts +382 -0
- manigent/web/frontend/src/components/welcome/welcomeCopy.ts +313 -0
- manigent/web/frontend/src/config/llmProviders.test.ts +161 -0
- manigent/web/frontend/src/config/llmProviders.ts +109 -0
- manigent/web/frontend/src/config/providerIds.fixture.json +13 -0
- manigent/web/frontend/src/hooks/accountNavigation.test.tsx +138 -0
- manigent/web/frontend/src/hooks/index.ts +10 -0
- manigent/web/frontend/src/hooks/useActiveWorkBootstrap.ts +86 -0
- manigent/web/frontend/src/hooks/useAgentAccess.test.tsx +30 -0
- manigent/web/frontend/src/hooks/useAgentAccess.ts +204 -0
- manigent/web/frontend/src/hooks/useAgentSampleRequests.ts +27 -0
- manigent/web/frontend/src/hooks/useAgentSearch.ts +10 -0
- manigent/web/frontend/src/hooks/useAgents.ts +46 -0
- manigent/web/frontend/src/hooks/useAnnouncements.test.ts +112 -0
- manigent/web/frontend/src/hooks/useAnnouncements.ts +100 -0
- manigent/web/frontend/src/hooks/useAssistantAgent.ts +24 -0
- manigent/web/frontend/src/hooks/useAutoResizeTextarea.ts +18 -0
- manigent/web/frontend/src/hooks/useBridgeDeliveries.test.tsx +79 -0
- manigent/web/frontend/src/hooks/useBriefTabs.ts +122 -0
- manigent/web/frontend/src/hooks/useChannelThreads.ts +69 -0
- manigent/web/frontend/src/hooks/useComposeForm.ts +80 -0
- manigent/web/frontend/src/hooks/useComputers.ts +123 -0
- manigent/web/frontend/src/hooks/useContextFirstItem.ts +118 -0
- manigent/web/frontend/src/hooks/useCreateProject.ts +78 -0
- manigent/web/frontend/src/hooks/useDebouncedValue.ts +16 -0
- manigent/web/frontend/src/hooks/useDeleteProject.ts +27 -0
- manigent/web/frontend/src/hooks/useDeskDispatch.test.tsx +170 -0
- manigent/web/frontend/src/hooks/useDeskDispatch.ts +135 -0
- manigent/web/frontend/src/hooks/useDeskReadState.test.tsx +85 -0
- manigent/web/frontend/src/hooks/useDeskReadState.ts +150 -0
- manigent/web/frontend/src/hooks/useDeskRecentlyDone.test.tsx +222 -0
- manigent/web/frontend/src/hooks/useDeskRecentlyDone.ts +225 -0
- manigent/web/frontend/src/hooks/useDeskTour.ts +37 -0
- manigent/web/frontend/src/hooks/useDeskUpdates.channelThreads.test.tsx +91 -0
- manigent/web/frontend/src/hooks/useDeskUpdates.hubThreads.test.tsx +93 -0
- manigent/web/frontend/src/hooks/useDeskUpdates.ts +353 -0
- manigent/web/frontend/src/hooks/useDisconnectIntegration.ts +23 -0
- manigent/web/frontend/src/hooks/useDmThreads.ts +99 -0
- manigent/web/frontend/src/hooks/useFileDropzone.ts +93 -0
- manigent/web/frontend/src/hooks/useFileUpload.ts +38 -0
- manigent/web/frontend/src/hooks/useFiles.ts +98 -0
- manigent/web/frontend/src/hooks/useHub.test.tsx +237 -0
- manigent/web/frontend/src/hooks/useHub.ts +502 -0
- manigent/web/frontend/src/hooks/useInstall.ts +82 -0
- manigent/web/frontend/src/hooks/useLabels.test.tsx +294 -0
- manigent/web/frontend/src/hooks/useLabels.ts +287 -0
- manigent/web/frontend/src/hooks/useLogout.ts +49 -0
- manigent/web/frontend/src/hooks/useMentionDetection.ts +90 -0
- manigent/web/frontend/src/hooks/useMessageAttachments.deskLimit.test.ts +264 -0
- manigent/web/frontend/src/hooks/useMessageAttachments.ts +283 -0
- manigent/web/frontend/src/hooks/useMessageDeepLink.test.tsx +70 -0
- manigent/web/frontend/src/hooks/useMessageDeepLink.ts +32 -0
- manigent/web/frontend/src/hooks/useMessageSearch.ts +78 -0
- manigent/web/frontend/src/hooks/useModelConfigs.ts +178 -0
- manigent/web/frontend/src/hooks/useMyAgents.ts +18 -0
- manigent/web/frontend/src/hooks/usePointerReorder.ts +270 -0
- manigent/web/frontend/src/hooks/usePrefersReducedMotion.ts +18 -0
- manigent/web/frontend/src/hooks/useProjectPlan.ts +36 -0
- manigent/web/frontend/src/hooks/useProjectReport.ts +19 -0
- manigent/web/frontend/src/hooks/useProjects.ts +104 -0
- manigent/web/frontend/src/hooks/useProjectsWithChatrooms.ts +97 -0
- manigent/web/frontend/src/hooks/usePublicRequests.ts +89 -0
- manigent/web/frontend/src/hooks/useRecentAgents.ts +38 -0
- manigent/web/frontend/src/hooks/useRenameProject.test.tsx +185 -0
- manigent/web/frontend/src/hooks/useRenameProject.ts +192 -0
- manigent/web/frontend/src/hooks/useRoomTrust.ts +56 -0
- manigent/web/frontend/src/hooks/useSharing.ts +46 -0
- manigent/web/frontend/src/hooks/useSopRecents.ts +55 -0
- manigent/web/frontend/src/hooks/useSopSchedules.ts +25 -0
- manigent/web/frontend/src/hooks/useSops.ts +99 -0
- manigent/web/frontend/src/hooks/useSwitchAccount.ts +41 -0
- manigent/web/frontend/src/hooks/useTeamGroups.ts +48 -0
- manigent/web/frontend/src/hooks/useThreadOutbox.ts +68 -0
- manigent/web/frontend/src/hooks/useTodos.add.test.tsx +89 -0
- manigent/web/frontend/src/hooks/useTodos.linkProject.test.tsx +115 -0
- manigent/web/frontend/src/hooks/useTodos.saveDraft.test.tsx +188 -0
- manigent/web/frontend/src/hooks/useTodos.ts +396 -0
- manigent/web/frontend/src/hooks/useUntrustedBodies.test.tsx +87 -0
- manigent/web/frontend/src/hooks/useUntrustedBodies.ts +51 -0
- manigent/web/frontend/src/hooks/useWebSocket.briefTab.test.tsx +148 -0
- manigent/web/frontend/src/hooks/useWebSocket.channelThreads.test.tsx +82 -0
- manigent/web/frontend/src/hooks/useWebSocket.hostSync.test.tsx +92 -0
- manigent/web/frontend/src/hooks/useWebSocket.hub.test.tsx +114 -0
- manigent/web/frontend/src/hooks/useWebSocket.labelSync.test.tsx +168 -0
- manigent/web/frontend/src/hooks/useWebSocket.plan.test.tsx +168 -0
- manigent/web/frontend/src/hooks/useWebSocket.reconnect.test.tsx +166 -0
- manigent/web/frontend/src/hooks/useWebSocket.runnerVersions.test.tsx +167 -0
- manigent/web/frontend/src/hooks/useWebSocket.ts +495 -0
- manigent/web/frontend/src/hooks/useWindowFileDropGuard.ts +33 -0
- manigent/web/frontend/src/landing.tsx +11 -0
- manigent/web/frontend/src/main.tsx +17 -0
- manigent/web/frontend/src/private-beta.tsx +10 -0
- manigent/web/frontend/src/public.tsx +42 -0
- manigent/web/frontend/src/queryClient.ts +16 -0
- manigent/web/frontend/src/stores/activeWorkStore.ts +88 -0
- manigent/web/frontend/src/stores/announcementStore.test.ts +217 -0
- manigent/web/frontend/src/stores/announcementStore.ts +169 -0
- manigent/web/frontend/src/stores/authStore.multiSession.test.ts +304 -0
- manigent/web/frontend/src/stores/authStore.test.ts +235 -0
- manigent/web/frontend/src/stores/authStore.ts +693 -0
- manigent/web/frontend/src/stores/deskLabelViewStore.test.ts +162 -0
- manigent/web/frontend/src/stores/deskLabelViewStore.ts +234 -0
- manigent/web/frontend/src/stores/deskLinkStore.ts +54 -0
- manigent/web/frontend/src/stores/deskStore.test.ts +53 -0
- manigent/web/frontend/src/stores/deskStore.ts +93 -0
- manigent/web/frontend/src/stores/heldPostsStore.ts +78 -0
- manigent/web/frontend/src/stores/mcpAuthStore.ts +52 -0
- manigent/web/frontend/src/stores/modelSelectionStore.ts +33 -0
- manigent/web/frontend/src/stores/perUserStores.ts +79 -0
- manigent/web/frontend/src/stores/runnerVersionStore.test.ts +81 -0
- manigent/web/frontend/src/stores/runnerVersionStore.ts +60 -0
- manigent/web/frontend/src/stores/skillAuthStore.ts +54 -0
- manigent/web/frontend/src/stores/switchReset.test.ts +236 -0
- manigent/web/frontend/src/stores/tokenExp.ts +32 -0
- manigent/web/frontend/src/stores/uiStore.ts +186 -0
- manigent/web/frontend/src/stores/unreadStore.ts +204 -0
- manigent/web/frontend/src/stores/unsavedGuard.ts +22 -0
- manigent/web/frontend/src/styles/design/fonts/JetBrainsMono-Latin.woff2 +0 -0
- manigent/web/frontend/src/styles/design/fonts/Nunito-Latin.woff2 +0 -0
- manigent/web/frontend/src/styles/design/fonts/Nunito-LatinExt.woff2 +0 -0
- manigent/web/frontend/src/styles/design/tokens.css +160 -0
- manigent/web/frontend/src/styles/homepage.css +242 -0
- manigent/web/frontend/src/styles/index.css +126 -0
- manigent/web/frontend/src/styles/landing.css +52 -0
- manigent/web/frontend/src/test/authFixtures.ts +154 -0
- manigent/web/frontend/src/test/fetchHarness.ts +203 -0
- manigent/web/frontend/src/test/fixtures/channelThreads.smoke.json +42 -0
- manigent/web/frontend/src/test/hubFixtures.ts +212 -0
- manigent/web/frontend/src/test/queryWrapper.tsx +9 -0
- manigent/web/frontend/src/test/suiteHygiene.test.ts +212 -0
- manigent/web/frontend/src/types/channelThreadName.test.ts +16 -0
- manigent/web/frontend/src/types/index.ts +903 -0
- manigent/web/frontend/src/utils/agentAccessCopy.test.ts +53 -0
- manigent/web/frontend/src/utils/agentAccessCopy.ts +231 -0
- manigent/web/frontend/src/utils/agentDisplay.ts +81 -0
- manigent/web/frontend/src/utils/briefSchedule.ts +25 -0
- manigent/web/frontend/src/utils/channelThreads.test.ts +109 -0
- manigent/web/frontend/src/utils/channelThreads.ts +179 -0
- manigent/web/frontend/src/utils/chatroomDisplay.ts +11 -0
- manigent/web/frontend/src/utils/deskLinks.test.ts +155 -0
- manigent/web/frontend/src/utils/deskLinks.ts +115 -0
- manigent/web/frontend/src/utils/deskRecipient.ts +29 -0
- manigent/web/frontend/src/utils/formatDate.ts +102 -0
- manigent/web/frontend/src/utils/frontDesk.ts +65 -0
- manigent/web/frontend/src/utils/hubChannelView.test.ts +178 -0
- manigent/web/frontend/src/utils/hubChannelView.ts +307 -0
- manigent/web/frontend/src/utils/hubDelivery.test.ts +105 -0
- manigent/web/frontend/src/utils/hubDelivery.ts +137 -0
- manigent/web/frontend/src/utils/hubDisplay.test.ts +157 -0
- manigent/web/frontend/src/utils/hubDisplay.ts +221 -0
- manigent/web/frontend/src/utils/hubThread.test.ts +184 -0
- manigent/web/frontend/src/utils/hubThread.ts +247 -0
- manigent/web/frontend/src/utils/labelErrors.test.ts +127 -0
- manigent/web/frontend/src/utils/labelErrors.ts +123 -0
- manigent/web/frontend/src/utils/labelView.test.ts +846 -0
- manigent/web/frontend/src/utils/labelView.ts +760 -0
- manigent/web/frontend/src/utils/legalUrls.ts +11 -0
- manigent/web/frontend/src/utils/messageSender.test.ts +32 -0
- manigent/web/frontend/src/utils/messageSender.ts +29 -0
- manigent/web/frontend/src/utils/oauthScopeCopy.ts +73 -0
- manigent/web/frontend/src/utils/personalize.ts +49 -0
- manigent/web/frontend/src/utils/reachableAgents.test.ts +36 -0
- manigent/web/frontend/src/utils/reachableAgents.ts +24 -0
- manigent/web/frontend/src/utils/recencyBuckets.ts +61 -0
- manigent/web/frontend/src/utils/roomTrust.test.ts +94 -0
- manigent/web/frontend/src/utils/roomTrust.ts +111 -0
- manigent/web/frontend/src/utils/snippetHighlight.test.ts +63 -0
- manigent/web/frontend/src/utils/snippetHighlight.ts +42 -0
- manigent/web/frontend/src/utils/sopRecipient.ts +37 -0
- manigent/web/frontend/src/utils/sopRequest.test.ts +91 -0
- manigent/web/frontend/src/utils/sopRequest.ts +68 -0
- manigent/web/frontend/src/utils/sopSchedule.ts +26 -0
- manigent/web/frontend/src/utils/sopSection.test.ts +148 -0
- manigent/web/frontend/src/utils/sopSection.ts +143 -0
- manigent/web/frontend/src/utils/textSearch.test.ts +60 -0
- manigent/web/frontend/src/utils/textSearch.ts +43 -0
- manigent/web/frontend/src/utils/thoughtPartner.ts +26 -0
- manigent/web/frontend/src/utils/threadName.test.ts +131 -0
- manigent/web/frontend/src/utils/threadName.ts +122 -0
- manigent/web/frontend/src/utils/ticketView.test.ts +296 -0
- manigent/web/frontend/src/utils/ticketView.ts +175 -0
- manigent/web/frontend/src/utils/todoAttachments.desiredState.test.ts +180 -0
- manigent/web/frontend/src/utils/todoAttachments.ts +126 -0
- manigent/web/frontend/src/utils/todoDraft.test.ts +84 -0
- manigent/web/frontend/src/utils/todoDraft.ts +46 -0
- manigent/web/frontend/src/utils/todoSearch.test.ts +82 -0
- manigent/web/frontend/src/utils/todoSearch.ts +43 -0
- manigent/web/frontend/src/utils/versionMatch.test.ts +295 -0
- manigent/web/frontend/src/utils/versionMatch.ts +245 -0
- manigent/web/frontend/src/vite-env.d.ts +1 -0
- manigent/web/frontend/tailwind.config.js +86 -0
- manigent/web/frontend/tos.html +188 -0
- manigent/web/frontend/tsconfig.dts.json +25 -0
- manigent/web/frontend/tsconfig.json +26 -0
- manigent/web/frontend/tsconfig.node.json +11 -0
- manigent/web/frontend/vite.config.ts +41 -0
- manigent/web/frontend/vitest.config.ts +16 -0
- manigent/ws_edge/app.py +514 -0
- manigent/ws_edge/auth.py +104 -0
- manigent/ws_edge/link.py +282 -0
- manigent/ws_edge/registry.py +206 -0
- manigent/ws_edge/terminal_relay.py +269 -0
- manigent_server-0.1.0.dist-info/METADATA +138 -0
- manigent_server-0.1.0.dist-info/RECORD +1004 -0
- manigent_server-0.1.0.dist-info/WHEEL +4 -0
- manigent_server-0.1.0.dist-info/entry_points.txt +2 -0
- manigent_server-0.1.0.dist-info/licenses/LICENSE +68 -0
|
@@ -0,0 +1,1603 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""
|
|
3
|
+
manigent/models/desk_todo.py
|
|
4
|
+
|
|
5
|
+
Desk to-do store — the My Desk right-rail "plate": tasks that still sit
|
|
6
|
+
with the manager. Every field on a to-do is one the owner can edit in the
|
|
7
|
+
web UI: the text, labels, a draft prompt and its recipient, and stored
|
|
8
|
+
attachments. Items arrive two ways — the owner captures one in the rail, or
|
|
9
|
+
an agent publishes one (``manigent todo publish``) carrying a suggested
|
|
10
|
+
prompt and recipient, written into the same ``draft_*`` fields the owner's
|
|
11
|
+
recipient picker writes. Nothing an agent publishes is shown in a place the
|
|
12
|
+
owner cannot edit it.
|
|
13
|
+
|
|
14
|
+
Clicking a to-do opens the Task take-over (a guided dispatch surface); the
|
|
15
|
+
plate is an *ordered* list the manager drags to reorder, so — unlike the
|
|
16
|
+
one-file-per-artifact brief-tab registry — every user's plate is a single
|
|
17
|
+
ordered JSON document. Reorder is then a plain array rewrite under one
|
|
18
|
+
lock, and agent-publish is a prepend.
|
|
19
|
+
|
|
20
|
+
A brand-new owner does not open an empty plate: ``SEED`` below is the
|
|
21
|
+
onboarding item every account starts with, materialized in memory on the first
|
|
22
|
+
read and written to disk by the first mutation. It is never re-asserted
|
|
23
|
+
afterwards — see the comment on the constant.
|
|
24
|
+
|
|
25
|
+
Storage::
|
|
26
|
+
|
|
27
|
+
{data_dir}/desk-todos/
|
|
28
|
+
<owner_user_id>.json # ordered list[DeskTodo], newest capture first
|
|
29
|
+
# ABSENT => the SEED, not an empty plate
|
|
30
|
+
{data_dir}/desk-seed-state/
|
|
31
|
+
<owner_user_id>.json # {"given": [seed id, ...]} — every starter row
|
|
32
|
+
# this account was EVER given; only ever grows
|
|
33
|
+
|
|
34
|
+
Mutations are broadcast to the owner via ``DESK_TODO_SYNC`` (see
|
|
35
|
+
``server/routes/desk_todos.py``) so the desk refetches ``GET /me/desk/todos``.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import logging
|
|
41
|
+
import mimetypes
|
|
42
|
+
import re
|
|
43
|
+
import secrets
|
|
44
|
+
import shutil
|
|
45
|
+
import unicodedata
|
|
46
|
+
from collections.abc import Iterable
|
|
47
|
+
from datetime import UTC, datetime
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from pydantic import BaseModel, Field, model_validator
|
|
52
|
+
|
|
53
|
+
from manigent.models.desk_sop import CONVENE_PROCEDURE, SCHEDULED_DEAL_SOP_TITLE
|
|
54
|
+
from manigent.models.onboarding_plan import (
|
|
55
|
+
ONBOARDING_PLAN_TEMPLATE,
|
|
56
|
+
PLAN_TEMPLATE_END,
|
|
57
|
+
PLAN_TEMPLATE_START,
|
|
58
|
+
)
|
|
59
|
+
from manigent.models.provider_agents import MODEL_AGENT_DEFINITION, provider_agent_names
|
|
60
|
+
from manigent.models.thinking_partner_plan import THINKING_PARTNER_PLAN_TEMPLATE
|
|
61
|
+
from manigent.utils.file_io import FileUtil
|
|
62
|
+
|
|
63
|
+
logger = logging.getLogger("manigent.models.desk_todo")
|
|
64
|
+
|
|
65
|
+
_lock = asyncio.Lock()
|
|
66
|
+
|
|
67
|
+
TODOS_DIR = "desk-todos"
|
|
68
|
+
|
|
69
|
+
# The seeded-items record: which starter rows this account has EVER been given,
|
|
70
|
+
# kept apart from the plate so that deleting a row cannot erase the fact that
|
|
71
|
+
# it was given. It is what lets ``backfill_starter_todos`` add a NEW starter row
|
|
72
|
+
# to old plates without ever re-adding one the owner already threw away.
|
|
73
|
+
# Written by the first plate save and by the backfill; nothing removes an entry.
|
|
74
|
+
SEED_STATE_DIR = "desk-seed-state"
|
|
75
|
+
|
|
76
|
+
# Real attachment bytes, one directory per to-do::
|
|
77
|
+
#
|
|
78
|
+
# {data_dir}/desk-todo-files/<owner_user_id>/<todo_id>/<attachment_id>
|
|
79
|
+
#
|
|
80
|
+
# Deliberately NOT the message-attachment layout in ``server/routes/files.py``.
|
|
81
|
+
# That one keys on ``(project_id, chatroom_name, filename)`` and writes through
|
|
82
|
+
# a changelog every project participant's runner replays into its own sandbox —
|
|
83
|
+
# so reusing it would fan a user's PRIVATE to-do attachments out to agents
|
|
84
|
+
# before the user ever dispatched them. Three further blockers, independent of
|
|
85
|
+
# the privacy one: that path has no delete route at all (append-only changelog),
|
|
86
|
+
# filename is its identity so two to-dos each holding ``report.pdf`` collide,
|
|
87
|
+
# and 20 MB of private bytes would live permanently in a replay stream every
|
|
88
|
+
# runner reads on join.
|
|
89
|
+
#
|
|
90
|
+
# This is a sibling of the desk documents already here (``desk-labels/``,
|
|
91
|
+
# ``desk-sops/``, ``desk-read-state/``, ``brief-tabs/``), on the
|
|
92
|
+
# ``knowledge_pack.py`` precedent: raw bytes on disk, base64 on the wire, no
|
|
93
|
+
# changelog. Both path segments and the filename are SERVER-MINTED ids, so path
|
|
94
|
+
# traversal is structurally impossible here rather than merely checked for —
|
|
95
|
+
# no client-supplied string ever reaches a path segment.
|
|
96
|
+
TODO_FILES_DIR = "desk-todo-files"
|
|
97
|
+
|
|
98
|
+
# The display ``name`` is not a path segment, but it IS echoed back in a
|
|
99
|
+
# Content-Disposition header by the download route, so it is bounded and
|
|
100
|
+
# scrubbed on the way in.
|
|
101
|
+
MAX_ATTACHMENT_NAME_LEN = 200
|
|
102
|
+
|
|
103
|
+
# Labels. Deliberately looser than the rail's chip-overflow threshold — the
|
|
104
|
+
# backend limit should never be the thing that makes a layout decision for the
|
|
105
|
+
# UI.
|
|
106
|
+
MAX_LABELS_PER_TODO = 8
|
|
107
|
+
MAX_SLUG_LEN = 32
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# The starter plate
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
#
|
|
113
|
+
# What a brand-new owner finds on the plate. Ordinary to-dos — editable,
|
|
114
|
+
# archivable, deletable, no ``locked`` field and no guard anywhere in this
|
|
115
|
+
# module protecting one. A helpful start, not a fixture.
|
|
116
|
+
#
|
|
117
|
+
# THE SEED IS A FIRST-WRITE MATERIALIZATION AND IS NEVER RE-ASSERTED. It decides
|
|
118
|
+
# only what an owner who has never written a plate sees; the moment anything is
|
|
119
|
+
# written — a capture, an archive toggle, a label edit, an agent publish — the
|
|
120
|
+
# file is authoritative forever after. An implementation that re-adds missing
|
|
121
|
+
# rows on read is wrong, and it is wrong in the way that silently resurrects the
|
|
122
|
+
# onboarding item every time the owner ticks it off. Mirrors
|
|
123
|
+
# ``models/desk_sop.py`` and ``models/desk_label.py``, which carry the long-form
|
|
124
|
+
# version of this argument; all three make the same promise and must keep
|
|
125
|
+
# making it.
|
|
126
|
+
#
|
|
127
|
+
# The decision is made on ``path.exists()`` and on nothing else, because
|
|
128
|
+
# ``FileUtil.read`` answers the same ``None`` for a missing file and for a
|
|
129
|
+
# JSONDecodeError: deciding "seed?" on the parsed value would re-seed a CORRUPT
|
|
130
|
+
# plate, which is the one case where the owner's rows are still on disk and most
|
|
131
|
+
# need not to be written over.
|
|
132
|
+
#
|
|
133
|
+
# ``by_agent_id`` is null: no agent published this. ``drafted`` IS true,
|
|
134
|
+
# because the row genuinely arrives carrying a ready prompt — that is the field
|
|
135
|
+
# the plate reads to show a preview of it and the toast reads to say "edit it,
|
|
136
|
+
# then command" rather than telling the owner to write a draft that is already
|
|
137
|
+
# there.
|
|
138
|
+
#
|
|
139
|
+
# The recipient pair is left null on purpose. A null recipient already resolves
|
|
140
|
+
# to ``{username}-assistant`` (``utils/todoDraft.ts``), which is exactly who
|
|
141
|
+
# this item is addressed to — and naming it here instead would bake one user's
|
|
142
|
+
# assistant name into a constant. It also has to be null for a further reason
|
|
143
|
+
# specific to this item: at account creation the assistant agent does not exist
|
|
144
|
+
# yet (it is registered later, from the user's own machine), so there is no id
|
|
145
|
+
# to store.
|
|
146
|
+
SEED_TIMESTAMP = "1970-01-01T00:00:00+00:00"
|
|
147
|
+
|
|
148
|
+
ONBOARDING_SEED_ID = "t-seed-onboarding-know-me"
|
|
149
|
+
THINKING_PARTNER_SEED_ID = "t-seed-thinking-partner"
|
|
150
|
+
CONVENE_SEED_ID = "t-seed-convene-models"
|
|
151
|
+
SCHEDULED_DEAL_SEED_ID = "t-seed-scheduled-deal-monitor"
|
|
152
|
+
|
|
153
|
+
# The second starter row's prompt. Same shape as onboarding's: the assistant
|
|
154
|
+
# fills the one ``<…>`` line of a ready-made plan
|
|
155
|
+
# (``models/thinking_partner_plan.py``), creates one project with it and sends a
|
|
156
|
+
# quiet review round, so the owner's only click is dismissing the start gate.
|
|
157
|
+
# Inline rather than a pointer to the "Register new agent" SOP in
|
|
158
|
+
# ``models/desk_sop.py``: that SOP is the owner's to edit or delete, and this
|
|
159
|
+
# to-do has to keep working after they do.
|
|
160
|
+
#
|
|
161
|
+
# The to-do registers NOTHING. thought_partner is registered by the plan's
|
|
162
|
+
# first milestone, after the owner has dismissed the gate, exactly as
|
|
163
|
+
# onboarding registers its team. ``--agent thought_partner`` at create is safe
|
|
164
|
+
# before the agent exists: the allowlist is stored as names and matched against
|
|
165
|
+
# the roster every turn.
|
|
166
|
+
THINKING_PARTNER_PROMPT = (
|
|
167
|
+
"Add a thinking partner for me as ONE project that you coordinate, using "
|
|
168
|
+
"the ready-made plan below.\n"
|
|
169
|
+
"\n"
|
|
170
|
+
# Knowledge flows model agents -> assistant -> everyone else. Without
|
|
171
|
+
# USER.md the brain dump would be all "unknown", so this row waits for
|
|
172
|
+
# onboarding rather than running a second briefing of its own.
|
|
173
|
+
"First: if my USER.md does not exist yet, stop and tell me to run \"Get to "
|
|
174
|
+
"know me & staff my priorities\" first: the brain dump is built from it.\n"
|
|
175
|
+
"1. Fill in the plan. Replace only the <...> line under Goal with my areas "
|
|
176
|
+
"from USER.md. Leave everything else word for word, and save it as "
|
|
177
|
+
"./thinking-partner-plan.md.\n"
|
|
178
|
+
"2. Create the project with your create-project skill, passing the plan, "
|
|
179
|
+
"thought_partner, and the id of this conversation:\n"
|
|
180
|
+
" manigent project create add-thought-partner \"$MANIGENT_AGENT_ID\" "
|
|
181
|
+
"\"<request>\" --display-name \"Add a thinking partner for big "
|
|
182
|
+
"decisions\" --agent-pool owned --agent thought_partner --spawned-from "
|
|
183
|
+
"<the id of this conversation> --plan-file ./thinking-partner-plan.md\n"
|
|
184
|
+
" The request reads: \"The plan is predefined. Do not redraft it and do "
|
|
185
|
+
"not consult anyone on it. Run `manigent plan review <project>` once so "
|
|
186
|
+
"the start gate goes up, tell me to dismiss it to begin, and stop.\"\n"
|
|
187
|
+
"3. Tell me, in one line, that dismissing the project's start gate "
|
|
188
|
+
"registers thought_partner and begins its onboarding. Do not register "
|
|
189
|
+
"thought_partner or install anything here; the plan's first milestone "
|
|
190
|
+
"does that. Do nothing else here.\n"
|
|
191
|
+
"\n"
|
|
192
|
+
f"{PLAN_TEMPLATE_START}\n"
|
|
193
|
+
f"{THINKING_PARTNER_PLAN_TEMPLATE}"
|
|
194
|
+
f"{PLAN_TEMPLATE_END}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# The third starter row's prompt: a model-agent panel, the demo of what several
|
|
198
|
+
# models together give that one model alone does not. Inline, like the row
|
|
199
|
+
# above, rather than a pointer to the "SYSTEM:Convene panel" SOP the owner may
|
|
200
|
+
# delete; the moderation steps are that SOP's own ``CONVENE_PROCEDURE``, so the
|
|
201
|
+
# two cannot drift. Capped at 2 rounds so the demo stays cheap (about 9 agent
|
|
202
|
+
# turns with three model agents).
|
|
203
|
+
CONVENE_PROMPT = (
|
|
204
|
+
f"{MODEL_AGENT_DEFINITION}\n"
|
|
205
|
+
"\n"
|
|
206
|
+
"Show me what my model agents can do together. From my USER.md and my "
|
|
207
|
+
"plate, propose 3 judgment questions I really face (not lookups), one "
|
|
208
|
+
"sentence each. I pick one or write my own.\n"
|
|
209
|
+
"\n"
|
|
210
|
+
"Then convene a panel on it:\n"
|
|
211
|
+
" Panel: every model agent on my roster\n"
|
|
212
|
+
" Max rounds: 2\n"
|
|
213
|
+
" Consensus: majority\n"
|
|
214
|
+
# The assistant moderates and never votes (CONVENE_PROCEDURE), so the
|
|
215
|
+
# fallback panel is made of other agents, never the assistant itself.
|
|
216
|
+
"If I have fewer than 2 model agents, tell me, and offer the model agent I "
|
|
217
|
+
"do have plus thought_partner (or another of my agents) as the panel "
|
|
218
|
+
"instead, saying plainly that they may run on the same model, so the "
|
|
219
|
+
"panel is less diverse.\n"
|
|
220
|
+
"Don't create the project until I've picked the question.\n"
|
|
221
|
+
"\n"
|
|
222
|
+
f"{CONVENE_PROCEDURE}\n"
|
|
223
|
+
"\n"
|
|
224
|
+
"Also report the one claim a single model alone would have got wrong, and "
|
|
225
|
+
"which model agent caught it."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
SEED: tuple[dict[str, object], ...] = (
|
|
229
|
+
{
|
|
230
|
+
"id": ONBOARDING_SEED_ID,
|
|
231
|
+
# Phrased for the OWNER reading their own plate: the row names the
|
|
232
|
+
# thing that needs doing, not the party doing it. Clicking it loads
|
|
233
|
+
# ``draft_prompt`` into the composer already addressed to
|
|
234
|
+
# ``{username}-assistant``, which is where the second person belongs.
|
|
235
|
+
"text": "Get to know me & staff my priorities",
|
|
236
|
+
"drafted": True,
|
|
237
|
+
# Self-contained rather than a pointer to an SOP: the plan travels
|
|
238
|
+
# inside the prompt, so the to-do keeps working if the owner deletes
|
|
239
|
+
# every SOP. The project it creates is the one place a new account's
|
|
240
|
+
# USER.md gets written; there is no separate personalize item.
|
|
241
|
+
#
|
|
242
|
+
# One click by design: the assistant creates the project with the plan
|
|
243
|
+
# already written (``--plan-file``) and sends a quiet review round, so
|
|
244
|
+
# the only thing on the owner's screen is the start gate. Dismissing it
|
|
245
|
+
# is the acceptance that starts the work.
|
|
246
|
+
#
|
|
247
|
+
# PRODUCT RULE, pinned in tests/test_desk_todo_seed.py: onboarding
|
|
248
|
+
# staffs to-dos and never starts them. Nothing here may tell the
|
|
249
|
+
# assistant to run ``todo trigger`` — the owner's own first click on a
|
|
250
|
+
# staffed to-do is the activation event.
|
|
251
|
+
"draft_prompt": (
|
|
252
|
+
"Get me set up as ONE project that you coordinate, using the "
|
|
253
|
+
"ready-made plan below. You staff my to-dos; you never start them. "
|
|
254
|
+
"I fire each one myself.\n"
|
|
255
|
+
"\n"
|
|
256
|
+
# The model agents (models/provider_agents.py) are plain agents in
|
|
257
|
+
# code; mentoring the assistant is written down in the plan and
|
|
258
|
+
# nowhere else. Knowledge flows one way: model agents -> assistant
|
|
259
|
+
# -> thought_partner and every agent the plan registers.
|
|
260
|
+
f"{MODEL_AGENT_DEFINITION}\n"
|
|
261
|
+
"\n"
|
|
262
|
+
"1. Fill in the plan. Replace only the <...> lines under Goal:\n"
|
|
263
|
+
" - Model agents: the ones on my roster from this fixed list: "
|
|
264
|
+
f"{', '.join(provider_agent_names())}. If I have none, write "
|
|
265
|
+
"\"none\".\n"
|
|
266
|
+
" - Existing agents: my other agents, one line each (name - "
|
|
267
|
+
"job).\n"
|
|
268
|
+
" Leave everything else word for word, and save it as "
|
|
269
|
+
"./onboarding-plan.md.\n"
|
|
270
|
+
"2. Create the project with your create-project skill, passing the "
|
|
271
|
+
"plan, every model agent on my roster, and the id of this "
|
|
272
|
+
"conversation:\n"
|
|
273
|
+
" manigent project create get-to-know-me \"$MANIGENT_AGENT_ID\" "
|
|
274
|
+
"\"<request>\" --display-name \"Get to know me & staff my "
|
|
275
|
+
"priorities\" --agent-pool owned --agent <model agent> "
|
|
276
|
+
"--spawned-from <the id of this conversation> --plan-file "
|
|
277
|
+
"./onboarding-plan.md\n"
|
|
278
|
+
" The request reads: \"The plan is predefined. Do not redraft it "
|
|
279
|
+
"and do not consult anyone on it. Run `manigent plan review "
|
|
280
|
+
"<project>` once so the start gate goes up, tell me to dismiss it "
|
|
281
|
+
"to begin, and stop.\"\n"
|
|
282
|
+
"3. Tell me, in one line, that the project is ready and that "
|
|
283
|
+
"dismissing its start gate begins it. Do nothing else here.\n"
|
|
284
|
+
"\n"
|
|
285
|
+
f"{PLAN_TEMPLATE_START}\n"
|
|
286
|
+
f"{ONBOARDING_PLAN_TEMPLATE}"
|
|
287
|
+
f"{PLAN_TEMPLATE_END}"
|
|
288
|
+
),
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
# Right after onboarding, which is what writes the USER.md the brain
|
|
292
|
+
# dump in this row's plan draws on. Same shape as the row above: a
|
|
293
|
+
# ready prompt whose plan rides in the project create, and a null
|
|
294
|
+
# recipient that resolves to the owner's own assistant — the one agent
|
|
295
|
+
# that can register another.
|
|
296
|
+
"id": THINKING_PARTNER_SEED_ID,
|
|
297
|
+
"text": "Add a thinking partner for big decisions",
|
|
298
|
+
"drafted": True,
|
|
299
|
+
"draft_prompt": THINKING_PARTNER_PROMPT,
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
# Third: it proposes questions from USER.md and the plate, which
|
|
303
|
+
# onboarding writes. Same shape as the rows above. Like them it never
|
|
304
|
+
# fires anything; it creates the panel project only after the owner
|
|
305
|
+
# picks the question.
|
|
306
|
+
"id": CONVENE_SEED_ID,
|
|
307
|
+
"text": "Get the best answer from all my models combined",
|
|
308
|
+
"drafted": True,
|
|
309
|
+
"draft_prompt": CONVENE_PROMPT,
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
# Last: a standalone everyday job that needs nothing onboarding writes.
|
|
313
|
+
# Unlike the rows above it POINTS at an SOP rather than carrying the
|
|
314
|
+
# procedure inline, so the owner edits the tracking steps in one place;
|
|
315
|
+
# if they delete or rename that SOP, the assistant says it cannot find
|
|
316
|
+
# it. Not in ``BACKFILL_SEED_IDS``: new accounts only.
|
|
317
|
+
"id": SCHEDULED_DEAL_SEED_ID,
|
|
318
|
+
"text": "Scheduled Deal Monitor",
|
|
319
|
+
"drafted": True,
|
|
320
|
+
"draft_prompt": (
|
|
321
|
+
f"Please invoke the SOP \"{SCHEDULED_DEAL_SOP_TITLE}\". Ask me "
|
|
322
|
+
"what product to track and how often to check it (for example "
|
|
323
|
+
"\"every day at 9am\" or \"every Monday\"). If any other parameter "
|
|
324
|
+
"is missing or you are not sure about one, ask me before you start."
|
|
325
|
+
),
|
|
326
|
+
},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Distinguishes "key absent from a PATCH body" (leave the stored value
|
|
330
|
+
# untouched) from an explicit JSON ``null`` (clear the field). Used only by
|
|
331
|
+
# ``patch_todo`` for the three-way ``draft_recipient_*`` merge; the route
|
|
332
|
+
# layer detects key presence off the raw request body (see
|
|
333
|
+
# ``server/routes/desk_todos.py``) since a non-serializable sentinel can't be
|
|
334
|
+
# a FastAPI ``Body`` default.
|
|
335
|
+
_UNSET: object = object()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class LabelError(ValueError):
|
|
339
|
+
"""A coded label failure. ``str(e)`` is EXACTLY the wire format the label
|
|
340
|
+
data contract §9 froze::
|
|
341
|
+
|
|
342
|
+
"<code>: <human sentence>"
|
|
343
|
+
"labels.cap_exceeded: at most 8 labels per to-do (got 9)"
|
|
344
|
+
|
|
345
|
+
Subclasses ``ValueError`` on purpose: the three ``except ValueError`` handlers
|
|
346
|
+
already in ``server/routes/desk_todos.py`` map it to a 400 with ``str(e)`` as
|
|
347
|
+
``detail``, which is byte-for-byte what §9 asks for — so the to-do write paths
|
|
348
|
+
need no new except clause. ``status`` is only read by
|
|
349
|
+
``server/routes/desk_labels.py``, where 404 and 409 are also reachable.
|
|
350
|
+
|
|
351
|
+
Every §9 row gets a constructor here, so a code string is written once in the
|
|
352
|
+
codebase and cannot be mistyped at one call site and right at another. The
|
|
353
|
+
registry-only codes live here too even though only ``desk_label`` raises
|
|
354
|
+
them: one home for the codes beats a second, near-identical error class, and
|
|
355
|
+
``desk_label`` may import from this module (never the reverse).
|
|
356
|
+
|
|
357
|
+
The code is frozen from first release; the sentence after it is free to
|
|
358
|
+
change forever, and nothing may parse it.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
def __init__(self, code: str, sentence: str, status: int = 400) -> None:
|
|
362
|
+
super().__init__(f"{code}: {sentence}")
|
|
363
|
+
self.code = code
|
|
364
|
+
self.sentence = sentence
|
|
365
|
+
self.status = status
|
|
366
|
+
|
|
367
|
+
# --- to-do write paths ---------------------------------------------------
|
|
368
|
+
@classmethod
|
|
369
|
+
def cap_exceeded(cls, got: int) -> "LabelError":
|
|
370
|
+
return cls(
|
|
371
|
+
"labels.cap_exceeded",
|
|
372
|
+
f"at most {MAX_LABELS_PER_TODO} labels per to-do (got {got})",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
@classmethod
|
|
376
|
+
def invalid_slug(cls, raw: str) -> "LabelError":
|
|
377
|
+
return cls(
|
|
378
|
+
"labels.invalid_slug",
|
|
379
|
+
f"{raw!r} is not a valid label — use letters, numbers and hyphens",
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
@classmethod
|
|
383
|
+
def slug_too_long(cls, slug: str) -> "LabelError":
|
|
384
|
+
return cls(
|
|
385
|
+
"labels.slug_too_long", f"{slug!r} exceeds {MAX_SLUG_LEN} characters"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
@classmethod
|
|
389
|
+
def invalid_type(cls) -> "LabelError":
|
|
390
|
+
return cls("labels.invalid_type", "labels must be a list of strings")
|
|
391
|
+
|
|
392
|
+
# --- registry paths (raised by models/desk_label.py) ---------------------
|
|
393
|
+
@classmethod
|
|
394
|
+
def name_too_long(cls, limit: int) -> "LabelError":
|
|
395
|
+
return cls("labels.name_too_long", f"name exceeds {limit} characters")
|
|
396
|
+
|
|
397
|
+
@classmethod
|
|
398
|
+
def registry_full(cls, limit: int) -> "LabelError":
|
|
399
|
+
return cls("labels.registry_full", f"at most {limit} labels in your list")
|
|
400
|
+
|
|
401
|
+
@classmethod
|
|
402
|
+
def unknown_color(cls, token: str, palette: tuple[str, ...]) -> "LabelError":
|
|
403
|
+
return cls(
|
|
404
|
+
"labels.unknown_color",
|
|
405
|
+
f"{token!r} is not a known palette token — use one of: "
|
|
406
|
+
+ ", ".join(palette),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
@classmethod
|
|
410
|
+
def invalid_kind(cls) -> "LabelError":
|
|
411
|
+
# THE CODE STRING IS FROZEN. It is published in the to-do data contract
|
|
412
|
+
# and the browser branches on it at ``utils/labelErrors.ts`` to write
|
|
413
|
+
# its own copy; changing it here is a silent wire break. Only the
|
|
414
|
+
# SENTENCE moves, and it moves because reciting a one-member enum
|
|
415
|
+
# ("kind must be 'context'") answers a question nobody asked. What the
|
|
416
|
+
# caller actually did was ask for a state, and what they need to hear is
|
|
417
|
+
# that states are not a label any more — the lifecycle they want is
|
|
418
|
+
# derived on ``state`` and cannot be set by hand.
|
|
419
|
+
return cls(
|
|
420
|
+
"labels.invalid_kind",
|
|
421
|
+
"every label is a context now — the 'state' kind is retired, and "
|
|
422
|
+
"whether the work is running is derived on the to-do's `state`",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
@classmethod
|
|
426
|
+
def kind_immutable(cls, slug: str, stored: str, wanted: str) -> "LabelError":
|
|
427
|
+
return cls(
|
|
428
|
+
"labels.kind_immutable",
|
|
429
|
+
f"{slug!r} is a {stored} and cannot become a {wanted} — "
|
|
430
|
+
f"delete it and add it again",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
@classmethod
|
|
434
|
+
def kind_conflict(cls, slug: str, other_kind: str, wanted: str) -> "LabelError":
|
|
435
|
+
"""One symmetric condition, one code, either direction — the sentence is
|
|
436
|
+
generated from the OTHER row's kind."""
|
|
437
|
+
return cls(
|
|
438
|
+
"labels.kind_conflict",
|
|
439
|
+
f"{slug!r} is a {other_kind}, not a {wanted}",
|
|
440
|
+
status=409,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
@classmethod
|
|
444
|
+
def kind_mismatch(
|
|
445
|
+
cls, slug: str, slug_kind: str, into: str, into_kind: str
|
|
446
|
+
) -> "LabelError":
|
|
447
|
+
return cls(
|
|
448
|
+
"labels.kind_mismatch",
|
|
449
|
+
f"{slug!r} is a {slug_kind} and {into!r} is a {into_kind} — "
|
|
450
|
+
f"labels can only be merged into the same kind",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
@classmethod
|
|
454
|
+
def unknown_slug(cls, slug: str) -> "LabelError":
|
|
455
|
+
return cls(
|
|
456
|
+
"labels.unknown_slug",
|
|
457
|
+
f"label {slug!r} is not in your label list",
|
|
458
|
+
status=404,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
@classmethod
|
|
462
|
+
def unknown_merge_target(cls, slug: str) -> "LabelError":
|
|
463
|
+
return cls(
|
|
464
|
+
"labels.unknown_merge_target",
|
|
465
|
+
f"label {slug!r} is not in your label list",
|
|
466
|
+
status=404,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
@classmethod
|
|
470
|
+
def duplicate(cls, slug: str) -> "LabelError":
|
|
471
|
+
return cls("labels.duplicate", f"label {slug!r} already exists", status=409)
|
|
472
|
+
|
|
473
|
+
@classmethod
|
|
474
|
+
def merge_self(cls, slug: str) -> "LabelError":
|
|
475
|
+
return cls("labels.merge_self", f"cannot merge {slug!r} into itself")
|
|
476
|
+
|
|
477
|
+
@classmethod
|
|
478
|
+
def registry_unreadable(cls) -> "LabelError":
|
|
479
|
+
"""The registry file exists and cannot be read, so a write that starts
|
|
480
|
+
from "zero rows" would replace rows nobody has seen.
|
|
481
|
+
|
|
482
|
+
409, not 400 and not 500: the request is well-formed and the server is
|
|
483
|
+
healthy — it is the CURRENT STATE of the document that makes the write
|
|
484
|
+
unsafe, which is exactly what 409 means. A 400 would blame the caller
|
|
485
|
+
for a file they did not corrupt, and a 500 would invite a retry that is
|
|
486
|
+
guaranteed to fail the same way.
|
|
487
|
+
|
|
488
|
+
Only DIRECT registry mutations raise this. ``auto_register`` skips
|
|
489
|
+
instead: refusing to save someone's to-do because a label INDEX is
|
|
490
|
+
unreadable is a worse outcome than the corruption.
|
|
491
|
+
"""
|
|
492
|
+
return cls(
|
|
493
|
+
"labels.registry_unreadable",
|
|
494
|
+
"your label list is on disk but cannot be read — it has not been "
|
|
495
|
+
"changed, and it will not be overwritten until it is repaired or "
|
|
496
|
+
"removed",
|
|
497
|
+
status=409,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# Runs of anything outside the stored charset collapse to a single "-". That is
|
|
502
|
+
# a REPLACE, not a drop, and it is what makes the contract's own worked example
|
|
503
|
+
# true ("Someday/Maybe" -> "someday-maybe", not "somedaymaybe"); it also matches
|
|
504
|
+
# the mock's `NORM` character-for-character, which is the other implementation
|
|
505
|
+
# of this grammar and the one @frontend ships against.
|
|
506
|
+
_NON_SLUG_RUN = re.compile(r"[^a-z0-9-]+")
|
|
507
|
+
_DASH_RUN = re.compile(r"-{2,}")
|
|
508
|
+
_VALID_SLUG = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def normalize_label(raw: str) -> str:
|
|
512
|
+
"""One label through the contract's normalization, in order: NFKC + strip;
|
|
513
|
+
drop ONE leading '@'; lowercase; collapse every run of non-``[a-z0-9-]`` to
|
|
514
|
+
a single '-'; collapse '-' runs and trim them; then validate.
|
|
515
|
+
|
|
516
|
+
Raises ``LabelError('labels.invalid_slug')`` when the result is empty or
|
|
517
|
+
fails ``^[a-z0-9][a-z0-9-]*$``, and ``labels.slug_too_long`` past 32 chars.
|
|
518
|
+
Length is checked AFTER normalization — a 40-char display name that
|
|
519
|
+
normalizes to 20 is fine.
|
|
520
|
+
|
|
521
|
+
``'@Office'`` -> ``office`` · ``'Wait For'`` -> ``wait-for`` ·
|
|
522
|
+
``'Someday/Maybe'`` -> ``someday-maybe`` · ``'@ '`` -> invalid_slug.
|
|
523
|
+
"""
|
|
524
|
+
if not isinstance(raw, str):
|
|
525
|
+
raise LabelError.invalid_type()
|
|
526
|
+
text = unicodedata.normalize("NFKC", raw).strip()
|
|
527
|
+
if text.startswith("@"):
|
|
528
|
+
text = text[1:]
|
|
529
|
+
text = _NON_SLUG_RUN.sub("-", text.lower())
|
|
530
|
+
text = _DASH_RUN.sub("-", text).strip("-")
|
|
531
|
+
if not text or not _VALID_SLUG.fullmatch(text):
|
|
532
|
+
raise LabelError.invalid_slug(raw)
|
|
533
|
+
if len(text) > MAX_SLUG_LEN:
|
|
534
|
+
raise LabelError.slug_too_long(text)
|
|
535
|
+
return text
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def normalize_labels(raw: object) -> list[str]:
|
|
539
|
+
"""A whole label list, normalized and de-duplicated preserving FIRST-SEEN
|
|
540
|
+
order (chip order is meaningful; nothing reads it yet, but nothing may
|
|
541
|
+
scramble it either).
|
|
542
|
+
|
|
543
|
+
``None`` -> ``[]``. A non-list, or a list holding a non-string, raises
|
|
544
|
+
``labels.invalid_type`` — which is why every route reads the label keys
|
|
545
|
+
untyped: FastAPI's own 422 carries a LIST as ``detail`` and would break the
|
|
546
|
+
contract's client-side ``^labels\\.[a-z0-9_]+: `` parse rule.
|
|
547
|
+
|
|
548
|
+
Does NOT check the cap. The cap is a property of the RESULT of a write, not
|
|
549
|
+
of an input, and checking it here would reject the add-delta that swaps a
|
|
550
|
+
label at 8. See ``merge_label_keys``.
|
|
551
|
+
"""
|
|
552
|
+
if raw is None:
|
|
553
|
+
return []
|
|
554
|
+
if not isinstance(raw, (list, tuple)):
|
|
555
|
+
raise LabelError.invalid_type()
|
|
556
|
+
out: list[str] = []
|
|
557
|
+
for item in raw:
|
|
558
|
+
if not isinstance(item, str):
|
|
559
|
+
raise LabelError.invalid_type()
|
|
560
|
+
slug = normalize_label(item)
|
|
561
|
+
if slug not in out:
|
|
562
|
+
out.append(slug)
|
|
563
|
+
return out
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def merge_label_keys(
|
|
567
|
+
current: list[str],
|
|
568
|
+
*,
|
|
569
|
+
replace: object = _UNSET,
|
|
570
|
+
add: object = None,
|
|
571
|
+
remove: object = None,
|
|
572
|
+
) -> list[str]:
|
|
573
|
+
"""Apply the three label keys in their ruled precedence — replace, then add,
|
|
574
|
+
then remove — and enforce the cap ONCE, on the final set.
|
|
575
|
+
|
|
576
|
+
Three behaviours an implementer cannot infer from the endpoint list, and all
|
|
577
|
+
three have a named test:
|
|
578
|
+
|
|
579
|
+
* The cap is checked AFTER dedupe, so re-adding a label an 8-label item
|
|
580
|
+
already carries is a no-op, not a 400. The assistant retries commands,
|
|
581
|
+
and a rule that fails only on the second run is the worst kind.
|
|
582
|
+
* The cap is checked AFTER ``remove`` is applied, so
|
|
583
|
+
``{add_labels:[x], remove_labels:[y]}`` on an 8-label item succeeds.
|
|
584
|
+
Checking between add and remove would refuse a legal swap.
|
|
585
|
+
* ``got`` in the error sentence is the size of the WOULD-BE result, not
|
|
586
|
+
the length of the payload. The sentence describes the to-do, which is
|
|
587
|
+
what the owner is looking at.
|
|
588
|
+
|
|
589
|
+
Raises ``labels.cap_exceeded`` and applies nothing — the caller has not saved
|
|
590
|
+
yet, so a rejected write leaves the plate byte-identical.
|
|
591
|
+
"""
|
|
592
|
+
result = list(current) if replace is _UNSET else normalize_labels(replace)
|
|
593
|
+
for slug in normalize_labels(add):
|
|
594
|
+
if slug not in result:
|
|
595
|
+
result.append(slug)
|
|
596
|
+
removing = set(normalize_labels(remove))
|
|
597
|
+
if removing:
|
|
598
|
+
result = [s for s in result if s not in removing]
|
|
599
|
+
if len(result) > MAX_LABELS_PER_TODO:
|
|
600
|
+
raise LabelError.cap_exceeded(len(result))
|
|
601
|
+
return result
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class AttachmentError(ValueError):
|
|
605
|
+
"""An attachment failure that already knows its HTTP status.
|
|
606
|
+
|
|
607
|
+
Subclasses ``ValueError`` on purpose, exactly as ``LabelError`` does: it
|
|
608
|
+
rides the ``except ValueError`` handlers already in
|
|
609
|
+
``server/routes/desk_todos.py`` if a caller forgets the narrower clause,
|
|
610
|
+
degrading to a 400 with the right sentence rather than a 500.
|
|
611
|
+
|
|
612
|
+
``str(e)`` is EXACTLY the wire ``detail``: a plain string, never a list,
|
|
613
|
+
never a code prefix. Unlike ``LabelError`` there is no machine-readable
|
|
614
|
+
code, because nothing parses these — the contract froze the SENTENCES, and
|
|
615
|
+
the client renders them verbatim.
|
|
616
|
+
|
|
617
|
+
This module may not raise ``HTTPException``: ``models/`` ships in the runner
|
|
618
|
+
wheel and fastapi is not a runner dependency. Every limit it enforces
|
|
619
|
+
therefore arrives as a parameter rather than being read from the
|
|
620
|
+
environment here.
|
|
621
|
+
"""
|
|
622
|
+
|
|
623
|
+
def __init__(self, status: int, sentence: str) -> None:
|
|
624
|
+
super().__init__(sentence)
|
|
625
|
+
self.status = status
|
|
626
|
+
self.sentence = sentence
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class DeskTodoAttachment(BaseModel):
|
|
630
|
+
"""One real, stored file on a to-do. The bytes live on disk at
|
|
631
|
+
``{data_dir}/desk-todo-files/<owner>/<todo_id>/<id>``; this row is the ONLY
|
|
632
|
+
thing that names them, and a blob the plate does not name is garbage by
|
|
633
|
+
definition.
|
|
634
|
+
|
|
635
|
+
Written by the owner, on save, and nobody else — ``manigent todo
|
|
636
|
+
publish`` carries no attachments, so an agent can never park bytes on its
|
|
637
|
+
owner's plate.
|
|
638
|
+
|
|
639
|
+
``name`` is the original filename, DISPLAY ONLY. It never becomes a path
|
|
640
|
+
segment: the file on disk is named by ``id``, which is why traversal is
|
|
641
|
+
structurally impossible here rather than merely checked for.
|
|
642
|
+
"""
|
|
643
|
+
|
|
644
|
+
id: str
|
|
645
|
+
name: str
|
|
646
|
+
size: int
|
|
647
|
+
content_type: str
|
|
648
|
+
sha256: str
|
|
649
|
+
created_at: str
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
class DeskTodo(BaseModel):
|
|
653
|
+
"""A single item on the manager's plate.
|
|
654
|
+
|
|
655
|
+
Only fields the owner can edit in the web UI live here (plus ids,
|
|
656
|
+
timestamps and the hidden ``by_agent_id``). Rows written by older servers
|
|
657
|
+
may carry keys that have since been removed — ``origin``, ``source``,
|
|
658
|
+
``due``, ``by_agent_name``, ``suggest_agent_*``, ``context``, ``files``,
|
|
659
|
+
``done_steps``, ``available``, ``linked``. They are not declared, so
|
|
660
|
+
pydantic's default ``extra="ignore"`` drops them on load and the next
|
|
661
|
+
``_save`` persists the slim shape — the same strategy
|
|
662
|
+
``_archived_read_shim`` describes for ``status``. No migration pass.
|
|
663
|
+
"""
|
|
664
|
+
|
|
665
|
+
id: str
|
|
666
|
+
owner_user_id: str
|
|
667
|
+
text: str
|
|
668
|
+
# The owner filed it away. A DISPOSAL, never a lifecycle state — nothing
|
|
669
|
+
# project-driven writes it and nothing reads it as "the work finished".
|
|
670
|
+
# A bool rather than a renamed two-value string on purpose: a string is
|
|
671
|
+
# what lets a third value get added later and re-invent lifecycle state,
|
|
672
|
+
# which is the exact confusion this field exists to end.
|
|
673
|
+
archived: bool = False
|
|
674
|
+
created_at: str
|
|
675
|
+
updated_at: str
|
|
676
|
+
|
|
677
|
+
# Ids of the projects and DM threads this to-do spawned. ONE id space, so a
|
|
678
|
+
# DM-thread id and a regular project id are indistinguishable here — which
|
|
679
|
+
# is required, because the desk's **command** button spawns a DM thread and
|
|
680
|
+
# that is the feature's most common creation path.
|
|
681
|
+
#
|
|
682
|
+
# A REFERENCE, not a foreign key: an id whose project was deleted stays in
|
|
683
|
+
# the list, is legal, and simply stops contributing to the derived state.
|
|
684
|
+
# This module stores the ids and knows NOTHING about projects — exactly as
|
|
685
|
+
# it stores label slugs and knows nothing about the registry. The
|
|
686
|
+
# derivation lives in the third module, ``models/desk_todo_link.py``, which
|
|
687
|
+
# imports this one and is never imported by it.
|
|
688
|
+
project_ids: list[str] = Field(default_factory=list)
|
|
689
|
+
|
|
690
|
+
# Bare normalized slugs — the owner's GTD contexts and states. Presentation
|
|
691
|
+
# (display name, colour, which axis) lives in the SEPARATE registry document
|
|
692
|
+
# (``models/desk_label.py``); this list is NOT a foreign key into it. A slug
|
|
693
|
+
# with no registry row is valid and renders unregistered, which is the whole
|
|
694
|
+
# reason the plate stays readable with the registry gone. Default-empty, so
|
|
695
|
+
# every plate written before labels existed loads unchanged and there is no
|
|
696
|
+
# migration script.
|
|
697
|
+
labels: list[str] = Field(default_factory=list)
|
|
698
|
+
|
|
699
|
+
draft_prompt: str | None = None
|
|
700
|
+
# Recipient of the draft — picked in the take-over composer, or resolved
|
|
701
|
+
# from an agent publish's ``--suggest``. Both id and name are persisted so
|
|
702
|
+
# the plate pill still labels correctly after a rename/removal; the
|
|
703
|
+
# frontend re-resolves against the live roster and falls back if the agent
|
|
704
|
+
# is gone. A suggested name that resolved to nobody stores the name with a
|
|
705
|
+
# null id. Null until set.
|
|
706
|
+
draft_recipient_id: str | None = None
|
|
707
|
+
draft_recipient_name: str | None = None
|
|
708
|
+
# Real stored bytes the OWNER parked on this item. Always a list, never
|
|
709
|
+
# null — ``default_factory`` means every plate row written before
|
|
710
|
+
# attachments existed validates unchanged, so there is no migration.
|
|
711
|
+
attachments: list[DeskTodoAttachment] = Field(default_factory=list)
|
|
712
|
+
|
|
713
|
+
# True once the item carries a ready draft: set when the manager saves one
|
|
714
|
+
# in the take-over, or when an agent publishes with a draft prompt.
|
|
715
|
+
drafted: bool = False
|
|
716
|
+
|
|
717
|
+
# Hidden provenance, never displayed: the agent that published this item,
|
|
718
|
+
# kept ONLY so ``DELETE /me/desk/todos/{id}`` can let the publishing agent
|
|
719
|
+
# retract its own item. Null for anything the owner captured.
|
|
720
|
+
by_agent_id: str | None = None
|
|
721
|
+
|
|
722
|
+
@model_validator(mode="before")
|
|
723
|
+
@classmethod
|
|
724
|
+
def _archived_read_shim(cls, data: Any) -> Any:
|
|
725
|
+
"""Read a plate written in the old ``status: "open" | "done"`` shape.
|
|
726
|
+
|
|
727
|
+
NOT compat-nostalgia, and not optional. Every plate on disk today
|
|
728
|
+
carries ``status``; without this line every to-do the owner ever filed
|
|
729
|
+
away springs back onto their open plate on first load, which is exactly
|
|
730
|
+
what AC-4.3 forbids::
|
|
731
|
+
|
|
732
|
+
legacy `status` present AND `archived` absent -> archived = (status == "done")
|
|
733
|
+
`archived` present -> it wins, always
|
|
734
|
+
neither -> False (the field default)
|
|
735
|
+
|
|
736
|
+
``status`` is deliberately NOT redeclared as a field, so pydantic's
|
|
737
|
+
default ``extra="ignore"`` drops it once this validator has read it, and
|
|
738
|
+
the owner's next ORDINARY save — a rename, a label edit, an archive
|
|
739
|
+
toggle — persists the new shape as a side effect of a write they asked
|
|
740
|
+
for. There is no rewrite pass, no bulk write over anyone's plate, and no
|
|
741
|
+
schema-version bump: the plate is one JSON document per owner and
|
|
742
|
+
``_save`` already rewrites the whole array, so the re-shape costs
|
|
743
|
+
nothing and touches exactly the one owner who wrote.
|
|
744
|
+
|
|
745
|
+
The shim is also what makes the change loadable in both directions
|
|
746
|
+
during a rollout: a row already carrying ``archived`` is untouched here.
|
|
747
|
+
"""
|
|
748
|
+
if not isinstance(data, dict):
|
|
749
|
+
return data
|
|
750
|
+
if "archived" in data:
|
|
751
|
+
return data
|
|
752
|
+
if "status" in data:
|
|
753
|
+
data = dict(data)
|
|
754
|
+
data["archived"] = data.get("status") == "done"
|
|
755
|
+
return data
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def gen_id() -> str:
|
|
759
|
+
return "t-" + secrets.token_hex(6)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _now() -> str:
|
|
763
|
+
return datetime.now(UTC).isoformat()
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _path(data_dir: Path, owner_user_id: str) -> Path:
|
|
767
|
+
return Path(data_dir) / TODOS_DIR / f"{owner_user_id}.json"
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _seed_rows(owner_user_id: str) -> list[DeskTodo]:
|
|
771
|
+
"""The starter plate, materialized in memory for one owner.
|
|
772
|
+
|
|
773
|
+
The timestamps are the fixed epoch constant rather than the clock. The seed
|
|
774
|
+
is materialized on every read of an unwritten plate, so minting ``now()``
|
|
775
|
+
here would make two consecutive GETs return different ``created_at`` values
|
|
776
|
+
for the same row — churn a client can legitimately notice, in a field
|
|
777
|
+
nothing needs. It is also simply more truthful: the owner did not capture
|
|
778
|
+
this. Rows keep the constant when the first write materializes them to
|
|
779
|
+
disk; anything captured or published afterwards is stamped with the real
|
|
780
|
+
clock and, being prepended, sorts ahead of them.
|
|
781
|
+
"""
|
|
782
|
+
return [
|
|
783
|
+
DeskTodo(
|
|
784
|
+
owner_user_id=owner_user_id,
|
|
785
|
+
created_at=SEED_TIMESTAMP,
|
|
786
|
+
updated_at=SEED_TIMESTAMP,
|
|
787
|
+
**row,
|
|
788
|
+
)
|
|
789
|
+
for row in SEED
|
|
790
|
+
]
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _load(data_dir: Path, owner_user_id: str) -> list[DeskTodo]:
|
|
794
|
+
"""Every read and every write starts here, so "missing" vs "empty" vs
|
|
795
|
+
"unreadable" is decided in exactly one place.
|
|
796
|
+
|
|
797
|
+
NO FILE -> the SEED, in memory. A file that exists but holds no usable rows
|
|
798
|
+
-> ZERO rows, never the seed as a repair: an owner who cleared their last
|
|
799
|
+
to-do gets an empty plate and keeps it, and a corrupt document is not
|
|
800
|
+
written over with starter content.
|
|
801
|
+
"""
|
|
802
|
+
path = _path(data_dir, owner_user_id)
|
|
803
|
+
if not path.exists():
|
|
804
|
+
return _seed_rows(owner_user_id)
|
|
805
|
+
raw = FileUtil.read(path, "json")
|
|
806
|
+
if not isinstance(raw, list):
|
|
807
|
+
return []
|
|
808
|
+
out: list[DeskTodo] = []
|
|
809
|
+
for row in raw:
|
|
810
|
+
if not isinstance(row, dict):
|
|
811
|
+
continue
|
|
812
|
+
try:
|
|
813
|
+
out.append(DeskTodo.model_validate(row))
|
|
814
|
+
except Exception:
|
|
815
|
+
continue
|
|
816
|
+
return out
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _save(data_dir: Path, owner_user_id: str, todos: list[DeskTodo]) -> None:
|
|
820
|
+
"""Every write ends here. The FIRST one — no plate file yet — is the moment
|
|
821
|
+
the in-memory seed becomes real, so it also records every ``SEED`` id as
|
|
822
|
+
given. All of them, not just the ones still in ``todos``: the owner was
|
|
823
|
+
shown the whole seed, and a first write that deletes a starter row must
|
|
824
|
+
still count it as given or the backfill would hand it straight back."""
|
|
825
|
+
path = _path(data_dir, owner_user_id)
|
|
826
|
+
first_save = not path.exists()
|
|
827
|
+
FileUtil.write(path, [t.model_dump() for t in todos], "json")
|
|
828
|
+
if first_save:
|
|
829
|
+
record_given_seed_ids(data_dir, owner_user_id, (row["id"] for row in SEED))
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
# --- the seeded-items record ------------------------------------------------
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _seed_state_path(data_dir: Path, owner_user_id: str) -> Path:
|
|
836
|
+
return Path(data_dir) / SEED_STATE_DIR / f"{owner_user_id}.json"
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def given_seed_ids(data_dir: Path, owner_user_id: str) -> set[str]:
|
|
840
|
+
"""Seed ids this account has ever been given. A missing or unreadable
|
|
841
|
+
record reads as the empty set."""
|
|
842
|
+
raw = FileUtil.read(_seed_state_path(data_dir, owner_user_id), "json")
|
|
843
|
+
given = raw.get("given") if isinstance(raw, dict) else None
|
|
844
|
+
if not isinstance(given, list):
|
|
845
|
+
return set()
|
|
846
|
+
return {g for g in given if isinstance(g, str)}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def record_given_seed_ids(
|
|
850
|
+
data_dir: Path, owner_user_id: str, ids: Iterable[str],
|
|
851
|
+
) -> None:
|
|
852
|
+
"""Union ``ids`` into the record. Never removes an entry, and skips the
|
|
853
|
+
write when nothing is new."""
|
|
854
|
+
have = given_seed_ids(data_dir, owner_user_id)
|
|
855
|
+
merged = have | set(ids)
|
|
856
|
+
if merged == have:
|
|
857
|
+
return
|
|
858
|
+
FileUtil.write(
|
|
859
|
+
_seed_state_path(data_dir, owner_user_id),
|
|
860
|
+
{"given": sorted(merged)},
|
|
861
|
+
"json",
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
# --- the one-time starter backfill ------------------------------------------
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
class BackfillReport(BaseModel):
|
|
869
|
+
"""Per-account outcome of ``backfill_starter_todos``, by user id.
|
|
870
|
+
|
|
871
|
+
``would_add`` and ``added`` name the same accounts; ``added`` is filled
|
|
872
|
+
only when the run applied. ``skipped_unreadable`` is a plate file that
|
|
873
|
+
exists but is not a JSON list — it is left alone rather than written over,
|
|
874
|
+
and unlike a missing plate the new-account seed will NOT reach it."""
|
|
875
|
+
|
|
876
|
+
would_add: list[str] = Field(default_factory=list)
|
|
877
|
+
added: list[str] = Field(default_factory=list)
|
|
878
|
+
skipped_already_given: list[str] = Field(default_factory=list)
|
|
879
|
+
skipped_no_plate: list[str] = Field(default_factory=list)
|
|
880
|
+
skipped_unreadable: list[str] = Field(default_factory=list)
|
|
881
|
+
|
|
882
|
+
@property
|
|
883
|
+
def skipped(self) -> int:
|
|
884
|
+
return (
|
|
885
|
+
len(self.skipped_already_given)
|
|
886
|
+
+ len(self.skipped_no_plate)
|
|
887
|
+
+ len(self.skipped_unreadable)
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
# Starter rows added after launch, each given once to every existing plate by
|
|
892
|
+
# ``backfill_starter_todos``. Each is decided on its own: an owner who deleted
|
|
893
|
+
# one still gets the next.
|
|
894
|
+
BACKFILL_SEED_IDS: tuple[str, ...] = (THINKING_PARTNER_SEED_ID, CONVENE_SEED_ID)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _seed_row(seed_id: str, owner_user_id: str) -> dict[str, Any]:
|
|
898
|
+
(row,) = (r for r in SEED if r["id"] == seed_id)
|
|
899
|
+
return DeskTodo(
|
|
900
|
+
owner_user_id=owner_user_id,
|
|
901
|
+
created_at=SEED_TIMESTAMP,
|
|
902
|
+
updated_at=SEED_TIMESTAMP,
|
|
903
|
+
**row,
|
|
904
|
+
).model_dump()
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def backfill_starter_todos(
|
|
908
|
+
data_dir: Path, user_ids: Iterable[str], *, apply: bool,
|
|
909
|
+
) -> BackfillReport:
|
|
910
|
+
"""Give every existing saved plate each ``BACKFILL_SEED_IDS`` row, once.
|
|
911
|
+
|
|
912
|
+
OFFLINE ONLY: plate writes are serialized by the in-process ``_lock``, which
|
|
913
|
+
this cannot share with a running server — the CLI wrapper refuses
|
|
914
|
+
``apply=True`` while the server is up. Per account:
|
|
915
|
+
|
|
916
|
+
- no plate file -> skipped_no_plate (the new seed covers it)
|
|
917
|
+
- plate not a JSON list -> skipped_unreadable (never written over)
|
|
918
|
+
|
|
919
|
+
and then per starter row, each decided on its own:
|
|
920
|
+
|
|
921
|
+
- id in the given record -> left out (deleted stays deleted)
|
|
922
|
+
- id already on the plate-> left out (and recorded, on apply)
|
|
923
|
+
- otherwise -> inserted right after the nearest earlier
|
|
924
|
+
``SEED`` row still on the plate, or at the top
|
|
925
|
+
if none is; on apply the record gains it and
|
|
926
|
+
the onboarding id (a pre-feature plate was
|
|
927
|
+
given onboarding at its first save)
|
|
928
|
+
|
|
929
|
+
The account lands in would_add (and added, on apply) if any row went in,
|
|
930
|
+
otherwise in skipped_already_given. The plate is edited as raw JSON rows so
|
|
931
|
+
every other row is written back exactly as it was read, including rows the
|
|
932
|
+
model would reject.
|
|
933
|
+
"""
|
|
934
|
+
seed_order = [str(r["id"]) for r in SEED]
|
|
935
|
+
report = BackfillReport()
|
|
936
|
+
for uid in user_ids:
|
|
937
|
+
path = _path(data_dir, uid)
|
|
938
|
+
if not path.exists():
|
|
939
|
+
report.skipped_no_plate.append(uid)
|
|
940
|
+
continue
|
|
941
|
+
raw = FileUtil.read(path, "json")
|
|
942
|
+
if not isinstance(raw, list):
|
|
943
|
+
report.skipped_unreadable.append(uid)
|
|
944
|
+
continue
|
|
945
|
+
given = given_seed_ids(data_dir, uid)
|
|
946
|
+
inserted: list[str] = []
|
|
947
|
+
already_there: list[str] = []
|
|
948
|
+
for seed_id in BACKFILL_SEED_IDS:
|
|
949
|
+
if seed_id in given:
|
|
950
|
+
continue
|
|
951
|
+
ids = [r.get("id") if isinstance(r, dict) else None for r in raw]
|
|
952
|
+
if seed_id in ids:
|
|
953
|
+
already_there.append(seed_id)
|
|
954
|
+
continue
|
|
955
|
+
earlier = seed_order[: seed_order.index(seed_id)]
|
|
956
|
+
at = 0
|
|
957
|
+
for prev in reversed(earlier):
|
|
958
|
+
if prev in ids:
|
|
959
|
+
at = ids.index(prev) + 1
|
|
960
|
+
break
|
|
961
|
+
raw.insert(at, _seed_row(seed_id, uid))
|
|
962
|
+
inserted.append(seed_id)
|
|
963
|
+
if apply and already_there:
|
|
964
|
+
record_given_seed_ids(data_dir, uid, already_there)
|
|
965
|
+
if not inserted:
|
|
966
|
+
report.skipped_already_given.append(uid)
|
|
967
|
+
continue
|
|
968
|
+
report.would_add.append(uid)
|
|
969
|
+
if apply:
|
|
970
|
+
FileUtil.write(path, raw, "json")
|
|
971
|
+
record_given_seed_ids(data_dir, uid, [ONBOARDING_SEED_ID, *inserted])
|
|
972
|
+
report.added.append(uid)
|
|
973
|
+
return report
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
# --- the attachment blob store ----------------------------------------------
|
|
977
|
+
# The plate JSON is the ONLY source of truth. A blob it does not name is
|
|
978
|
+
# garbage by definition — which is what makes the orphan sweep below one
|
|
979
|
+
# ``listdir`` and a set difference, and what makes the ordering rule in
|
|
980
|
+
# ``patch_todo`` sufficient rather than merely hopeful.
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def gen_attachment_id() -> str:
|
|
984
|
+
"""``"a-" + token_hex(6)``. Sibling of ``gen_id``'s ``"t-"`` prefix, so an
|
|
985
|
+
id is self-describing in a log line and a to-do id can never be mistaken
|
|
986
|
+
for an attachment id in a URL."""
|
|
987
|
+
return "a-" + secrets.token_hex(6)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def normalize_attachment_name(raw: str) -> str:
|
|
991
|
+
"""The stored display ``name``: strip control characters and path
|
|
992
|
+
separators, drop leading dots, collapse whitespace, bound the length, and
|
|
993
|
+
fall back to ``"file"`` if nothing survives.
|
|
994
|
+
|
|
995
|
+
Belt and braces over the route's ``_validate_filename``, which rejects
|
|
996
|
+
traversal outright. This one exists for a different reason: the name is
|
|
997
|
+
echoed back in a ``Content-Disposition`` header by the download route, so a
|
|
998
|
+
name carrying a quote, a CR or a NUL could inject a header parameter. It is
|
|
999
|
+
scrubbed once, on the way in, so every later reader of ``name`` — header,
|
|
1000
|
+
UI, log — gets an already-safe string.
|
|
1001
|
+
"""
|
|
1002
|
+
text = raw or ""
|
|
1003
|
+
# Path separators first, so "a/b" collapses to two words rather than "ab".
|
|
1004
|
+
for sep in ("/", "\\"):
|
|
1005
|
+
text = text.replace(sep, " ")
|
|
1006
|
+
text = "".join(ch for ch in text if unicodedata.category(ch)[0] != "C")
|
|
1007
|
+
text = re.sub(r"\s+", " ", text).strip().lstrip(".").strip()
|
|
1008
|
+
text = text[:MAX_ATTACHMENT_NAME_LEN].strip()
|
|
1009
|
+
return text or "file"
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def guess_content_type(name: str) -> str:
|
|
1013
|
+
"""The stored ``content_type``, derived from the NORMALIZED display name.
|
|
1014
|
+
|
|
1015
|
+
Derived server-side rather than trusted from the client — the inbound entry
|
|
1016
|
+
is exactly ``{filename, content_b64}`` and carries no type — so a caller
|
|
1017
|
+
cannot mislabel bytes into a browser-rendered type. Same
|
|
1018
|
+
``mimetypes.guess_type`` + ``application/octet-stream`` fallback
|
|
1019
|
+
``server/routes/files.py`` already applies on download. The type is
|
|
1020
|
+
DESCRIBED, never gated: there is no allowlist on this path, because a to-do
|
|
1021
|
+
exists to be dispatched and the message path that receives it has none
|
|
1022
|
+
either — a file you can send in a DM must not be one you cannot park.
|
|
1023
|
+
"""
|
|
1024
|
+
return mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _owner_files_dir(data_dir: Path, owner_user_id: str) -> Path:
|
|
1028
|
+
"""``{data_dir}/desk-todo-files/<owner_user_id>/`` — the sweep's unit."""
|
|
1029
|
+
return Path(data_dir) / TODO_FILES_DIR / owner_user_id
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def todo_files_dir(data_dir: Path, owner_user_id: str, todo_id: str) -> Path:
|
|
1033
|
+
"""``…/<todo_id>/`` — the delete's unit. Both segments are server-minted."""
|
|
1034
|
+
return _owner_files_dir(data_dir, owner_user_id) / todo_id
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def read_attachment_blob(
|
|
1038
|
+
data_dir: Path, owner_user_id: str, todo_id: str, attachment_id: str
|
|
1039
|
+
) -> bytes | None:
|
|
1040
|
+
"""The stored bytes, or ``None`` when the file is missing.
|
|
1041
|
+
|
|
1042
|
+
``None`` is not an error here. A plate row naming vanished bytes is the
|
|
1043
|
+
deliberately-surfaced case: the route turns it into a 404 that renders as a
|
|
1044
|
+
broken chip, rather than a 500 or a silently-empty download.
|
|
1045
|
+
"""
|
|
1046
|
+
return FileUtil.read(
|
|
1047
|
+
todo_files_dir(data_dir, owner_user_id, todo_id) / attachment_id, "bytes"
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def write_attachment_blob(
|
|
1052
|
+
data_dir: Path, owner_user_id: str, todo_id: str, attachment_id: str, raw: bytes
|
|
1053
|
+
) -> None:
|
|
1054
|
+
"""Write one blob, through ``FileUtil.write(..., "bytes")`` so it gets the
|
|
1055
|
+
same atomic write + parent mkdir every other document on this desk gets.
|
|
1056
|
+
|
|
1057
|
+
Deliberately a module-level function rather than an inline ``FileUtil``
|
|
1058
|
+
call: this is the seam a test patches to prove that a failed blob write
|
|
1059
|
+
leaves the plate byte-identical. Monkeypatching ``FileUtil.write`` globally
|
|
1060
|
+
would break the plate write too and prove nothing.
|
|
1061
|
+
"""
|
|
1062
|
+
FileUtil.write(
|
|
1063
|
+
todo_files_dir(data_dir, owner_user_id, todo_id) / attachment_id, raw, "bytes"
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _remove_dir_if_empty(path: Path) -> None:
|
|
1068
|
+
try:
|
|
1069
|
+
if path.is_dir() and not any(path.iterdir()):
|
|
1070
|
+
path.rmdir()
|
|
1071
|
+
except OSError as e: # pragma: no cover - filesystem edge
|
|
1072
|
+
logger.warning(f"Could not remove empty attachment dir {path}: {e}")
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _write_new_blobs(
|
|
1076
|
+
data_dir: Path,
|
|
1077
|
+
owner_user_id: str,
|
|
1078
|
+
todo_id: str,
|
|
1079
|
+
minted: list[tuple[str, bytes]],
|
|
1080
|
+
) -> None:
|
|
1081
|
+
"""Write every newly-minted blob, BEFORE the plate row that will name them.
|
|
1082
|
+
|
|
1083
|
+
On ANY exception: unlink every blob THIS call already wrote, remove the
|
|
1084
|
+
to-do's directory if it is now empty, and re-raise unchanged. Without the
|
|
1085
|
+
rollback the plate would still be byte-identical (we never reached
|
|
1086
|
+
``_save``) but a directory would exist on disk. The sweep would eventually
|
|
1087
|
+
take it; "eventually" is not the guarantee worth having when the whole
|
|
1088
|
+
point of writing bytes first is that a failure leaves nothing behind.
|
|
1089
|
+
"""
|
|
1090
|
+
written: list[Path] = []
|
|
1091
|
+
try:
|
|
1092
|
+
for attachment_id, raw in minted:
|
|
1093
|
+
write_attachment_blob(data_dir, owner_user_id, todo_id, attachment_id, raw)
|
|
1094
|
+
written.append(
|
|
1095
|
+
todo_files_dir(data_dir, owner_user_id, todo_id) / attachment_id
|
|
1096
|
+
)
|
|
1097
|
+
except BaseException:
|
|
1098
|
+
for path in written:
|
|
1099
|
+
FileUtil.delete(path)
|
|
1100
|
+
_remove_dir_if_empty(todo_files_dir(data_dir, owner_user_id, todo_id))
|
|
1101
|
+
raise
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
# --- the two-document escape hatch ------------------------------------------
|
|
1105
|
+
# ``desk_label.delete_label`` / ``merge_label`` mutate BOTH the plate and the
|
|
1106
|
+
# registry and must hold the plate lock across both writes, so they cannot call
|
|
1107
|
+
# ``patch_todo`` / ``reorder_todos`` — those take this same non-reentrant lock
|
|
1108
|
+
# and would deadlock. They get these three instead.
|
|
1109
|
+
#
|
|
1110
|
+
# LOCK ORDER, and it is the only rule preventing a deadlock here:
|
|
1111
|
+
# plate_lock (OUTER) -> desk_label._registry_lock (INNER). NEVER reversed.
|
|
1112
|
+
# Nothing in the type system enforces that, which is why it is written on both
|
|
1113
|
+
# modules.
|
|
1114
|
+
plate_lock = _lock
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def load_plate(data_dir: Path, owner_user_id: str) -> list[DeskTodo]:
|
|
1118
|
+
"""The plate, unlocked. Callers MUST already hold ``plate_lock``."""
|
|
1119
|
+
return _load(data_dir, owner_user_id)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def save_plate(data_dir: Path, owner_user_id: str, todos: list[DeskTodo]) -> None:
|
|
1123
|
+
"""Write the plate, unlocked. Callers MUST already hold ``plate_lock``."""
|
|
1124
|
+
_save(data_dir, owner_user_id, todos)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def sweep_orphan_todo_files(data_dir: Path, owner_user_id: str) -> int:
|
|
1128
|
+
"""Delete every blob the plate does not name, for one owner. Returns the
|
|
1129
|
+
number of paths removed.
|
|
1130
|
+
|
|
1131
|
+
Two levels, because there are two ways to orphan bytes:
|
|
1132
|
+
|
|
1133
|
+
* an entire ``<todo_id>/`` directory whose to-do is gone from the plate,
|
|
1134
|
+
and
|
|
1135
|
+
* a single ``<attachment_id>`` file inside a LIVE to-do whose descriptor
|
|
1136
|
+
was reconciled away — which is also what a crash between the blob write
|
|
1137
|
+
and the plate write leaves behind, and is the real orphan risk that
|
|
1138
|
+
deleting a to-do never exercises.
|
|
1139
|
+
|
|
1140
|
+
CALLERS MUST ALREADY HOLD ``plate_lock`` — the same contract, for the same
|
|
1141
|
+
reason, that ``load_plate`` / ``save_plate`` carry. Both callers
|
|
1142
|
+
(``delete_todo``, ``patch_todo``) are already inside it, and taking the
|
|
1143
|
+
non-reentrant lock here would deadlock. It reads through ``load_plate``,
|
|
1144
|
+
never ``_load``-plus-lock.
|
|
1145
|
+
|
|
1146
|
+
Deliberately NOT scheduled. There is no cron and no background job, because
|
|
1147
|
+
a sweep that runs only from a scheduler is a sweep that silently is not
|
|
1148
|
+
running in someone's deployment. It can afford to be synchronous precisely
|
|
1149
|
+
because the plate is the only source of truth: a blob it does not name is
|
|
1150
|
+
garbage by definition, so this is one ``listdir`` of a small per-owner
|
|
1151
|
+
directory diffed against a list already in memory.
|
|
1152
|
+
|
|
1153
|
+
Best-effort by contract: a filesystem error is logged, never raised. The
|
|
1154
|
+
caller's write has already succeeded, and failing it on a bookkeeping side
|
|
1155
|
+
effect would leave the user with an item they cannot remove.
|
|
1156
|
+
"""
|
|
1157
|
+
root = _owner_files_dir(data_dir, owner_user_id)
|
|
1158
|
+
if not root.is_dir():
|
|
1159
|
+
return 0
|
|
1160
|
+
referenced = {
|
|
1161
|
+
t.id: {a.id for a in t.attachments}
|
|
1162
|
+
for t in load_plate(data_dir, owner_user_id)
|
|
1163
|
+
}
|
|
1164
|
+
removed = 0
|
|
1165
|
+
try:
|
|
1166
|
+
entries = list(root.iterdir())
|
|
1167
|
+
except OSError as e: # pragma: no cover - filesystem edge
|
|
1168
|
+
logger.warning(f"Orphan sweep could not list {root}: {e}")
|
|
1169
|
+
return 0
|
|
1170
|
+
for entry in entries:
|
|
1171
|
+
try:
|
|
1172
|
+
if entry.name not in referenced:
|
|
1173
|
+
# The to-do is gone from the plate; everything under it is
|
|
1174
|
+
# unreachable by definition.
|
|
1175
|
+
if entry.is_dir():
|
|
1176
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
1177
|
+
else:
|
|
1178
|
+
entry.unlink()
|
|
1179
|
+
removed += 1
|
|
1180
|
+
continue
|
|
1181
|
+
if not entry.is_dir():
|
|
1182
|
+
continue
|
|
1183
|
+
keep = referenced[entry.name]
|
|
1184
|
+
for blob in entry.iterdir():
|
|
1185
|
+
if blob.name not in keep:
|
|
1186
|
+
if blob.is_dir():
|
|
1187
|
+
shutil.rmtree(blob, ignore_errors=True)
|
|
1188
|
+
else:
|
|
1189
|
+
blob.unlink()
|
|
1190
|
+
removed += 1
|
|
1191
|
+
_remove_dir_if_empty(entry)
|
|
1192
|
+
except OSError as e: # pragma: no cover - filesystem edge
|
|
1193
|
+
logger.warning(f"Orphan sweep could not remove {entry}: {e}")
|
|
1194
|
+
return removed
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def list_todos(data_dir: Path, owner_user_id: str) -> list[DeskTodo]:
|
|
1198
|
+
"""Return the owner's plate in stored (manager-controlled) order."""
|
|
1199
|
+
return _load(data_dir, owner_user_id)
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def get_todo(data_dir: Path, owner_user_id: str, todo_id: str) -> DeskTodo | None:
|
|
1203
|
+
for t in _load(data_dir, owner_user_id):
|
|
1204
|
+
if t.id == todo_id:
|
|
1205
|
+
return t
|
|
1206
|
+
return None
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
async def add_todo(
|
|
1210
|
+
data_dir: Path,
|
|
1211
|
+
owner_user_id: str,
|
|
1212
|
+
text: str,
|
|
1213
|
+
*,
|
|
1214
|
+
labels: object = None,
|
|
1215
|
+
) -> DeskTodo:
|
|
1216
|
+
"""Capture an owner task; prepended to the plate (newest first).
|
|
1217
|
+
|
|
1218
|
+
``labels`` is normalized and capped BEFORE the row is built, so a rejected
|
|
1219
|
+
capture never reaches the document."""
|
|
1220
|
+
text = (text or "").strip()
|
|
1221
|
+
if not text:
|
|
1222
|
+
raise ValueError("Task text cannot be empty")
|
|
1223
|
+
slugs = merge_label_keys([], replace=labels)
|
|
1224
|
+
async with _lock:
|
|
1225
|
+
todos = _load(data_dir, owner_user_id)
|
|
1226
|
+
now = _now()
|
|
1227
|
+
todo = DeskTodo(
|
|
1228
|
+
id=gen_id(),
|
|
1229
|
+
owner_user_id=owner_user_id,
|
|
1230
|
+
text=text,
|
|
1231
|
+
created_at=now,
|
|
1232
|
+
updated_at=now,
|
|
1233
|
+
labels=slugs,
|
|
1234
|
+
)
|
|
1235
|
+
todos.insert(0, todo)
|
|
1236
|
+
_save(data_dir, owner_user_id, todos)
|
|
1237
|
+
return todo
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
async def publish_agent_todo(
|
|
1241
|
+
data_dir: Path,
|
|
1242
|
+
owner_user_id: str,
|
|
1243
|
+
*,
|
|
1244
|
+
by_agent_id: str,
|
|
1245
|
+
text: str,
|
|
1246
|
+
draft_prompt: str | None = None,
|
|
1247
|
+
draft_recipient_id: str | None = None,
|
|
1248
|
+
draft_recipient_name: str | None = None,
|
|
1249
|
+
labels: object = None,
|
|
1250
|
+
) -> DeskTodo:
|
|
1251
|
+
"""Push an agent-published task onto the owner's plate (prepended).
|
|
1252
|
+
|
|
1253
|
+
The suggested prompt and recipient land in the same ``draft_*`` fields the
|
|
1254
|
+
owner's take-over writes, so what the agent suggests is exactly what the
|
|
1255
|
+
owner sees and edits. The caller (the route) resolves the recipient.
|
|
1256
|
+
|
|
1257
|
+
``labels`` is validated identically to ``add_todo``. Note what does NOT
|
|
1258
|
+
happen here: no auto-registration. The item is the agent's to write; the
|
|
1259
|
+
vocabulary is the owner's to curate."""
|
|
1260
|
+
text = (text or "").strip()
|
|
1261
|
+
if not text:
|
|
1262
|
+
raise ValueError("Task text cannot be empty")
|
|
1263
|
+
slugs = merge_label_keys([], replace=labels)
|
|
1264
|
+
async with _lock:
|
|
1265
|
+
todos = _load(data_dir, owner_user_id)
|
|
1266
|
+
now = _now()
|
|
1267
|
+
todo = DeskTodo(
|
|
1268
|
+
id=gen_id(),
|
|
1269
|
+
owner_user_id=owner_user_id,
|
|
1270
|
+
text=text,
|
|
1271
|
+
created_at=now,
|
|
1272
|
+
updated_at=now,
|
|
1273
|
+
by_agent_id=by_agent_id,
|
|
1274
|
+
draft_prompt=draft_prompt or None,
|
|
1275
|
+
draft_recipient_id=draft_recipient_id,
|
|
1276
|
+
draft_recipient_name=draft_recipient_name,
|
|
1277
|
+
drafted=bool(draft_prompt),
|
|
1278
|
+
labels=slugs,
|
|
1279
|
+
)
|
|
1280
|
+
todos.insert(0, todo)
|
|
1281
|
+
_save(data_dir, owner_user_id, todos)
|
|
1282
|
+
return todo
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _reconcile_attachments(
|
|
1286
|
+
data_dir: Path,
|
|
1287
|
+
owner_user_id: str,
|
|
1288
|
+
target: DeskTodo,
|
|
1289
|
+
desired: list[dict],
|
|
1290
|
+
total_limit: int | None,
|
|
1291
|
+
) -> list[DeskTodoAttachment]:
|
|
1292
|
+
"""Turn the desired-state array into the to-do's new attachment list.
|
|
1293
|
+
|
|
1294
|
+
Pure planning plus blob WRITES. It does not save the plate and it does not
|
|
1295
|
+
delete anything — deletion is the caller's step 3, strictly after the plate
|
|
1296
|
+
stops naming the bytes. The caller holds ``plate_lock``.
|
|
1297
|
+
|
|
1298
|
+
``desired`` is the route's already-decoded form: ``{"keep": id}`` entries
|
|
1299
|
+
pass through, ``{"name", "raw", "content_type"}`` entries are new bytes.
|
|
1300
|
+
Order is preserved exactly as sent — the tray is an ordered thing on
|
|
1301
|
+
screen, and nothing here may scramble it.
|
|
1302
|
+
|
|
1303
|
+
The array is the COMPLETE desired state, which is what makes it
|
|
1304
|
+
structurally duplicate-proof: a client that re-sends bytes for an
|
|
1305
|
+
already-stored file instead of ``{keep: id}`` gets a new id and the old id
|
|
1306
|
+
is absent, so the worst case is churn (new id, same bytes) and never two
|
|
1307
|
+
rows for one file.
|
|
1308
|
+
|
|
1309
|
+
Three cases worth stating because they are not inferable from the shape:
|
|
1310
|
+
|
|
1311
|
+
* A ``{keep}`` naming an id that is not on THIS to-do raises
|
|
1312
|
+
``AttachmentError(400, ...)``. Loud, not silent: absence-means-delete
|
|
1313
|
+
is only safe because the client holds the complete desired state, so a
|
|
1314
|
+
client that has demonstrably lost track of what is stored — a stale
|
|
1315
|
+
second tab — is told in the same request rather than having its
|
|
1316
|
+
confusion committed. It is also the only option consistent with the
|
|
1317
|
+
save being atomic; silently dropping the entry would half-honour it.
|
|
1318
|
+
* A duplicate ``{keep}`` of the same id collapses to one row.
|
|
1319
|
+
* The per-to-do total is checked HERE, on the post-reconcile result
|
|
1320
|
+
(kept + new), not on the input — the same discipline
|
|
1321
|
+
``merge_label_keys`` applies to the label cap, and for the same reason:
|
|
1322
|
+
the sentence has to describe the to-do, which is what the owner is
|
|
1323
|
+
looking at. It raises having applied nothing, so the plate stays
|
|
1324
|
+
byte-identical and no byte reaches disk.
|
|
1325
|
+
"""
|
|
1326
|
+
stored = {a.id: a for a in target.attachments}
|
|
1327
|
+
now = _now()
|
|
1328
|
+
|
|
1329
|
+
result: list[DeskTodoAttachment] = []
|
|
1330
|
+
seen_keeps: set[str] = set()
|
|
1331
|
+
minted: list[tuple[str, bytes]] = []
|
|
1332
|
+
|
|
1333
|
+
for entry in desired:
|
|
1334
|
+
keep_id = entry.get("keep")
|
|
1335
|
+
if keep_id is not None:
|
|
1336
|
+
existing = stored.get(keep_id)
|
|
1337
|
+
if existing is None:
|
|
1338
|
+
raise AttachmentError(
|
|
1339
|
+
400, f"attachment {keep_id!r} is not on this to-do"
|
|
1340
|
+
)
|
|
1341
|
+
if keep_id in seen_keeps:
|
|
1342
|
+
continue
|
|
1343
|
+
seen_keeps.add(keep_id)
|
|
1344
|
+
result.append(existing)
|
|
1345
|
+
continue
|
|
1346
|
+
|
|
1347
|
+
raw: bytes = entry["raw"]
|
|
1348
|
+
attachment_id = gen_attachment_id()
|
|
1349
|
+
result.append(
|
|
1350
|
+
DeskTodoAttachment(
|
|
1351
|
+
id=attachment_id,
|
|
1352
|
+
name=entry["name"],
|
|
1353
|
+
size=len(raw),
|
|
1354
|
+
content_type=entry["content_type"],
|
|
1355
|
+
sha256=FileUtil.sha256(raw),
|
|
1356
|
+
created_at=now,
|
|
1357
|
+
)
|
|
1358
|
+
)
|
|
1359
|
+
minted.append((attachment_id, raw))
|
|
1360
|
+
|
|
1361
|
+
if total_limit is not None:
|
|
1362
|
+
total = sum(a.size for a in result)
|
|
1363
|
+
if total > total_limit:
|
|
1364
|
+
raise AttachmentError(
|
|
1365
|
+
413,
|
|
1366
|
+
f"attachments total {total} bytes, exceeds the {total_limit}-byte "
|
|
1367
|
+
"per-to-do limit (raise MANIGENT_MAX_MESSAGE_ATTACHMENTS_BYTES)",
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
# Only now, with every check passed, do bytes reach the disk — so a 413 or
|
|
1371
|
+
# a bad {keep} never leaves one behind.
|
|
1372
|
+
if minted:
|
|
1373
|
+
_write_new_blobs(data_dir, owner_user_id, target.id, minted)
|
|
1374
|
+
return result
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
async def patch_todo(
|
|
1378
|
+
data_dir: Path,
|
|
1379
|
+
owner_user_id: str,
|
|
1380
|
+
todo_id: str,
|
|
1381
|
+
*,
|
|
1382
|
+
archived: bool | None = None,
|
|
1383
|
+
text: str | None = None,
|
|
1384
|
+
draft_prompt: str | None = None,
|
|
1385
|
+
draft_recipient_id: str | None = _UNSET, # _UNSET → leave untouched
|
|
1386
|
+
draft_recipient_name: str | None = _UNSET, # None → clear, str → set
|
|
1387
|
+
drafted: bool | None = None,
|
|
1388
|
+
labels: object = _UNSET, # _UNSET → leave untouched, None/[] → clear
|
|
1389
|
+
add_labels: object = None,
|
|
1390
|
+
remove_labels: object = None,
|
|
1391
|
+
attachments: object = _UNSET, # _UNSET → untouched, None/[] → remove all
|
|
1392
|
+
total_limit: int | None = None, # post-reconcile cap, supplied by the route
|
|
1393
|
+
) -> DeskTodo | None:
|
|
1394
|
+
"""Patch a task in place. Returns the updated task, or None if missing.
|
|
1395
|
+
|
|
1396
|
+
``draft_recipient_id`` / ``draft_recipient_name`` are three-way: ``_UNSET``
|
|
1397
|
+
(the default, when the caller omits them) leaves the stored value alone,
|
|
1398
|
+
an explicit ``None`` clears it, and a string overwrites it. The older
|
|
1399
|
+
scalar fields collapse absent+None into "untouched" (a ``None`` never
|
|
1400
|
+
clears them).
|
|
1401
|
+
|
|
1402
|
+
``labels`` is three-way in exactly the same way. ``add_labels`` /
|
|
1403
|
+
``remove_labels`` need no three-way handling — absent and empty both mean
|
|
1404
|
+
no-op — and are idempotent in both directions: adding a label the item
|
|
1405
|
+
already carries and removing one it does not are 200s, not errors. All three
|
|
1406
|
+
are delegated to ``merge_label_keys``, which is where the cap is checked.
|
|
1407
|
+
|
|
1408
|
+
``attachments`` is three-way in exactly the same way ``labels`` is:
|
|
1409
|
+
``_UNSET`` (the caller omitted the key) leaves the stored list alone,
|
|
1410
|
+
``None`` or ``[]`` removes all, and a list reconciles to exactly that
|
|
1411
|
+
desired state. The ABSENT case is the one that matters most — a done
|
|
1412
|
+
toggle, a rename or a label edit must never nuke a to-do's files, and every
|
|
1413
|
+
one of those goes through this function. ``total_limit`` is the per-to-do
|
|
1414
|
+
byte cap; the route supplies it, because this module ships in the runner
|
|
1415
|
+
wheel and may not read the server's environment for itself.
|
|
1416
|
+
|
|
1417
|
+
ORDER INSIDE THE LOCK, and it is the only rule that keeps a crash
|
|
1418
|
+
survivable::
|
|
1419
|
+
|
|
1420
|
+
1. plan + write NEW blobs (_reconcile_attachments)
|
|
1421
|
+
2. write the plate (_save)
|
|
1422
|
+
3. delete now-unreferenced blobs (sweep_orphan_todo_files)
|
|
1423
|
+
|
|
1424
|
+
New bytes go down before the row that names them, so a failed blob write
|
|
1425
|
+
leaves nothing in the JSON. Deletions happen strictly AFTER the row stops
|
|
1426
|
+
naming them, because delete-then-failed-save would destroy bytes the plate
|
|
1427
|
+
still points at — the one failure the "plate is the only source of truth"
|
|
1428
|
+
invariant does not cover. Both halves stay inside the same lock, and the
|
|
1429
|
+
worst case (a crash in the gap) leaves an unreferenced directory that the
|
|
1430
|
+
next attachment-bearing save sweeps.
|
|
1431
|
+
"""
|
|
1432
|
+
async with _lock:
|
|
1433
|
+
todos = _load(data_dir, owner_user_id)
|
|
1434
|
+
target: DeskTodo | None = None
|
|
1435
|
+
for t in todos:
|
|
1436
|
+
if t.id == todo_id:
|
|
1437
|
+
target = t
|
|
1438
|
+
break
|
|
1439
|
+
if target is None:
|
|
1440
|
+
return None
|
|
1441
|
+
if archived is not None:
|
|
1442
|
+
# THE ONE WRITER (AC-1.6). This assignment is the single place in
|
|
1443
|
+
# the product that records "the owner filed this away". Its three
|
|
1444
|
+
# callers are the desk checkbox, the CLI archive/unarchive verbs,
|
|
1445
|
+
# and the trigger's --consume option. Nothing project-driven writes
|
|
1446
|
+
# it and nothing reads it as "the work finished".
|
|
1447
|
+
#
|
|
1448
|
+
# A bool has no value to validate, so the old
|
|
1449
|
+
# `status must be 'open' or 'done'` branch is gone outright. None
|
|
1450
|
+
# still means "untouched", exactly as it did for `status`.
|
|
1451
|
+
target.archived = archived
|
|
1452
|
+
if text is not None:
|
|
1453
|
+
text = text.strip()
|
|
1454
|
+
if text:
|
|
1455
|
+
target.text = text
|
|
1456
|
+
if draft_prompt is not None:
|
|
1457
|
+
target.draft_prompt = draft_prompt
|
|
1458
|
+
if draft_recipient_id is not _UNSET:
|
|
1459
|
+
target.draft_recipient_id = draft_recipient_id
|
|
1460
|
+
if draft_recipient_name is not _UNSET:
|
|
1461
|
+
target.draft_recipient_name = draft_recipient_name
|
|
1462
|
+
if drafted is not None:
|
|
1463
|
+
target.drafted = drafted
|
|
1464
|
+
if labels is not _UNSET or add_labels is not None or remove_labels is not None:
|
|
1465
|
+
target.labels = merge_label_keys(
|
|
1466
|
+
target.labels,
|
|
1467
|
+
replace=labels,
|
|
1468
|
+
add=add_labels,
|
|
1469
|
+
remove=remove_labels,
|
|
1470
|
+
)
|
|
1471
|
+
if attachments is not _UNSET:
|
|
1472
|
+
desired = list(attachments) if isinstance(attachments, list) else []
|
|
1473
|
+
# Step 1: plan, then write the new bytes — before the row names them.
|
|
1474
|
+
target.attachments = _reconcile_attachments(
|
|
1475
|
+
data_dir, owner_user_id, target, desired, total_limit
|
|
1476
|
+
)
|
|
1477
|
+
target.updated_at = _now()
|
|
1478
|
+
# Step 2: the plate. From here on the new blobs are reachable and the
|
|
1479
|
+
# ones the desired-state array dropped are, by definition, garbage.
|
|
1480
|
+
_save(data_dir, owner_user_id, todos)
|
|
1481
|
+
if attachments is not _UNSET:
|
|
1482
|
+
# Step 3: collect that garbage — strictly after the row stopped
|
|
1483
|
+
# naming it, never before. This is the SAME primitive delete_todo
|
|
1484
|
+
# uses; the reconcile has no delete branch of its own to get wrong.
|
|
1485
|
+
sweep_orphan_todo_files(data_dir, owner_user_id)
|
|
1486
|
+
return target
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
async def associate_todo(
|
|
1490
|
+
data_dir: Path, owner_user_id: str, todo_id: str, project_id: str
|
|
1491
|
+
) -> DeskTodo | None:
|
|
1492
|
+
"""Add one project (or DM-thread) id to a to-do. Returns the updated to-do,
|
|
1493
|
+
or None if the to-do is gone.
|
|
1494
|
+
|
|
1495
|
+
IDEMPOTENT: associating a project the to-do already carries is a no-op that
|
|
1496
|
+
still returns the to-do, not a 409. That matches ``add_labels`` /
|
|
1497
|
+
``remove_labels`` and it matches them for the same reason — the owner's
|
|
1498
|
+
assistant retries commands, and a rule that succeeds on the first run and
|
|
1499
|
+
fails on the second is the worst kind of rule to hand an agent.
|
|
1500
|
+
|
|
1501
|
+
Stores the id VERBATIM. It does not resolve the project, does not validate
|
|
1502
|
+
it, and does not check who owns it: this module knows nothing about
|
|
1503
|
+
projects. Authorization is the ROUTE's job and lives there, because the
|
|
1504
|
+
visibility predicate it needs (``server/routes/_batch.py``) is not shipped
|
|
1505
|
+
in the runner wheel while this module is.
|
|
1506
|
+
"""
|
|
1507
|
+
project_id = (project_id or "").strip()
|
|
1508
|
+
if not project_id:
|
|
1509
|
+
raise ValueError("project_id cannot be empty")
|
|
1510
|
+
async with _lock:
|
|
1511
|
+
todos = _load(data_dir, owner_user_id)
|
|
1512
|
+
for t in todos:
|
|
1513
|
+
if t.id == todo_id:
|
|
1514
|
+
if project_id not in t.project_ids:
|
|
1515
|
+
t.project_ids.append(project_id)
|
|
1516
|
+
t.updated_at = _now()
|
|
1517
|
+
_save(data_dir, owner_user_id, todos)
|
|
1518
|
+
return t
|
|
1519
|
+
return None
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
async def dissociate_todo(
|
|
1523
|
+
data_dir: Path, owner_user_id: str, todo_id: str, project_id: str
|
|
1524
|
+
) -> DeskTodo | None:
|
|
1525
|
+
"""Remove one project id from a to-do. Returns the updated to-do, or None
|
|
1526
|
+
if the to-do is gone.
|
|
1527
|
+
|
|
1528
|
+
Idempotent in the other direction: removing an id the to-do does not carry
|
|
1529
|
+
is a no-op that still returns the to-do.
|
|
1530
|
+
|
|
1531
|
+
This is also the owner's ONLY way to clear a DANGLING id — one whose
|
|
1532
|
+
project has been deleted — which is why the route above it takes no
|
|
1533
|
+
ownership check on the way out. A deleted project resolves to nothing, so
|
|
1534
|
+
an ownership check would make exactly the rows that most need removing the
|
|
1535
|
+
ones that cannot be removed.
|
|
1536
|
+
"""
|
|
1537
|
+
async with _lock:
|
|
1538
|
+
todos = _load(data_dir, owner_user_id)
|
|
1539
|
+
for t in todos:
|
|
1540
|
+
if t.id == todo_id:
|
|
1541
|
+
if project_id in t.project_ids:
|
|
1542
|
+
t.project_ids = [p for p in t.project_ids if p != project_id]
|
|
1543
|
+
t.updated_at = _now()
|
|
1544
|
+
_save(data_dir, owner_user_id, todos)
|
|
1545
|
+
return t
|
|
1546
|
+
return None
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
async def reorder_todos(
|
|
1550
|
+
data_dir: Path, owner_user_id: str, ordered_ids: list[str]
|
|
1551
|
+
) -> list[DeskTodo]:
|
|
1552
|
+
"""Reorder the plate to match ``ordered_ids``. Ids not present are ignored;
|
|
1553
|
+
any todos omitted from ``ordered_ids`` are appended in their prior order so
|
|
1554
|
+
a partial list (e.g. only the open items) never drops the rest."""
|
|
1555
|
+
async with _lock:
|
|
1556
|
+
todos = _load(data_dir, owner_user_id)
|
|
1557
|
+
by_id = {t.id: t for t in todos}
|
|
1558
|
+
seen: set[str] = set()
|
|
1559
|
+
ordered: list[DeskTodo] = []
|
|
1560
|
+
for tid in ordered_ids:
|
|
1561
|
+
t = by_id.get(tid)
|
|
1562
|
+
if t is not None and tid not in seen:
|
|
1563
|
+
ordered.append(t)
|
|
1564
|
+
seen.add(tid)
|
|
1565
|
+
for t in todos:
|
|
1566
|
+
if t.id not in seen:
|
|
1567
|
+
ordered.append(t)
|
|
1568
|
+
_save(data_dir, owner_user_id, ordered)
|
|
1569
|
+
return ordered
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
async def delete_todo(data_dir: Path, owner_user_id: str, todo_id: str) -> bool:
|
|
1573
|
+
"""Remove a task, and its stored bytes with it. Returns True if it existed.
|
|
1574
|
+
|
|
1575
|
+
The plate write goes FIRST here, for the same reason blob deletions come
|
|
1576
|
+
last in ``patch_todo``: the row is what makes the bytes reachable, so it
|
|
1577
|
+
goes first and the bytes follow. A crash in the gap leaves an unreferenced
|
|
1578
|
+
directory, which is garbage by definition and which the sweep — run here,
|
|
1579
|
+
and on the next attachment-bearing save — takes.
|
|
1580
|
+
|
|
1581
|
+
``rmtree`` is best-effort by contract: a filesystem error is logged, not
|
|
1582
|
+
raised. The to-do is already gone from the plate, and failing the delete
|
|
1583
|
+
would leave the user with an item they cannot remove; the sweep is the
|
|
1584
|
+
backstop. It runs over the whole OWNER directory rather than just this
|
|
1585
|
+
to-do's, because a delete is the cheapest moment to notice that some
|
|
1586
|
+
earlier crash left bytes behind.
|
|
1587
|
+
"""
|
|
1588
|
+
async with _lock:
|
|
1589
|
+
todos = _load(data_dir, owner_user_id)
|
|
1590
|
+
kept = [t for t in todos if t.id != todo_id]
|
|
1591
|
+
if len(kept) == len(todos):
|
|
1592
|
+
return False
|
|
1593
|
+
_save(data_dir, owner_user_id, kept)
|
|
1594
|
+
try:
|
|
1595
|
+
shutil.rmtree(todo_files_dir(data_dir, owner_user_id, todo_id))
|
|
1596
|
+
except FileNotFoundError:
|
|
1597
|
+
pass
|
|
1598
|
+
except OSError as e: # pragma: no cover - filesystem edge
|
|
1599
|
+
logger.warning(
|
|
1600
|
+
f"Could not remove attachment dir for to-do {todo_id!r}: {e}"
|
|
1601
|
+
)
|
|
1602
|
+
sweep_orphan_todo_files(data_dir, owner_user_id)
|
|
1603
|
+
return True
|