Merge PR #9: harden company resume, external sessions, and Office UI i18n
Merges xiaodong-l's hardening work: company suspend-checkpoint resume reconciliation, external session token lifecycle fixes, Windows launch shims, codex shell_environment_policy forwarding, Office UI i18n (en/zh-CN), and two org presets. frontend_dist is rebuilt from the merged sources in this commit so the #11 runtime-status fixes and the i18n bundle coexist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # opc/plugins/office_ui/frontend_dist/assets/index-BgyI65M_.js # opc/plugins/office_ui/frontend_dist/index.html
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
schema_version: 2
|
||||
kind: opc_org_architecture
|
||||
organization_id: cross-border-ecommerce
|
||||
organization_name: Cross-Border Ecommerce Team
|
||||
company:
|
||||
name: Cross-Border Ecommerce Team
|
||||
topology: Ecommerce Growth Hub
|
||||
company_profile: custom
|
||||
execution_model: actor_runtime
|
||||
final_decider_role_id: ecommerce_director
|
||||
company_profiles:
|
||||
- corporate
|
||||
- custom
|
||||
roles:
|
||||
- id: ecommerce_director
|
||||
name: Ecommerce Director
|
||||
responsibility: Owns market choice, commercial goals, portfolio priorities, budget
|
||||
allocation, operating cadence, risk decisions, and final delivery.
|
||||
reports_to: owner
|
||||
icon: leader
|
||||
can_spawn:
|
||||
- market_researcher
|
||||
- listing_localization_specialist
|
||||
- paid_media_specialist
|
||||
- social_content_specialist
|
||||
- operations_manager
|
||||
- finance_analyst
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Clarify target platform, country, category, customer, margin target, budget, inventory
|
||||
constraints, and time horizon before delegating.
|
||||
- Require every recommendation to separate verified facts, assumptions, experiments,
|
||||
owners, metrics, and stop conditions.
|
||||
skill_refs:
|
||||
- ecommerce-operating-system
|
||||
- market-product-research
|
||||
- compliance-freshness
|
||||
- cross-border-guide
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: native
|
||||
allowed_downstream_roles:
|
||||
- market_researcher
|
||||
- listing_localization_specialist
|
||||
- paid_media_specialist
|
||||
- social_content_specialist
|
||||
- operations_manager
|
||||
- finance_analyst
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: coordinator
|
||||
coordinator_policy: null
|
||||
- id: market_researcher
|
||||
name: Market and Product Researcher
|
||||
responsibility: Researches demand, competition, pricing, reviews, trends, product
|
||||
opportunities, and market-entry evidence.
|
||||
reports_to: ecommerce_director
|
||||
icon: search
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Use current sources, record URLs and access dates, compare multiple competitors,
|
||||
and quantify confidence and data gaps.
|
||||
skill_refs:
|
||||
- market-product-research
|
||||
- compliance-freshness
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: research
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: listing_localization_specialist
|
||||
name: Listing and Localization Specialist
|
||||
responsibility: Produces localized product positioning, titles, bullets, descriptions,
|
||||
keyword maps, image briefs, and storefront copy.
|
||||
reports_to: ecommerce_director
|
||||
icon: writing
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Adapt claims and terminology to the target market and platform. Never invent certifications,
|
||||
performance claims, or product facts.
|
||||
skill_refs:
|
||||
- listing-localization
|
||||
- market-product-research
|
||||
- compliance-freshness
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: paid_media_specialist
|
||||
name: Paid Media Specialist
|
||||
responsibility: Designs and optimizes PPC and paid-social campaigns, budgets, tracking,
|
||||
experiments, and profitability controls.
|
||||
reports_to: ecommerce_director
|
||||
icon: target
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Define funnel stage, audience, creative hypothesis, budget, attribution limits,
|
||||
success metric, guardrails, and stop-loss rule for every campaign.
|
||||
skill_refs:
|
||||
- paid-media-optimization
|
||||
- ecommerce-operating-system
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: social_content_specialist
|
||||
name: Social Content Specialist
|
||||
responsibility: Plans platform-native organic content, creator briefs, short-video
|
||||
concepts, publishing cadence, and community feedback loops.
|
||||
reports_to: ecommerce_director
|
||||
icon: marketing
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Build content around verified product value, target-customer situations, platform-native
|
||||
formats, and measurable calls to action.
|
||||
skill_refs:
|
||||
- listing-localization
|
||||
- paid-media-optimization
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: operations_manager
|
||||
name: Ecommerce Operations Manager
|
||||
responsibility: Owns catalog operations, order flow, inventory, fulfillment, customer
|
||||
service coordination, SOPs, and operational incident handling.
|
||||
reports_to: ecommerce_director
|
||||
icon: settings
|
||||
can_spawn:
|
||||
- supply_chain_specialist
|
||||
- customer_service_specialist
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Convert recommendations into repeatable SOPs with owner, trigger, input, output,
|
||||
SLA, exception path, and audit evidence.
|
||||
skill_refs:
|
||||
- ecommerce-operating-system
|
||||
- inventory-fulfillment
|
||||
- cross-border-guide
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: native
|
||||
allowed_downstream_roles:
|
||||
- supply_chain_specialist
|
||||
- customer_service_specialist
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: coordinator
|
||||
coordinator_policy: null
|
||||
- id: supply_chain_specialist
|
||||
name: Supply Chain Specialist
|
||||
responsibility: Plans sourcing, lead times, replenishment, safety stock, logistics
|
||||
options, landed cost, and inventory risk.
|
||||
reports_to: operations_manager
|
||||
icon: database
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- State assumptions for demand, lead time, MOQ, defect rate, freight, duties, and
|
||||
service level; provide sensitivity ranges instead of false precision.
|
||||
skill_refs:
|
||||
- inventory-fulfillment
|
||||
- ecommerce-operating-system
|
||||
- cross-border-guide
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: customer_service_specialist
|
||||
name: Customer Service Specialist
|
||||
responsibility: Designs support macros, escalation paths, return handling, review
|
||||
response, complaint analysis, and voice-of-customer feedback.
|
||||
reports_to: operations_manager
|
||||
icon: message
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Keep responses factual, empathetic, policy-compliant, and explicit about what
|
||||
requires human approval or account access.
|
||||
skill_refs:
|
||||
- customer-service-voice
|
||||
- compliance-freshness
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: finance_analyst
|
||||
name: Ecommerce Finance Analyst
|
||||
responsibility: Models unit economics, pricing, contribution margin, CAC, ROAS,
|
||||
cash conversion, scenario risk, and KPI reporting.
|
||||
reports_to: ecommerce_director
|
||||
icon: chart
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Show formulas, currencies, tax and fee assumptions, data sources, and sensitivity
|
||||
ranges. Flag missing inputs rather than guessing silently.
|
||||
skill_refs:
|
||||
- ecommerce-unit-economics
|
||||
- paid-media-optimization
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: analysis
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
employees: []
|
||||
escalation_rules: []
|
||||
runtime_policies:
|
||||
custom:
|
||||
communication:
|
||||
default_mode: dm
|
||||
blocking_default: true
|
||||
meeting_required_for:
|
||||
- budget_change
|
||||
- compliance_risk
|
||||
- inventory_risk
|
||||
allow_broadcast: true
|
||||
memory:
|
||||
include_role_memory: true
|
||||
include_project_memory: true
|
||||
include_decision_log: true
|
||||
include_artifact_index: true
|
||||
recent_history_lines: 16
|
||||
handoff:
|
||||
require_structured_handoff: true
|
||||
require_ack: true
|
||||
include_risks: true
|
||||
include_open_questions: true
|
||||
artifact:
|
||||
enforce_contract: false
|
||||
require_artifact_index: true
|
||||
required_kinds: []
|
||||
review:
|
||||
enable_work_item_gates: true
|
||||
strict_gate_inference: true
|
||||
require_reviewer_role: true
|
||||
allow_human_override: true
|
||||
gate_harness:
|
||||
enabled: true
|
||||
decision_mode: agent_first
|
||||
default_degrade_policy: allow
|
||||
auto_infer_turn_kind: true
|
||||
auto_infer_gate_profile: true
|
||||
max_rework_rounds_per_issue: 2
|
||||
stagnation_threshold: 2
|
||||
allow_pass_with_constraints: true
|
||||
enable_delivery_constraints_propagation: true
|
||||
agent_model: ''
|
||||
agent_confidence_threshold: 0.55
|
||||
fallback_to_rules_on_parse_error: true
|
||||
builtin_blocker_map:
|
||||
workspace_mismatch: rework_same_work_item
|
||||
missing_required_artifact: rework_same_work_item
|
||||
verification_missing: rework_same_work_item
|
||||
dependency_not_ready: rework_same_work_item
|
||||
cross_work_item_conflict: replan
|
||||
external_secret_missing: await_user_decision
|
||||
permission_missing: escalate
|
||||
environment_capability_gap: pass_with_constraints
|
||||
quality_gap: rework_same_work_item
|
||||
goal_env_mismatch: replan
|
||||
unresolved_user_visible_risk: pass_with_constraints
|
||||
turn_kind_overrides: {}
|
||||
gate_profile_overrides: {}
|
||||
parallel:
|
||||
auto_dispatch: true
|
||||
review_gate_enabled: true
|
||||
max_workers: 7
|
||||
coordination:
|
||||
inference_mode: llm_primary
|
||||
fallback_mode: conservative
|
||||
strict_gate_turn_kinds:
|
||||
- verify
|
||||
- deliver
|
||||
mixed_gate_turn_kinds:
|
||||
- synthesize
|
||||
- review
|
||||
- integration
|
||||
allow_manager_release_for_mixed_only: true
|
||||
allow_custom_signals: true
|
||||
talent_templates: []
|
||||
teams: []
|
||||
team_runtime:
|
||||
default_team_id: ''
|
||||
shared_role_session_scope: team
|
||||
allow_shared_role_sessions: true
|
||||
seat_refresh_interval_seconds: 30
|
||||
metadata: {}
|
||||
installed_packages: []
|
||||
role_serial_queue_enabled: true
|
||||
metadata:
|
||||
source: org_mode
|
||||
organization_config_file: company_orgs/org_cross-border-ecommerce_config.yaml
|
||||
@@ -0,0 +1,380 @@
|
||||
schema_version: 2
|
||||
kind: opc_org_architecture
|
||||
organization_id: software-development
|
||||
organization_name: Software Development Team
|
||||
company:
|
||||
name: Software Development Team
|
||||
topology: Product Engineering Hub
|
||||
company_profile: custom
|
||||
execution_model: actor_runtime
|
||||
final_decider_role_id: engineering_manager
|
||||
company_profiles:
|
||||
- corporate
|
||||
- custom
|
||||
roles:
|
||||
- id: engineering_manager
|
||||
name: Engineering Manager
|
||||
responsibility: Owns scope, delivery, staffing, cross-role coordination, risk decisions,
|
||||
and final acceptance for software projects.
|
||||
reports_to: owner
|
||||
icon: leader
|
||||
can_spawn:
|
||||
- product_manager
|
||||
- software_architect
|
||||
- qa_engineer
|
||||
- security_reviewer
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Clarify the user goal, repository boundaries, constraints, acceptance criteria,
|
||||
and delivery order before delegating.
|
||||
- Require implementation evidence, focused tests, review findings, deployment notes,
|
||||
and explicit residual risks before final delivery.
|
||||
skill_refs:
|
||||
- repo-onboarding
|
||||
- software-delivery
|
||||
- release-readiness
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: native
|
||||
allowed_downstream_roles:
|
||||
- product_manager
|
||||
- software_architect
|
||||
- qa_engineer
|
||||
- security_reviewer
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: coordinator
|
||||
coordinator_policy: null
|
||||
- id: product_manager
|
||||
name: Product Manager
|
||||
responsibility: Converts business goals into prioritized requirements, user flows,
|
||||
acceptance criteria, and release scope.
|
||||
reports_to: engineering_manager
|
||||
icon: clipboard
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Produce testable requirements and identify assumptions, exclusions, dependencies,
|
||||
and user-visible behavior.
|
||||
skill_refs:
|
||||
- software-delivery
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: software_architect
|
||||
name: Software Architect
|
||||
responsibility: Owns repository analysis, architecture, technical decomposition,
|
||||
interfaces, data design, and engineering integration.
|
||||
reports_to: engineering_manager
|
||||
icon: code
|
||||
can_spawn:
|
||||
- frontend_engineer
|
||||
- backend_engineer
|
||||
- devops_engineer
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Inspect the existing repository before proposing architecture. Prefer existing
|
||||
patterns and define contracts between work items.
|
||||
- Review implementation integration and reject changes that violate repository boundaries
|
||||
or lack verification.
|
||||
skill_refs:
|
||||
- repo-onboarding
|
||||
- software-delivery
|
||||
- code-review-gate
|
||||
- code-review
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: native
|
||||
allowed_downstream_roles:
|
||||
- frontend_engineer
|
||||
- backend_engineer
|
||||
- devops_engineer
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: coordinator
|
||||
coordinator_policy: null
|
||||
- id: frontend_engineer
|
||||
name: Frontend Engineer
|
||||
responsibility: Implements accessible, responsive, tested frontend behavior using
|
||||
the existing design system and framework conventions.
|
||||
reports_to: software_architect
|
||||
icon: design
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Implement complete UI states and verify desktop and mobile behavior with focused
|
||||
tests or browser evidence.
|
||||
skill_refs:
|
||||
- software-delivery
|
||||
- testing-quality-gate
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: external
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: backend_engineer
|
||||
name: Backend Engineer
|
||||
responsibility: Implements APIs, domain logic, persistence, integrations, migrations,
|
||||
and backend tests.
|
||||
reports_to: software_architect
|
||||
icon: terminal
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Preserve data and API compatibility, validate inputs, handle failure paths, and
|
||||
add focused tests for changed behavior.
|
||||
skill_refs:
|
||||
- software-delivery
|
||||
- testing-quality-gate
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: external
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: work
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: qa_engineer
|
||||
name: QA Engineer
|
||||
responsibility: Independently validates requirements, regressions, edge cases, test
|
||||
evidence, and release readiness.
|
||||
reports_to: engineering_manager
|
||||
icon: bug
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Build a risk-based test matrix, run focused and integration checks, and report
|
||||
reproducible defects with evidence.
|
||||
skill_refs:
|
||||
- testing-quality-gate
|
||||
- code-review-gate
|
||||
- code-review
|
||||
- release-readiness
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: verify
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: devops_engineer
|
||||
name: DevOps Engineer
|
||||
responsibility: Owns environment setup, CI/CD, deployment, observability, rollback
|
||||
planning, and operational verification.
|
||||
reports_to: software_architect
|
||||
icon: settings
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Probe the environment first, keep changes reversible, document commands, and include
|
||||
health checks and rollback steps.
|
||||
skill_refs:
|
||||
- repo-onboarding
|
||||
- release-readiness
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: setup
|
||||
shell_timeout_override: 1800
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
- id: security_reviewer
|
||||
name: Security Reviewer
|
||||
responsibility: Reviews authentication, authorization, secrets, dependencies, input
|
||||
handling, privacy, and abuse risks.
|
||||
reports_to: engineering_manager
|
||||
icon: shield
|
||||
can_spawn: []
|
||||
tools: []
|
||||
preferred_external_agent: codex
|
||||
prompt_refs:
|
||||
- Review only the relevant attack surface, rank findings by severity, and distinguish
|
||||
blockers from hardening opportunities.
|
||||
skill_refs:
|
||||
- code-review-gate
|
||||
- code-review
|
||||
- release-readiness
|
||||
handoff_template_ref: null
|
||||
memory_policy_ref: null
|
||||
artifact_contract_ref: null
|
||||
runtime_policy:
|
||||
execution_strategy: auto
|
||||
allowed_downstream_roles: []
|
||||
review_role: null
|
||||
default_turn_type: review
|
||||
shell_timeout_override: null
|
||||
setup_env_type: null
|
||||
coordination_hints: {}
|
||||
signal_capabilities: []
|
||||
parallelism_constraints: []
|
||||
gate_preferences: {}
|
||||
capabilities: []
|
||||
role_type: worker
|
||||
coordinator_policy: null
|
||||
employees: []
|
||||
escalation_rules: []
|
||||
runtime_policies:
|
||||
custom:
|
||||
communication:
|
||||
default_mode: dm
|
||||
blocking_default: true
|
||||
meeting_required_for:
|
||||
- architecture
|
||||
- cross_team_conflict
|
||||
allow_broadcast: true
|
||||
memory:
|
||||
include_role_memory: true
|
||||
include_project_memory: true
|
||||
include_decision_log: true
|
||||
include_artifact_index: true
|
||||
recent_history_lines: 16
|
||||
handoff:
|
||||
require_structured_handoff: true
|
||||
require_ack: true
|
||||
include_risks: true
|
||||
include_open_questions: true
|
||||
artifact:
|
||||
enforce_contract: false
|
||||
require_artifact_index: true
|
||||
required_kinds: []
|
||||
review:
|
||||
enable_work_item_gates: true
|
||||
strict_gate_inference: true
|
||||
require_reviewer_role: true
|
||||
allow_human_override: true
|
||||
gate_harness:
|
||||
enabled: true
|
||||
decision_mode: agent_first
|
||||
default_degrade_policy: allow
|
||||
auto_infer_turn_kind: true
|
||||
auto_infer_gate_profile: true
|
||||
max_rework_rounds_per_issue: 2
|
||||
stagnation_threshold: 2
|
||||
allow_pass_with_constraints: true
|
||||
enable_delivery_constraints_propagation: true
|
||||
agent_model: ''
|
||||
agent_confidence_threshold: 0.55
|
||||
fallback_to_rules_on_parse_error: true
|
||||
builtin_blocker_map:
|
||||
workspace_mismatch: rework_same_work_item
|
||||
missing_required_artifact: rework_same_work_item
|
||||
verification_missing: rework_same_work_item
|
||||
dependency_not_ready: rework_same_work_item
|
||||
cross_work_item_conflict: replan
|
||||
external_secret_missing: await_user_decision
|
||||
permission_missing: escalate
|
||||
environment_capability_gap: pass_with_constraints
|
||||
quality_gap: rework_same_work_item
|
||||
goal_env_mismatch: replan
|
||||
unresolved_user_visible_risk: pass_with_constraints
|
||||
turn_kind_overrides: {}
|
||||
gate_profile_overrides: {}
|
||||
parallel:
|
||||
auto_dispatch: true
|
||||
review_gate_enabled: true
|
||||
max_workers: 6
|
||||
coordination:
|
||||
inference_mode: llm_primary
|
||||
fallback_mode: conservative
|
||||
strict_gate_turn_kinds:
|
||||
- verify
|
||||
- deliver
|
||||
mixed_gate_turn_kinds:
|
||||
- synthesize
|
||||
- review
|
||||
- integration
|
||||
allow_manager_release_for_mixed_only: true
|
||||
allow_custom_signals: true
|
||||
talent_templates: []
|
||||
teams: []
|
||||
team_runtime:
|
||||
default_team_id: ''
|
||||
shared_role_session_scope: team
|
||||
allow_shared_role_sessions: true
|
||||
seat_refresh_interval_seconds: 30
|
||||
metadata: {}
|
||||
installed_packages: []
|
||||
role_serial_queue_enabled: true
|
||||
metadata:
|
||||
source: org_mode
|
||||
organization_config_file: company_orgs/org_software-development_config.yaml
|
||||
@@ -3952,6 +3952,23 @@ class OPCStore:
|
||||
current = {}
|
||||
if token_record is None:
|
||||
current.pop(agent, None)
|
||||
legacy_agent = str(
|
||||
current.get("external_resume_agent_type")
|
||||
or (
|
||||
current.get("selected_execution_agent")
|
||||
if current.get("external_resume_session_id")
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if legacy_agent == agent:
|
||||
for key in (
|
||||
"external_resume_session_id",
|
||||
"external_resume_session_scope_id",
|
||||
"external_resume_agent_type",
|
||||
"resume_source_session",
|
||||
):
|
||||
current.pop(key, None)
|
||||
else:
|
||||
current[agent] = {
|
||||
str(k): v for k, v in dict(token_record).items()
|
||||
|
||||
+92
-4
@@ -665,6 +665,7 @@ class OPCEngine:
|
||||
self.approval_engine,
|
||||
task_preparer=self._build_external_agent_task,
|
||||
communication=self.communication,
|
||||
org_engine=self.org_engine,
|
||||
)
|
||||
self.secretary = SecretaryService(
|
||||
llm=self.llm,
|
||||
@@ -11330,6 +11331,53 @@ class OPCEngine:
|
||||
if callable(reset):
|
||||
await reset(tasks, payload=payload)
|
||||
|
||||
def _merge_company_runtime_checkpoint_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
supplement: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
merged = copy.deepcopy(payload)
|
||||
|
||||
merged["task_ids"] = list(dict.fromkeys([
|
||||
*list(merged.get("task_ids", []) or []),
|
||||
*list(supplement.get("task_ids", []) or []),
|
||||
]))
|
||||
for key, identity_key in (
|
||||
("task_snapshots", "task_id"),
|
||||
("active_work_items", "work_item_id"),
|
||||
):
|
||||
indexed = {
|
||||
str(item.get(identity_key, "") or "").strip(): copy.deepcopy(item)
|
||||
for item in list(merged.get(key, []) or [])
|
||||
if isinstance(item, dict)
|
||||
and str(item.get(identity_key, "") or "").strip()
|
||||
}
|
||||
for item in list(supplement.get(key, []) or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
identity = str(item.get(identity_key, "") or "").strip()
|
||||
if identity:
|
||||
indexed[identity] = copy.deepcopy(item)
|
||||
merged[key] = list(indexed.values())
|
||||
|
||||
for key in ("role_runtime_session_ids", "seat_state_ids"):
|
||||
merged[key] = list(dict.fromkeys([
|
||||
*list(merged.get(key, []) or []),
|
||||
*list(supplement.get(key, []) or []),
|
||||
]))
|
||||
for key in (
|
||||
"native_runtime_resume",
|
||||
"adapter_session_state",
|
||||
"external_sessions",
|
||||
"progress_tail",
|
||||
):
|
||||
merged[key] = {
|
||||
**dict(merged.get(key, {}) or {}),
|
||||
**copy.deepcopy(dict(supplement.get(key, {}) or {})),
|
||||
}
|
||||
merged["basis_hash"] = self._checkpoint_basis_hash(merged)
|
||||
return merged
|
||||
|
||||
async def _load_company_suspend_checkpoint_runtime(
|
||||
self,
|
||||
checkpoint: ExecutionCheckpoint,
|
||||
@@ -11352,16 +11400,56 @@ class OPCEngine:
|
||||
task = await self.store.get_task(task_id)
|
||||
if task:
|
||||
tasks.append(task)
|
||||
if not tasks and parent_session_id:
|
||||
|
||||
plan_data = payload.get("company_work_item_plan") or payload.get("plan") or {}
|
||||
plan = deserialize_company_work_item_runtime_plan(plan_data if isinstance(plan_data, dict) else {})
|
||||
if parent_session_id:
|
||||
snapshot = await self._load_company_runtime_snapshot(parent_session_id)
|
||||
if snapshot:
|
||||
_plan, tasks = snapshot
|
||||
snapshot_plan, current_tasks = snapshot
|
||||
if not tasks:
|
||||
tasks = current_tasks
|
||||
if not plan.projections:
|
||||
plan = snapshot_plan
|
||||
else:
|
||||
known_task_ids = {task.id for task in tasks}
|
||||
held_task_ids = await self._company_suspend_resume_candidate_task_ids(
|
||||
current_tasks
|
||||
)
|
||||
supplemental_tasks = [
|
||||
task
|
||||
for task in current_tasks
|
||||
if task.id not in known_task_ids and task.id in held_task_ids
|
||||
]
|
||||
if supplemental_tasks:
|
||||
supplement = await self._company_runtime_checkpoint_payload(
|
||||
checkpoint_type=str(
|
||||
checkpoint.checkpoint_type
|
||||
or payload.get("checkpoint_type")
|
||||
or "company_runtime_interrupted"
|
||||
),
|
||||
reason=str(payload.get("reason", "") or "runtime_resume_reconciliation"),
|
||||
parent_session_id=parent_session_id,
|
||||
origin_task_id=str(
|
||||
checkpoint.task_id
|
||||
or payload.get("origin_task_id")
|
||||
or ""
|
||||
).strip()
|
||||
or None,
|
||||
plan=plan,
|
||||
tasks=supplemental_tasks,
|
||||
stop_intent_id=str(payload.get("stop_intent_id", "") or "").strip()
|
||||
or None,
|
||||
)
|
||||
payload = self._merge_company_runtime_checkpoint_payload(
|
||||
payload,
|
||||
supplement,
|
||||
)
|
||||
tasks.extend(supplemental_tasks)
|
||||
if not tasks:
|
||||
await self.store.resolve_execution_checkpoint(checkpoint.checkpoint_id, status="invalid")
|
||||
return None
|
||||
|
||||
plan_data = payload.get("company_work_item_plan") or payload.get("plan") or {}
|
||||
plan = deserialize_company_work_item_runtime_plan(plan_data if isinstance(plan_data, dict) else {})
|
||||
payload["checkpoint_id"] = checkpoint.checkpoint_id
|
||||
payload["checkpoint_type"] = checkpoint.checkpoint_type
|
||||
return payload, parent_session_id, plan, tasks
|
||||
|
||||
@@ -5661,7 +5661,18 @@ class CompanyWorkItemExecutor:
|
||||
await self._emit_progress(f"[Company:{projection_id}] awaiting peer", task_id=task.id)
|
||||
await self.save_task(task)
|
||||
else:
|
||||
await transition_work_item_from_task(
|
||||
self.store,
|
||||
task,
|
||||
target_status_or_phase=result.status,
|
||||
reason=f"work_item_{result.status.value}",
|
||||
)
|
||||
await self._append_progress(
|
||||
task,
|
||||
f"Work item ended with status {result.status.value}.",
|
||||
)
|
||||
await self._emit_progress(f"[Company:{projection_id}] failed", task_id=task.id)
|
||||
await self.save_task(task)
|
||||
return result
|
||||
|
||||
# Comms park check: if the agent sent any blocking messages
|
||||
@@ -7532,12 +7543,19 @@ class CompanyWorkItemExecutor:
|
||||
metadata_updates["review_resolution"] = copy.deepcopy(resolution)
|
||||
metadata_updates["review_resolution_state"] = "pending"
|
||||
try:
|
||||
current = None
|
||||
if hasattr(self.store, "get_delegation_work_item"):
|
||||
current = await self.store.get_delegation_work_item(review_work_item_id)
|
||||
update_kwargs: dict[str, Any] = {
|
||||
"claimed_by_role_runtime_session_id": "",
|
||||
"claimed_by_seat_id": "",
|
||||
"metadata_updates": metadata_updates,
|
||||
}
|
||||
if current is None or current.phase not in DONE_PHASES or current.phase == phase:
|
||||
update_kwargs["phase"] = phase
|
||||
persisted = await self.store.update_delegation_work_item(
|
||||
review_work_item_id,
|
||||
phase=phase,
|
||||
claimed_by_role_runtime_session_id="",
|
||||
claimed_by_seat_id="",
|
||||
metadata_updates=metadata_updates,
|
||||
**update_kwargs,
|
||||
)
|
||||
except Exception:
|
||||
logger.opt(exception=True).warning(
|
||||
|
||||
@@ -1710,6 +1710,7 @@ class CompanyRuntime:
|
||||
**dict(session.resume_state),
|
||||
**runtime_state,
|
||||
}
|
||||
await self._refresh_adapter_session_state_from_store(session)
|
||||
adapter_state_updates = self._adapter_state_from_result(task, result)
|
||||
if adapter_state_updates:
|
||||
session.adapter_session_state = {
|
||||
@@ -1899,6 +1900,31 @@ class CompanyRuntime:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
async def _refresh_adapter_session_state_from_store(
|
||||
self,
|
||||
session: CompanyMemberSession,
|
||||
) -> None:
|
||||
role_session_id = str(session.role_session_id or "").strip()
|
||||
getter = getattr(self.store, "get_delegation_role_session", None)
|
||||
if not role_session_id or not callable(getter):
|
||||
return
|
||||
try:
|
||||
persisted = await getter(role_session_id)
|
||||
except Exception:
|
||||
logger.opt(exception=True).debug(
|
||||
"company runtime adapter-state refresh failed"
|
||||
)
|
||||
return
|
||||
if persisted is None:
|
||||
return
|
||||
adapter_state = dict(
|
||||
getattr(persisted, "adapter_session_state", {}) or {}
|
||||
)
|
||||
session.adapter_session_state = adapter_state
|
||||
role_session = self.role_sessions.get(role_session_id)
|
||||
if role_session is not None:
|
||||
role_session.adapter_session_state = dict(adapter_state)
|
||||
|
||||
def _ensure_role_session(self, task: Task) -> DelegationRoleSession | None:
|
||||
role_id = self._role_id(task)
|
||||
if not role_id:
|
||||
|
||||
@@ -517,7 +517,28 @@ class ExternalAgentAdapter(abc.ABC):
|
||||
merged = {**os.environ, **{str(k): str(v) for k, v in extra_env.items()}}
|
||||
path_value = merged.get("PATH") or merged.get("Path") or merged.get("path") or ""
|
||||
resolved = shutil.which(executable, path=path_value or None)
|
||||
if not resolved or resolved == executable:
|
||||
if not resolved:
|
||||
return cmd
|
||||
|
||||
if os.name == "nt":
|
||||
resolved = ExternalAgentAdapter._resolve_windows_command_shim(
|
||||
executable,
|
||||
resolved,
|
||||
path_value=path_value,
|
||||
)
|
||||
|
||||
if os.name == "nt" and os.path.splitext(resolved)[1].lower() in {".cmd", ".bat"}:
|
||||
comspec = str(os.environ.get("COMSPEC") or "").strip()
|
||||
if not comspec:
|
||||
comspec = shutil.which("cmd.exe") or "cmd.exe"
|
||||
wrapped_cmd = [comspec, "/d", "/s", "/c", resolved, *cmd[1:]]
|
||||
if isinstance(launch_metadata, dict):
|
||||
launch_metadata.setdefault("configured_binary", executable)
|
||||
launch_metadata["resolved_binary"] = resolved
|
||||
launch_metadata["launch_wrapper"] = comspec
|
||||
return wrapped_cmd
|
||||
|
||||
if resolved == executable:
|
||||
return cmd
|
||||
resolved_cmd = list(cmd)
|
||||
resolved_cmd[0] = resolved
|
||||
@@ -526,6 +547,31 @@ class ExternalAgentAdapter(abc.ABC):
|
||||
launch_metadata["resolved_binary"] = resolved
|
||||
return resolved_cmd
|
||||
|
||||
@staticmethod
|
||||
def _resolve_windows_command_shim(
|
||||
executable: str,
|
||||
resolved: str,
|
||||
*,
|
||||
path_value: str = "",
|
||||
) -> str:
|
||||
"""Prefer a Windows-launchable sibling over an extensionless POSIX shim."""
|
||||
if os.path.splitext(resolved)[1]:
|
||||
return resolved
|
||||
|
||||
for extension in (".exe", ".com", ".cmd", ".bat"):
|
||||
sibling = f"{resolved}{extension}"
|
||||
if os.path.isfile(sibling):
|
||||
return sibling
|
||||
|
||||
if os.path.dirname(executable):
|
||||
return resolved
|
||||
|
||||
for extension in (".exe", ".com", ".cmd", ".bat"):
|
||||
candidate = shutil.which(f"{executable}{extension}", path=path_value or None)
|
||||
if candidate:
|
||||
return candidate
|
||||
return resolved
|
||||
|
||||
async def send_process_input(
|
||||
self,
|
||||
proc: asyncio.subprocess.Process,
|
||||
|
||||
@@ -32,6 +32,8 @@ class CodexAdapter(ExternalAgentAdapter):
|
||||
"CODEX_SANDBOX_NETWORK_DISABLED",
|
||||
"CODEX_THREAD_ID",
|
||||
}
|
||||
_RUNTIME_SHELL_ENV_KEYS = {"PYTHONPATH"}
|
||||
_RUNTIME_SHELL_ENV_PREFIXES = ("OPC_",)
|
||||
_PROMPT_SENTINEL = "-"
|
||||
_TTY_EOF = b"\x04"
|
||||
_INTERACTIVE_ARGV_PROMPT_MAX_BYTES = 16 * 1024
|
||||
@@ -118,6 +120,64 @@ class CodexAdapter(ExternalAgentAdapter):
|
||||
normalized = key.upper()
|
||||
return normalized in cls._PARENT_CODEX_RUNTIME_ENV_VARS
|
||||
|
||||
@classmethod
|
||||
def _runtime_shell_environment_config_args(
|
||||
cls,
|
||||
extra_env: dict[str, str] | None,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Forward only OpenOPC's runtime contract into Codex shell tools.
|
||||
|
||||
Codex can intentionally run with ``shell_environment_policy.inherit=core``.
|
||||
The broker process still receives ``OPC_*`` variables, but commands launched
|
||||
by Codex do not unless they are added to the policy's ``set`` table.
|
||||
"""
|
||||
forwarded: dict[str, str] = {}
|
||||
for raw_key, raw_value in dict(extra_env or {}).items():
|
||||
key = str(raw_key).strip()
|
||||
if not key:
|
||||
continue
|
||||
if key not in cls._RUNTIME_SHELL_ENV_KEYS and not key.startswith(
|
||||
cls._RUNTIME_SHELL_ENV_PREFIXES
|
||||
):
|
||||
continue
|
||||
value = str(raw_value)
|
||||
if key == "OPC_ALLOWED_COLLAB_TOOLS":
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
value = ",".join(
|
||||
str(item).strip() for item in parsed if str(item).strip()
|
||||
)
|
||||
forwarded[key] = value
|
||||
|
||||
args: list[str] = []
|
||||
for key in sorted(forwarded):
|
||||
value = forwarded[key]
|
||||
if "'''" in value:
|
||||
encoded = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
encoded = f"'''{value}'''"
|
||||
args.extend(["-c", f"shell_environment_policy.set.{key}={encoded}"])
|
||||
return args, sorted(forwarded)
|
||||
|
||||
@classmethod
|
||||
def _inject_runtime_shell_environment(
|
||||
cls,
|
||||
cmd: list[str],
|
||||
extra_env: dict[str, str] | None,
|
||||
launch_metadata: dict[str, Any] | None = None,
|
||||
) -> list[str]:
|
||||
config_args, forwarded_keys = cls._runtime_shell_environment_config_args(extra_env)
|
||||
if not config_args or not cmd:
|
||||
return list(cmd)
|
||||
insert_at = 2 if len(cmd) > 1 and str(cmd[1]).strip() == "exec" else 1
|
||||
launch_cmd = [*cmd[:insert_at], *config_args, *cmd[insert_at:]]
|
||||
if isinstance(launch_metadata, dict):
|
||||
launch_metadata["runtime_shell_environment_keys"] = forwarded_keys
|
||||
return launch_cmd
|
||||
|
||||
def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
|
||||
args: list[str] = []
|
||||
if workspace_path:
|
||||
@@ -564,8 +624,13 @@ class CodexAdapter(ExternalAgentAdapter):
|
||||
prompt_transport = str((launch_metadata or {}).get("prompt_transport") or "").strip().lower()
|
||||
prompt = self._prompt_text_from_task(task) if prompt_transport == "stdin" else ""
|
||||
env = self.build_process_env(extra_env)
|
||||
launch_cmd = self._resolve_launch_command(
|
||||
runtime_cmd = self._inject_runtime_shell_environment(
|
||||
cmd,
|
||||
extra_env,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
launch_cmd = self._resolve_launch_command(
|
||||
runtime_cmd,
|
||||
extra_env=extra_env,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
|
||||
@@ -92,11 +92,13 @@ class ExternalAgentBroker:
|
||||
approval_engine: ApprovalEngine,
|
||||
task_preparer: Callable[[Task], Coroutine[Any, Any, Task]] | None = None,
|
||||
communication: CommunicationManager | None = None,
|
||||
org_engine: Any | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.approval_engine = approval_engine
|
||||
self.task_preparer = task_preparer
|
||||
self.communication = communication
|
||||
self.org_engine = org_engine
|
||||
|
||||
@staticmethod
|
||||
def _normalize_external_agent_choice(value: Any) -> str:
|
||||
@@ -137,19 +139,19 @@ class ExternalAgentBroker:
|
||||
)
|
||||
return selected
|
||||
|
||||
async def _provider_stream_token_allows_resume(
|
||||
async def _stored_provider_token_allows_resume(
|
||||
self,
|
||||
*,
|
||||
adapter: ExternalAgentAdapter,
|
||||
task: Task,
|
||||
role_session_id: str,
|
||||
token: str,
|
||||
) -> bool:
|
||||
"""Cross-check an early stream token against its latest run status."""
|
||||
) -> bool | None:
|
||||
"""Return the latest durable resumability verdict for one provider token."""
|
||||
|
||||
list_sessions = getattr(self.store, "list_external_sessions", None)
|
||||
if not callable(list_sessions):
|
||||
return False
|
||||
return None
|
||||
project_id = str(task.project_id or "default").strip() or "default"
|
||||
kwargs: dict[str, Any] = {"project_id": project_id, "limit": 100}
|
||||
if role_session_id:
|
||||
@@ -160,19 +162,30 @@ class ExternalAgentBroker:
|
||||
sessions = await list_sessions(**kwargs)
|
||||
except Exception:
|
||||
logger.opt(exception=True).debug(
|
||||
"External resume restore: provider-stream status lookup failed"
|
||||
"External resume restore: provider-token status lookup failed"
|
||||
)
|
||||
return False
|
||||
for session in sessions:
|
||||
if str(getattr(session, "agent_type", "") or "").strip() != adapter.agent_type:
|
||||
continue
|
||||
if external_session_matches_provider_token(session, token):
|
||||
return (
|
||||
external_session_allows_resume(session)
|
||||
and str(getattr(session, "status", "") or "").strip().lower()
|
||||
in {"done", "suspended"}
|
||||
)
|
||||
return False
|
||||
return None
|
||||
matching = [
|
||||
session
|
||||
for session in sessions
|
||||
if str(getattr(session, "agent_type", "") or "").strip()
|
||||
== adapter.agent_type
|
||||
and external_session_matches_provider_token(session, token)
|
||||
]
|
||||
if not matching:
|
||||
return None
|
||||
selected, selected_token = select_best_external_resume_session(
|
||||
matching,
|
||||
agent_type=adapter.agent_type,
|
||||
project_id=project_id,
|
||||
)
|
||||
return bool(
|
||||
selected is not None
|
||||
and selected_token == token
|
||||
and external_session_allows_resume(selected)
|
||||
and str(getattr(selected, "status", "") or "").strip().lower()
|
||||
in {"done", "suspended"}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _task_explicitly_selected_external_agent(cls, task: Task, agent_type: str) -> bool:
|
||||
@@ -474,9 +487,12 @@ class ExternalAgentBroker:
|
||||
return
|
||||
if str(getattr(config, "session_mode", "") or "").strip().lower() == "new":
|
||||
return
|
||||
# Respect already-configured resume state.
|
||||
if str(getattr(config, "session_mode", "") or "").strip().lower() == "resume" and getattr(config, "session_id", ""):
|
||||
return
|
||||
configured_session_token = (
|
||||
str(getattr(config, "session_id", "") or "").strip()
|
||||
if str(getattr(config, "session_mode", "") or "").strip().lower()
|
||||
== "resume"
|
||||
else ""
|
||||
)
|
||||
|
||||
store = getattr(self, "store", None)
|
||||
if store is None or not hasattr(store, "get_external_session"):
|
||||
@@ -494,7 +510,7 @@ class ExternalAgentBroker:
|
||||
# Fix 5 PR6: canonical source is ``role_runtime_session.adapter_session_state[agent_type]``.
|
||||
# Check it first; the ExternalSession table is a compatibility
|
||||
# fallback for legacy rows written before PR6 landed.
|
||||
session_token = ""
|
||||
session_token = configured_session_token
|
||||
if role_session_id and hasattr(store, "get_role_session_adapter_state"):
|
||||
try:
|
||||
entry = await store.get_role_session_adapter_state(
|
||||
@@ -507,28 +523,29 @@ class ExternalAgentBroker:
|
||||
)
|
||||
entry = None
|
||||
if isinstance(entry, dict):
|
||||
session_token = str(
|
||||
role_session_token = str(
|
||||
entry.get("resume_session_id")
|
||||
or entry.get("provider_session_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not is_provider_session_token(
|
||||
session_token,
|
||||
role_session_token,
|
||||
agent_type=adapter.agent_type,
|
||||
project_id=project_id,
|
||||
):
|
||||
session_token = ""
|
||||
if (
|
||||
session_token
|
||||
and str(entry.get("source", "") or "").strip()
|
||||
== "provider_stream"
|
||||
and not await self._provider_stream_token_allows_resume(
|
||||
role_session_token = ""
|
||||
session_token = session_token or role_session_token
|
||||
token_allows_resume = (
|
||||
await self._stored_provider_token_allows_resume(
|
||||
adapter=adapter,
|
||||
task=task,
|
||||
role_session_id=role_session_id,
|
||||
token=session_token,
|
||||
)
|
||||
):
|
||||
if session_token
|
||||
else None
|
||||
)
|
||||
if session_token and token_allows_resume is False:
|
||||
session_token = ""
|
||||
clear_role_state = getattr(
|
||||
store,
|
||||
@@ -544,8 +561,23 @@ class ExternalAgentBroker:
|
||||
)
|
||||
except Exception:
|
||||
logger.opt(exception=True).debug(
|
||||
"External resume restore: stale provider-stream token clear failed"
|
||||
"External resume restore: stale provider token clear failed"
|
||||
)
|
||||
if hasattr(config, "session_mode"):
|
||||
config.session_mode = "new"
|
||||
if hasattr(config, "session_id"):
|
||||
config.session_id = ""
|
||||
task.metadata = dict(task.metadata or {})
|
||||
task.metadata.pop("external_resume_session_id", None)
|
||||
task.metadata.pop("external_resume_session_scope_id", None)
|
||||
task.metadata.pop("external_resume_agent_type", None)
|
||||
task.metadata["external_resume_fallback"] = (
|
||||
"provider_terminal_failure"
|
||||
)
|
||||
if on_progress:
|
||||
await on_progress(
|
||||
f"[External resume] {adapter.agent_type} skipped prior failed session"
|
||||
)
|
||||
# Do not immediately rediscover the same unfinalized or
|
||||
# failed stream row through the compatibility fallback.
|
||||
return
|
||||
@@ -2552,20 +2584,34 @@ class ExternalAgentBroker:
|
||||
and hasattr(self.store, "get_role_session_adapter_state")
|
||||
and hasattr(self.store, "update_role_session_adapter_state")
|
||||
):
|
||||
# A stream token is durable early so Stop can retain it. A normal
|
||||
# terminal failure, however, must not leave that attempt's token
|
||||
# pinned for a later unrelated turn.
|
||||
# A stream token is durable early so Stop can retain it. A normal
|
||||
# terminal failure must clear both a token discovered by this task
|
||||
# and an older role token that this failed attempt resumed.
|
||||
try:
|
||||
current = await self.store.get_role_session_adapter_state(
|
||||
role_session_id,
|
||||
adapter.agent_type,
|
||||
)
|
||||
current_token = ""
|
||||
if isinstance(current, dict):
|
||||
current_token = str(
|
||||
current.get("resume_session_id")
|
||||
or current.get("provider_session_id")
|
||||
or ""
|
||||
).strip()
|
||||
failed_token = str(
|
||||
resume_session_id
|
||||
or provider_session_id
|
||||
or getattr(adapter.config, "session_id", "")
|
||||
or ""
|
||||
).strip()
|
||||
if (
|
||||
isinstance(current, dict)
|
||||
and str(current.get("last_task_id", "") or "").strip()
|
||||
== str(task.id or "").strip()
|
||||
and str(current.get("source", "") or "").strip()
|
||||
== "provider_stream"
|
||||
and (
|
||||
str(current.get("last_task_id", "") or "").strip()
|
||||
== str(task.id or "").strip()
|
||||
or bool(failed_token and current_token == failed_token)
|
||||
)
|
||||
):
|
||||
await self.store.update_role_session_adapter_state(
|
||||
role_session_id,
|
||||
@@ -2576,3 +2622,19 @@ class ExternalAgentBroker:
|
||||
logger.opt(exception=True).debug(
|
||||
"Failed to clear provider-stream role state after terminal failure"
|
||||
)
|
||||
if result.status != TaskStatus.DONE:
|
||||
failed_token = str(
|
||||
resume_session_id
|
||||
or provider_session_id
|
||||
or getattr(adapter.config, "session_id", "")
|
||||
or ""
|
||||
).strip()
|
||||
task_token = str(
|
||||
(task.metadata or {}).get("external_resume_session_id", "") or ""
|
||||
).strip()
|
||||
if failed_token and task_token == failed_token:
|
||||
task.metadata = dict(task.metadata or {})
|
||||
task.metadata.pop("external_resume_session_id", None)
|
||||
task.metadata.pop("external_resume_session_scope_id", None)
|
||||
task.metadata.pop("external_resume_agent_type", None)
|
||||
task.metadata["external_resume_fallback"] = "provider_terminal_failure"
|
||||
|
||||
@@ -61,6 +61,23 @@ def provider_token_from_external_session(
|
||||
) -> str:
|
||||
if not external_session_allows_resume(session):
|
||||
return ""
|
||||
return _provider_token_from_session_identity(
|
||||
session,
|
||||
agent_type=agent_type,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
|
||||
def _provider_token_from_session_identity(
|
||||
session: Any | None,
|
||||
*,
|
||||
agent_type: str,
|
||||
project_id: str,
|
||||
) -> str:
|
||||
"""Extract a provider token without treating its row as resumable."""
|
||||
|
||||
if session is None:
|
||||
return ""
|
||||
metadata = dict(getattr(session, "metadata", {}) or {})
|
||||
for candidate in (
|
||||
metadata.get("resume_session_id"),
|
||||
@@ -108,7 +125,7 @@ def select_best_external_resume_session(
|
||||
) -> tuple[Any | None, str]:
|
||||
"""Select the newest valid provider capability, ignoring placeholders."""
|
||||
|
||||
valid: list[tuple[Any, str]] = []
|
||||
latest_by_token: dict[str, Any] = {}
|
||||
normalized_agent = str(agent_type or "").strip()
|
||||
for session in list(sessions or []):
|
||||
if (
|
||||
@@ -116,23 +133,35 @@ def select_best_external_resume_session(
|
||||
!= normalized_agent
|
||||
):
|
||||
continue
|
||||
token = provider_token_from_external_session(
|
||||
token = _provider_token_from_session_identity(
|
||||
session,
|
||||
agent_type=normalized_agent,
|
||||
project_id=project_id,
|
||||
)
|
||||
if token:
|
||||
valid.append((session, token))
|
||||
if not token:
|
||||
continue
|
||||
current = latest_by_token.get(token)
|
||||
if current is None or _session_timestamp(session) > _session_timestamp(current):
|
||||
latest_by_token[token] = session
|
||||
|
||||
valid = [
|
||||
(session, token)
|
||||
for token, session in latest_by_token.items()
|
||||
if external_session_allows_resume(session)
|
||||
]
|
||||
if not valid:
|
||||
return None, ""
|
||||
|
||||
def _sort_key(item: tuple[Any, str]) -> tuple[float, str]:
|
||||
session, token = item
|
||||
updated_at = getattr(session, "updated_at", None)
|
||||
try:
|
||||
timestamp = float(updated_at.timestamp())
|
||||
except Exception:
|
||||
timestamp = 0.0
|
||||
return timestamp, token
|
||||
return _session_timestamp(session), token
|
||||
|
||||
return max(valid, key=_sort_key)
|
||||
|
||||
|
||||
def _session_timestamp(session: Any) -> float:
|
||||
updated_at = getattr(session, "updated_at", None)
|
||||
try:
|
||||
return float(updated_at.timestamp())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@@ -91,6 +91,19 @@ def _ensure_aiohttp() -> None:
|
||||
_pip_install(*missing)
|
||||
|
||||
|
||||
def _run_npm(npm: str, *args: str) -> None:
|
||||
"""Run npm in a way that also works when Windows resolves npm.cmd."""
|
||||
cmd = [npm, *args]
|
||||
if sys.platform == "win32":
|
||||
npm_path = Path(npm)
|
||||
npm_cmd = npm_path.with_suffix(".cmd")
|
||||
if npm_path.suffix.lower() in {".cmd", ".bat"}:
|
||||
cmd = [os.environ.get("COMSPEC", "cmd.exe"), "/c", npm, *args]
|
||||
elif npm_cmd.exists():
|
||||
cmd = [os.environ.get("COMSPEC", "cmd.exe"), "/c", str(npm_cmd), *args]
|
||||
subprocess.check_call(cmd, cwd=_FRONTEND_SRC)
|
||||
|
||||
|
||||
def _ensure_frontend() -> None:
|
||||
"""Build frontend if frontend_dist is missing, empty, or stale."""
|
||||
if not _frontend_needs_rebuild():
|
||||
@@ -114,11 +127,11 @@ def _ensure_frontend() -> None:
|
||||
node_modules = _FRONTEND_SRC / "node_modules"
|
||||
if not node_modules.is_dir():
|
||||
terminal_status("Installing npm dependencies")
|
||||
subprocess.check_call([npm, "install"], cwd=_FRONTEND_SRC)
|
||||
_run_npm(npm, "install")
|
||||
|
||||
# npm run build → outputs to ../frontend_dist via vite config
|
||||
terminal_status("Building frontend bundle")
|
||||
subprocess.check_call([npm, "run", "build"], cwd=_FRONTEND_SRC)
|
||||
_run_npm(npm, "run", "build")
|
||||
|
||||
if _FRONTEND_DIST.is_dir() and any(_FRONTEND_DIST.iterdir()):
|
||||
terminal_status("Frontend built successfully", kind="success")
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,9 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>OpenOPC Pixel Office</title>
|
||||
<script type="module" crossorigin src="./assets/index-BgyI65M_.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-Dw3OK9za.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DEvLDWDw.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B6-ikSHW.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { extractSessionRecruitmentByRole, sessionChannelId } from './lib/session
|
||||
import { resolveCanonicalTurnId, terminalAssistantTurnId } from './lib/turnIdentity'
|
||||
import { unassignAgent } from './game/map/OfficeStore'
|
||||
import type { AgentAnimStatus, EmployeeAssignment, KanbanPhase, KanbanTask, RoleAggregatedStatus, RoleWorkItemSummary, Session, TaskPreferredAgent } from './types/kanban'
|
||||
import { useI18n } from './i18n'
|
||||
|
||||
function readOutdoorOverrideUi(): 'auto' | 'day' | 'night' {
|
||||
try {
|
||||
@@ -444,6 +445,7 @@ function MaybeExecutionPanel({ taskId, sessions, agents, onClose }: {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { locale, setLocale, t, translateMaybe } = useI18n()
|
||||
const bridgeRef = useRef(new GameBridge())
|
||||
useMemo(() => registerTestRunner(bridgeRef.current), [])
|
||||
const clientRef = useRef<VisualSocketClient | null>(null)
|
||||
@@ -2397,26 +2399,47 @@ export default function App() {
|
||||
<div className="topbar-center">
|
||||
<div className="page-nav">
|
||||
<button className={`page-nav-btn${activePage === 'workspace' ? ' active' : ''}`} onClick={() => setActivePage('workspace')}>
|
||||
Workspace
|
||||
{t('app.page.workspace')}
|
||||
{(() => {
|
||||
const total = chatStore.channels.reduce((sum, ch) => sum + chatStore.getUnreadCount(ch.id), 0)
|
||||
return total > 0 ? <span className="nav-unread-badge">{total > 99 ? '99+' : total}</span> : null
|
||||
})()}
|
||||
</button>
|
||||
<button className={`page-nav-btn${activePage === 'office' ? ' active' : ''}`} onClick={() => setActivePage('office')}>Office</button>
|
||||
<button className={`page-nav-btn${activePage === 'org' ? ' active' : ''}`} onClick={() => setActivePage('org')}>Org</button>
|
||||
<button className={`page-nav-btn${activePage === 'office' ? ' active' : ''}`} onClick={() => setActivePage('office')}>{t('app.page.office')}</button>
|
||||
<button className={`page-nav-btn${activePage === 'org' ? ' active' : ''}`} onClick={() => setActivePage('org')}>{t('app.page.org')}</button>
|
||||
</div>
|
||||
<div className="stat-chips">
|
||||
<span className="stat-chip"><b>{metrics.totalAgents}</b> agents</span>
|
||||
<span className="stat-chip"><b>{metrics.totalSkills}</b> skills</span>
|
||||
<span className="stat-chip"><b>{boardStore.getOpenTaskCount()}</b> tasks</span>
|
||||
<span className="stat-chip"><b>{metrics.totalAgents}</b> {t('app.metric.agents')}</span>
|
||||
<span className="stat-chip"><b>{metrics.totalSkills}</b> {t('app.metric.skills')}</span>
|
||||
<span className="stat-chip"><b>{boardStore.getOpenTaskCount()}</b> {t('app.metric.tasks')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<div className="language-toggle" role="group" aria-label={t('language.label')} title={t('language.label')}>
|
||||
<button
|
||||
type="button"
|
||||
data-locale="en"
|
||||
aria-pressed={locale === 'en'}
|
||||
className={`language-toggle-btn${locale === 'en' ? ' active' : ''}`}
|
||||
onClick={() => setLocale('en')}
|
||||
>
|
||||
{t('language.english')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-locale="zh-CN"
|
||||
aria-pressed={locale === 'zh-CN'}
|
||||
className={`language-toggle-btn${locale === 'zh-CN' ? ' active' : ''}`}
|
||||
onClick={() => setLocale('zh-CN')}
|
||||
>
|
||||
{t('language.chinese')}
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
className="theme-select"
|
||||
value={outdoorOverride}
|
||||
title="Outdoor lighting override"
|
||||
title={t('outdoor.title')}
|
||||
aria-label={t('outdoor.title')}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as 'auto' | 'day' | 'night'
|
||||
setOutdoorOverride(v)
|
||||
@@ -2434,20 +2457,20 @@ export default function App() {
|
||||
bridgeRef.current.syncOutdoorLighting()
|
||||
}}
|
||||
>
|
||||
<option value="auto">Outdoor auto</option>
|
||||
<option value="day">Outdoor day</option>
|
||||
<option value="night">Outdoor night</option>
|
||||
<option value="auto">{t('outdoor.auto')}</option>
|
||||
<option value="day">{t('outdoor.day')}</option>
|
||||
<option value="night">{t('outdoor.night')}</option>
|
||||
</select>
|
||||
<select className="theme-select" value={theme} onChange={(e) => setTheme(e.target.value as ThemeName)}>
|
||||
<option value="midnight">Midnight</option>
|
||||
<option value="neon">Neon</option>
|
||||
<option value="paper">Paper</option>
|
||||
<option value="retro">Retro</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
<option value="cozy">Cozy</option>
|
||||
<option value="openopc">OpenOPC</option>
|
||||
<option value="midnight">{t('theme.midnight')}</option>
|
||||
<option value="neon">{t('theme.neon')}</option>
|
||||
<option value="paper">{t('theme.paper')}</option>
|
||||
<option value="retro">{t('theme.retro')}</option>
|
||||
<option value="terminal">{t('theme.terminal')}</option>
|
||||
<option value="cozy">{t('theme.cozy')}</option>
|
||||
<option value="openopc">{t('theme.openopc')}</option>
|
||||
</select>
|
||||
<button className={`icon-btn ${showDevTools ? 'active' : ''}`} onClick={() => setShowDevTools((v) => !v)} title="Developer Tools">
|
||||
<button className={`icon-btn ${showDevTools ? 'active' : ''}`} onClick={() => setShowDevTools((v) => !v)} title={t('dev.tools')} aria-label={t('dev.tools')}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M5.5 2L2 5.5 5.5 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><path d="M10.5 7L14 10.5 10.5 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2602,14 +2625,14 @@ export default function App() {
|
||||
{/* Phaser Game Canvas */}
|
||||
<section className="canvas-wrap">
|
||||
<PhaserGame bridge={bridgeRef.current} active={activePage === 'office'} />
|
||||
<button className="canvas-float-btn" onClick={() => setShowSubagents((v) => !v)} title={showSubagents ? 'Hide sub-agents' : 'Show sub-agents'}>
|
||||
<button className="canvas-float-btn" onClick={() => setShowSubagents((v) => !v)} title={showSubagents ? t('office.hideSubagents') : t('office.showSubagents')}>
|
||||
{showSubagents ? '👥' : '👤'}
|
||||
</button>
|
||||
<button
|
||||
className="sidebar-collapse-btn"
|
||||
onClick={toggleSidebar}
|
||||
title={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'}
|
||||
aria-label={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'}
|
||||
title={sidebarCollapsed ? t('office.showSidePanel') : t('office.hideSidePanel')}
|
||||
aria-label={sidebarCollapsed ? t('office.showSidePanel') : t('office.hideSidePanel')}
|
||||
>
|
||||
<span className="collapse-glyph">{sidebarCollapsed ? '❮' : '❯'}</span>
|
||||
</button>
|
||||
@@ -2624,13 +2647,13 @@ export default function App() {
|
||||
<div className="mode-info-bar">
|
||||
<span className="mode-badge">{globalExecMode === 'company' ? `${globalExecMode}/${globalCompanyProfile}` : globalModeLabel}</span>
|
||||
{isOrgMode ? (
|
||||
<span className="mode-hint">Manage your team in the <b>Org</b> tab</span>
|
||||
<span className="mode-hint">{t('office.modeHint.org')}</span>
|
||||
) : (
|
||||
<span className="mode-hint">Switch to <b>Org</b> mode to create or manage agents</span>
|
||||
<span className="mode-hint">{t('office.modeHint.switch')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-label">Offices <span className="count-badge">{offices.length}</span></div>
|
||||
<div className="section-label">{t('office.offices')} <span className="count-badge">{offices.length}</span></div>
|
||||
<div className="office-cards">
|
||||
{offices.map((office) => {
|
||||
const deskCount = getOfficeDeskSeats(office.id).length
|
||||
@@ -2652,14 +2675,14 @@ export default function App() {
|
||||
) : (
|
||||
<>
|
||||
<span className="office-name">{office.name}</span>
|
||||
<button className="office-edit-btn" title="Rename" onClick={(e) => { e.stopPropagation(); setEditingOfficeName(office.id); setOfficeNameDraft(office.name) }}>✎</button>
|
||||
<button className="office-edit-btn" title={t('office.rename')} onClick={(e) => { e.stopPropagation(); setEditingOfficeName(office.id); setOfficeNameDraft(office.name) }}>✎</button>
|
||||
</>
|
||||
)}
|
||||
<span className="office-capacity">{assignedCards.length}/{deskCount}</span>
|
||||
</div>
|
||||
<div className="office-agents">
|
||||
{assignedCards.map(c => (
|
||||
<span key={c.id} className="office-agent-chip" title={`${c.displayName} — ${c.seatId ?? 'no seat'}`} onClick={(e) => { e.stopPropagation(); selectAgent(c.id) }}>
|
||||
<span key={c.id} className="office-agent-chip" title={`${c.displayName} — ${c.seatId ?? t('office.noSeat')}`} onClick={(e) => { e.stopPropagation(); selectAgent(c.id) }}>
|
||||
{c.displayName.slice(0, 8)}
|
||||
</span>
|
||||
))}
|
||||
@@ -2670,7 +2693,7 @@ export default function App() {
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e => { if (e.target.value) handleAssignAgent(office.id, e.target.value) }}
|
||||
>
|
||||
<option value="">+ Move here</option>
|
||||
<option value="">{t('office.moveHere')}</option>
|
||||
{otherAgents.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.displayName} ({offices.find(o => o.id === (cards.find(cc => cc.id === a.id)?.officeId))?.name ?? '?'})</option>
|
||||
))}
|
||||
@@ -2682,7 +2705,7 @@ export default function App() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="section-label">Active Agents <span className="count-badge">{swarmAgents.length}</span></div>
|
||||
<div className="section-label">{t('office.activeAgents')} <span className="count-badge">{swarmAgents.length}</span></div>
|
||||
<div className="agent-list">
|
||||
{swarmAgents.map((agent) => (
|
||||
<div key={agent.agent_id} className={`agent-row ${selectedAgentId === agent.agent_id ? 'selected' : ''}`}>
|
||||
@@ -2690,7 +2713,7 @@ export default function App() {
|
||||
<span className={`dot ${agent.status}`} />
|
||||
<div className="agent-info">
|
||||
<span className="agent-name">{agent.name}</span>
|
||||
<span className="agent-spec">{agent.specialties.slice(0, 2).join(' · ') || 'general'}</span>
|
||||
<span className="agent-spec">{agent.specialties.slice(0, 2).join(' · ') || t('common.general')}</span>
|
||||
</div>
|
||||
</button>
|
||||
{isOrgMode && (
|
||||
@@ -2698,27 +2721,27 @@ export default function App() {
|
||||
? <span className="agent-del" style={{ pointerEvents: 'none' }}><span className="spinner-inline" /></span>
|
||||
: confirmDeleteId === agent.agent_id
|
||||
? <span className="del-confirm">
|
||||
<span className="del-confirm-label">Delete?</span>
|
||||
<button className="del-confirm-yes" onClick={() => { setDeletingAgentId(agent.agent_id); setConfirmDeleteId(null); clientRef.current?.deleteAgent(agent.agent_id) }}>Yes</button>
|
||||
<button className="del-confirm-no" onClick={() => setConfirmDeleteId(null)}>No</button>
|
||||
<span className="del-confirm-label">{t('office.deleteQuestion')}</span>
|
||||
<button className="del-confirm-yes" onClick={() => { setDeletingAgentId(agent.agent_id); setConfirmDeleteId(null); clientRef.current?.deleteAgent(agent.agent_id) }}>{t('common.yes')}</button>
|
||||
<button className="del-confirm-no" onClick={() => setConfirmDeleteId(null)}>{t('common.no')}</button>
|
||||
</span>
|
||||
: <button className="agent-del" title={`Remove ${agent.name}`} onClick={() => setConfirmDeleteId(agent.agent_id)}>×</button>
|
||||
: <button className="agent-del" title={t('office.removeAgent', { name: agent.name })} onClick={() => setConfirmDeleteId(agent.agent_id)}>×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{swarmAgents.length === 0 && (
|
||||
<div className="empty-state">No agents yet — click a template above to spawn one.</div>
|
||||
<div className="empty-state">{t('office.emptyAgents')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedCard && (
|
||||
<div className="agent-detail">
|
||||
<div className="agent-detail-name">{selectedCard.displayName}</div>
|
||||
<div className="agent-detail-row"><span className="detail-label">State</span><span className="detail-value">{selectedCard.state}</span></div>
|
||||
<div className="agent-detail-row"><span className="detail-label">Tool</span><span className="detail-value">{selectedCard.currentTool ?? '—'}</span></div>
|
||||
<div className="agent-detail-row"><span className="detail-label">Task</span><span className="detail-value">{selectedCard.taskSummary ?? '—'}</span></div>
|
||||
<div className="agent-detail">
|
||||
<div className="agent-detail-name">{selectedCard.displayName}</div>
|
||||
<div className="agent-detail-row"><span className="detail-label">{t('common.state')}</span><span className="detail-value">{translateMaybe('agent.status', selectedCard.state) || selectedCard.state}</span></div>
|
||||
<div className="agent-detail-row"><span className="detail-label">{t('common.tool')}</span><span className="detail-value">{selectedCard.currentTool ?? '—'}</span></div>
|
||||
<div className="agent-detail-row"><span className="detail-label">{t('common.task')}</span><span className="detail-value">{selectedCard.taskSummary ?? '—'}</span></div>
|
||||
<div className="agent-detail-row">
|
||||
<span className="detail-label">Office</span>
|
||||
<span className="detail-label">{t('app.page.office')}</span>
|
||||
<select
|
||||
className="detail-select"
|
||||
value={selectedCard.officeId}
|
||||
@@ -2729,7 +2752,7 @@ export default function App() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="agent-detail-row">
|
||||
<span className="detail-label">Seat</span>
|
||||
<span className="detail-label">{t('common.seat')}</span>
|
||||
<select
|
||||
className="detail-select"
|
||||
value={selectedCard.seatId ?? ''}
|
||||
@@ -2754,8 +2777,8 @@ export default function App() {
|
||||
{cards.length > swarmAgents.length && (
|
||||
<>
|
||||
<div className="section-label">
|
||||
Characters
|
||||
<button className="inline-btn" onClick={() => setShowSubagents((v) => !v)}>{showSubagents ? 'hide sub' : 'show sub'}</button>
|
||||
{t('office.characters')}
|
||||
<button className="inline-btn" onClick={() => setShowSubagents((v) => !v)}>{showSubagents ? t('office.hideSub') : t('office.showSub')}</button>
|
||||
</div>
|
||||
<div className="agent-list">
|
||||
{cards.filter((c) => !swarmAgents.some((a) => a.agent_id === c.id)).map((card) => (
|
||||
@@ -2777,28 +2800,29 @@ export default function App() {
|
||||
{showDevTools && (
|
||||
<div className="dev-overlay">
|
||||
<div className="dev-header">
|
||||
<span className="dev-title">Developer Tools</span>
|
||||
<span className="dev-title">{t('dev.tools')}</span>
|
||||
<button className="icon-btn" onClick={() => setShowDevTools(false)}>✕</button>
|
||||
</div>
|
||||
<div className="dev-group">
|
||||
<div className="dev-label">Connection</div>
|
||||
<div className="dev-label">{t('dev.connection')}</div>
|
||||
<div className="input-row">
|
||||
<input value={wsUrlInput} onChange={(e) => setWsUrlInput(e.target.value)} placeholder="ws://..." />
|
||||
<button className="send-btn" onClick={applyWsUrl}>↩</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-group">
|
||||
<div className="dev-label">Evolution Pipeline</div>
|
||||
<div className="dev-label">{t('dev.evolution')}</div>
|
||||
<div className="evo-pipeline">
|
||||
{(['Trace', 'Reflect', 'Synthesize', 'Practice', 'Lifecycle'] as const).map((phase, i) => {
|
||||
const key = phase.toLowerCase() as keyof typeof evolutionPhases
|
||||
const active = key in evolutionPhases ? evolutionPhases[key as 'trace' | 'reflect' | 'synthesize'] : false
|
||||
const phaseLabelKey = `dev.phase.${key}` as Parameters<typeof t>[0]
|
||||
return (
|
||||
<div key={phase} className="evo-phase-group">
|
||||
{i > 0 && <div className="evo-connector" />}
|
||||
<div className={`evo-node ${active ? 'active' : ''}`}>
|
||||
<div className="evo-dot" />
|
||||
<span className="evo-label">{phase}</span>
|
||||
<span className="evo-label">{t(phaseLabelKey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -2815,7 +2839,7 @@ export default function App() {
|
||||
</div>
|
||||
<div className="dev-group">
|
||||
<div className="dev-label">
|
||||
Events
|
||||
{t('dev.events')}
|
||||
<select className="inline-select" value={eventTypeFilter} onChange={(e) => setEventTypeFilter(e.target.value)}>
|
||||
{eventTypes.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
@@ -2833,7 +2857,7 @@ export default function App() {
|
||||
</div>
|
||||
{Object.keys(snapshot?.channels ?? {}).length > 0 && (
|
||||
<div className="dev-group">
|
||||
<div className="dev-label">Channels</div>
|
||||
<div className="dev-label">{t('dev.channels')}</div>
|
||||
{Object.entries(snapshot?.channels ?? {}).map(([name, info]) => (
|
||||
<div className="list-row" key={name}>
|
||||
<span>{name}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Session } from '../types/kanban'
|
||||
import { IconPlus, IconSearch, IconActivity, IconShield, IconTrash, IconWorkItem } from './SvgIcons'
|
||||
import { getSessionRuntimeStatus } from '../lib/sessionRuntime'
|
||||
import { deriveCompanyRuntimeDisplayStatus } from '../lib/workItemSessions'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface SessionSidebarProps {
|
||||
sessions: Session[]
|
||||
@@ -35,12 +36,14 @@ function sessionDotClass(session: Session): string {
|
||||
return STATUS_DOT[displayStatus] ?? 'status-pending'
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
type TFunction = ReturnType<typeof useI18n>['t']
|
||||
|
||||
function relativeTime(ts: number, t: TFunction): string {
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 60_000) return 'just now'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
|
||||
return `${Math.floor(diff / 86_400_000)}d`
|
||||
if (diff < 60_000) return t('session.justNow')
|
||||
if (diff < 3_600_000) return t('session.minutes', { count: Math.floor(diff / 60_000) })
|
||||
if (diff < 86_400_000) return t('session.hours', { count: Math.floor(diff / 3_600_000) })
|
||||
return t('session.days', { count: Math.floor(diff / 86_400_000) })
|
||||
}
|
||||
|
||||
function dateGroup(ts: number): string {
|
||||
@@ -53,6 +56,15 @@ function dateGroup(ts: number): string {
|
||||
return 'Earlier'
|
||||
}
|
||||
|
||||
function groupLabel(group: 'Today' | 'Yesterday' | 'Earlier', hasRuntimeSessions: boolean, t: TFunction): string {
|
||||
const label = group === 'Today'
|
||||
? t('session.today')
|
||||
: group === 'Yesterday'
|
||||
? t('session.yesterday')
|
||||
: t('session.earlier')
|
||||
return hasRuntimeSessions ? `${label} ${t('session.runtimeSuffix')}` : label
|
||||
}
|
||||
|
||||
interface SessionTree {
|
||||
session: Session
|
||||
children: Session[]
|
||||
@@ -104,6 +116,7 @@ function SessionItem({
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { t, translateMaybe } = useI18n()
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const agentLabel = session.assigneeIds.length > 0
|
||||
@@ -134,12 +147,12 @@ function SessionItem({
|
||||
<span className="session-agent-tag">{agentLabel}</span>
|
||||
)}
|
||||
{!isChild && session.isCompanyRuntime && (
|
||||
<span className="session-runtime-badge" title="Company runtime"><IconWorkItem /></span>
|
||||
<span className="session-runtime-badge" title={t('session.companyRuntime')}><IconWorkItem /></span>
|
||||
)}
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="session-item-meta">
|
||||
{displayStatus} · {relativeTime(session.updatedAt)}
|
||||
{translateMaybe('kanban.status', displayStatus) || displayStatus} · {relativeTime(session.updatedAt, t)}
|
||||
</span>
|
||||
</div>
|
||||
{!!unreadCount && unreadCount > 0 && (
|
||||
@@ -149,15 +162,15 @@ function SessionItem({
|
||||
<button
|
||||
className="session-delete-btn"
|
||||
onClick={e => { e.stopPropagation(); setConfirming(true) }}
|
||||
title="Delete"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
)}
|
||||
{confirming && (
|
||||
<span className="session-confirm-delete" onClick={e => e.stopPropagation()}>
|
||||
<button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>Delete</button>
|
||||
<button className="session-confirm-no" onClick={() => setConfirming(false)}>Cancel</button>
|
||||
<button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>{t('common.delete')}</button>
|
||||
<button className="session-confirm-no" onClick={() => setConfirming(false)}>{t('common.cancel')}</button>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -165,6 +178,7 @@ function SessionItem({
|
||||
}
|
||||
|
||||
export function SessionSidebar({ sessions, activeSessionId, activeChannel, secretaryChannelId, unreadCounts, onSelect, onCreateSession, onDeleteSession, onSelectSecretary }: SessionSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const [search, setSearch] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||
const hasRuntimeSessions = sessions.some(session =>
|
||||
@@ -256,7 +270,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
e.stopPropagation()
|
||||
toggleCollapse(node.session.taskId)
|
||||
}}
|
||||
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
aria-label={isCollapsed ? t('session.expand') : t('session.collapse')}
|
||||
/>
|
||||
)}
|
||||
<SessionItem
|
||||
@@ -269,13 +283,13 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts])
|
||||
}, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts, t])
|
||||
|
||||
const renderVirtualRow = useCallback((row: SidebarRow) => {
|
||||
if (row.kind === 'group') {
|
||||
return (
|
||||
<div className="session-group-label">
|
||||
{hasRuntimeSessions ? `${row.group} Runtime Sessions` : row.group}
|
||||
{groupLabel(row.group, hasRuntimeSessions, t)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -288,7 +302,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
className="session-child-count"
|
||||
onClick={() => toggleCollapse(row.node.session.taskId)}
|
||||
>
|
||||
{row.node.children.length} sub-task{row.node.children.length > 1 ? 's' : ''}
|
||||
{t(row.node.children.length > 1 ? 'session.subTasks' : 'session.subTask', { count: row.node.children.length })}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -305,13 +319,13 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts])
|
||||
}, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts, t])
|
||||
|
||||
return (
|
||||
<div className="session-sidebar">
|
||||
<button className="session-new-btn" onClick={onCreateSession}>
|
||||
<IconPlus />
|
||||
<span>New Chat</span>
|
||||
<span>{t('session.newChat')}</span>
|
||||
</button>
|
||||
|
||||
{onSelectSecretary && (
|
||||
@@ -320,7 +334,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
onClick={onSelectSecretary}
|
||||
>
|
||||
<IconShield />
|
||||
<span>Secretary</span>
|
||||
<span>{t('session.secretary')}</span>
|
||||
{!!(secretaryChannelId && unreadCounts?.[secretaryChannelId]) && unreadCounts![secretaryChannelId!] > 0 && (
|
||||
<span className="session-unread-badge">{unreadCounts![secretaryChannelId!] > 99 ? '99+' : unreadCounts![secretaryChannelId!]}</span>
|
||||
)}
|
||||
@@ -331,7 +345,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
<IconSearch />
|
||||
<input
|
||||
className="session-search"
|
||||
placeholder="Search..."
|
||||
placeholder={t('session.search')}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -368,7 +382,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
if (!items || items.length === 0) return null
|
||||
return (
|
||||
<div key={group} className="session-group">
|
||||
<div className="session-group-label">{hasRuntimeSessions ? `${group} Runtime Sessions` : group}</div>
|
||||
<div className="session-group-label">{groupLabel(group, hasRuntimeSessions, t)}</div>
|
||||
{items.map(node => {
|
||||
const hasChildren = node.children.length > 0
|
||||
const isCollapsed = collapsed.has(node.session.taskId)
|
||||
@@ -382,7 +396,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
e.stopPropagation()
|
||||
toggleCollapse(node.session.taskId)
|
||||
}}
|
||||
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
aria-label={isCollapsed ? t('session.expand') : t('session.collapse')}
|
||||
/>
|
||||
)}
|
||||
<SessionItem
|
||||
@@ -416,7 +430,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
className="session-child-count"
|
||||
onClick={() => toggleCollapse(node.session.taskId)}
|
||||
>
|
||||
{node.children.length} sub-task{node.children.length > 1 ? 's' : ''}
|
||||
{t(node.children.length > 1 ? 'session.subTasks' : 'session.subTask', { count: node.children.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -427,7 +441,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="session-empty">No sessions yet</div>
|
||||
<div className="session-empty">{t('session.noSessions')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -436,7 +450,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<IconActivity />
|
||||
<span>Activity</span>
|
||||
<span>{t('session.activity')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { Project } from '../types/kanban'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
projects: Project[]
|
||||
@@ -10,6 +11,7 @@ interface ProjectSelectorProps {
|
||||
}
|
||||
|
||||
export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDelete }: ProjectSelectorProps) {
|
||||
const { t } = useI18n()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
@@ -34,7 +36,8 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
className="theme-select"
|
||||
value={activeId}
|
||||
onChange={e => onSelect(e.target.value)}
|
||||
title="Switch project"
|
||||
title={t('project.switch')}
|
||||
aria-label={t('project.switch')}
|
||||
>
|
||||
{projects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
@@ -49,7 +52,7 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
autoFocus
|
||||
className="theme-select"
|
||||
value={newName}
|
||||
placeholder="project-name"
|
||||
placeholder={t('project.placeholder')}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') setCreating(false) }}
|
||||
style={{ width: 120 }}
|
||||
@@ -60,7 +63,7 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
<button
|
||||
className="pill-btn"
|
||||
onClick={() => setCreating(true)}
|
||||
title="New project"
|
||||
title={t('project.new')}
|
||||
style={{ fontSize: 11, padding: '2px 8px' }}
|
||||
>
|
||||
+
|
||||
@@ -70,10 +73,10 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
<button
|
||||
className="pill-btn"
|
||||
onClick={() => setConfirmDelete(activeId)}
|
||||
title="Delete project"
|
||||
title={t('project.delete')}
|
||||
style={{ fontSize: 11, padding: '2px 8px', color: '#ef4444' }}
|
||||
>
|
||||
Del
|
||||
{t('project.deleteButton')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -88,10 +91,10 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
maxWidth: 400, boxShadow: '0 8px 32px rgba(0,0,0,0.4)', textAlign: 'center',
|
||||
}}>
|
||||
<p style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 15 }}>
|
||||
Delete project "{confirmDelete}"?
|
||||
{t('project.confirmTitle', { name: confirmDelete })}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, opacity: 0.7 }}>
|
||||
All sessions, messages, tasks, and agent data in this project will be permanently deleted. This action cannot be undone.
|
||||
{t('project.confirmBody')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button
|
||||
@@ -99,14 +102,14 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
style={{ padding: '6px 18px', fontSize: 13 }}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="pill-btn"
|
||||
onClick={handleDelete}
|
||||
style={{ padding: '6px 18px', fontSize: 13, background: '#ef4444', color: '#fff' }}
|
||||
>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
export type Locale = 'en' | 'zh-CN'
|
||||
|
||||
const STORAGE_KEY = 'openopc_locale'
|
||||
|
||||
const en = {
|
||||
'app.page.workspace': 'Workspace',
|
||||
'app.page.office': 'Office',
|
||||
'app.page.org': 'Org',
|
||||
'app.page.mapEditor': 'Map editor',
|
||||
'app.metric.agents': 'agents',
|
||||
'app.metric.skills': 'skills',
|
||||
'app.metric.tasks': 'tasks',
|
||||
'language.label': 'Language',
|
||||
'language.english': 'EN',
|
||||
'language.chinese': '简',
|
||||
'outdoor.title': 'Outdoor lighting override',
|
||||
'outdoor.auto': 'Outdoor auto',
|
||||
'outdoor.day': 'Outdoor day',
|
||||
'outdoor.night': 'Outdoor night',
|
||||
'theme.midnight': 'Midnight',
|
||||
'theme.neon': 'Neon',
|
||||
'theme.paper': 'Paper',
|
||||
'theme.retro': 'Retro',
|
||||
'theme.terminal': 'Terminal',
|
||||
'theme.cozy': 'Cozy',
|
||||
'theme.openopc': 'OpenOPC',
|
||||
'dev.tools': 'Developer Tools',
|
||||
'dev.connection': 'Connection',
|
||||
'dev.evolution': 'Evolution Pipeline',
|
||||
'dev.events': 'Events',
|
||||
'dev.channels': 'Channels',
|
||||
'dev.phase.trace': 'Trace',
|
||||
'dev.phase.reflect': 'Reflect',
|
||||
'dev.phase.synthesize': 'Synthesize',
|
||||
'dev.phase.practice': 'Practice',
|
||||
'dev.phase.lifecycle': 'Lifecycle',
|
||||
'project.switch': 'Switch project',
|
||||
'project.new': 'New project',
|
||||
'project.delete': 'Delete project',
|
||||
'project.deleteButton': 'Del',
|
||||
'project.placeholder': 'project-name',
|
||||
'project.confirmTitle': 'Delete project "{name}"?',
|
||||
'project.confirmBody': 'All sessions, messages, tasks, and agent data in this project will be permanently deleted. This action cannot be undone.',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.yes': 'Yes',
|
||||
'common.no': 'No',
|
||||
'common.loading': 'Loading...',
|
||||
'common.ready': 'ready',
|
||||
'common.autoSaved': 'Auto-saved',
|
||||
'common.active': 'active',
|
||||
'common.idle': 'idle',
|
||||
'common.general': 'general',
|
||||
'common.state': 'State',
|
||||
'common.tool': 'Tool',
|
||||
'common.task': 'Task',
|
||||
'common.seat': 'Seat',
|
||||
'common.role': 'Role',
|
||||
'common.employee': 'Employee',
|
||||
'common.manager': 'Manager',
|
||||
'common.origin': 'Origin',
|
||||
'common.projection': 'Projection',
|
||||
'common.gate': 'Gate',
|
||||
'common.runtime': 'Runtime',
|
||||
'office.modeHint.org': 'Manage your team in the Org tab',
|
||||
'office.modeHint.switch': 'Switch to Org mode to create or manage agents',
|
||||
'office.offices': 'Offices',
|
||||
'office.activeAgents': 'Active Agents',
|
||||
'office.rename': 'Rename',
|
||||
'office.noSeat': 'no seat',
|
||||
'office.moveHere': '+ Move here',
|
||||
'office.characters': 'Characters',
|
||||
'office.hideSub': 'hide sub',
|
||||
'office.showSub': 'show sub',
|
||||
'office.hideSubagents': 'Hide sub-agents',
|
||||
'office.showSubagents': 'Show sub-agents',
|
||||
'office.showSidePanel': 'Show side panel',
|
||||
'office.hideSidePanel': 'Hide side panel',
|
||||
'office.removeAgent': 'Remove {name}',
|
||||
'office.deleteQuestion': 'Delete?',
|
||||
'office.emptyAgents': 'No agents yet — click a template above to spawn one.',
|
||||
'session.newChat': 'New Chat',
|
||||
'session.secretary': 'Secretary',
|
||||
'session.search': 'Search...',
|
||||
'session.activity': 'Activity',
|
||||
'session.noSessions': 'No sessions yet',
|
||||
'session.today': 'Today',
|
||||
'session.yesterday': 'Yesterday',
|
||||
'session.earlier': 'Earlier',
|
||||
'session.runtimeSuffix': 'Runtime Sessions',
|
||||
'session.justNow': 'just now',
|
||||
'session.minutes': '{count}m',
|
||||
'session.hours': '{count}h',
|
||||
'session.days': '{count}d',
|
||||
'session.companyRuntime': 'Company runtime',
|
||||
'session.expand': 'Expand',
|
||||
'session.collapse': 'Collapse',
|
||||
'session.subTask': '{count} sub-task',
|
||||
'session.subTasks': '{count} sub-tasks',
|
||||
'workspace.selectRuntimeSession': 'Select a Runtime Session on the left to view its Work Item board.',
|
||||
'workspace.noWorkItems': 'No work items yet — start delegation to populate this board.',
|
||||
'workspace.clickEditTitle': 'Click to edit session title',
|
||||
'workspace.channel.secretary': 'Secretary',
|
||||
'workspace.channel.activity': 'Activity',
|
||||
'workspace.confirmNoRoles': 'Your org has no roles defined.\n\nSet up at least one role before running a task.\n\nGo to Org tab now?',
|
||||
'workspace.confirmNoDecider': 'Your organization has multiple top-level roles but no final decider selected.\n\nChoose one final decider in the Org tab before running a task.\n\nGo to Org tab now?',
|
||||
'workspace.warningNoTeams': 'No runtime teams defined — the system will auto-generate from your roles',
|
||||
'workspace.warningVacantRoles': '{count} role(s) have no employees: {names}',
|
||||
'workspace.confirmWarnings': 'Before running this task:\n\n{warnings}\n\nRun anyway?',
|
||||
'kanban.addTask': 'Add task',
|
||||
'kanban.taskTitle': 'Task title...',
|
||||
'kanban.startTask': 'Start task',
|
||||
'kanban.startWorkItem': 'Start Work Item',
|
||||
'kanban.upstreamDeps': '{count} upstream dep(s)',
|
||||
'kanban.dep': '{count} dep',
|
||||
'kanban.crossOffice': 'Cross-office',
|
||||
'kanban.executionTurn': 'Execution Turn: {id}',
|
||||
'kanban.column.Todo': 'Todo',
|
||||
'kanban.column.In Progress': 'In Progress',
|
||||
'kanban.column.Done': 'Done',
|
||||
'kanban.column.Ready': 'Ready',
|
||||
'kanban.column.Running': 'Running',
|
||||
'kanban.column.Review': 'Review',
|
||||
'kanban.column.Approved': 'Approved',
|
||||
'kanban.status.todo': 'To do',
|
||||
'kanban.status.in_progress': 'In progress',
|
||||
'kanban.status.in_review': 'In review',
|
||||
'kanban.status.done': 'Done',
|
||||
'kanban.status.running': 'Running',
|
||||
'kanban.status.idle': 'Idle',
|
||||
'kanban.status.blocked': 'Blocked',
|
||||
'kanban.status.awaiting_peer': 'Awaiting',
|
||||
'kanban.status.awaiting_manager_review': 'Mgr Review',
|
||||
'kanban.status.awaiting_human': 'Human Review',
|
||||
'kanban.status.awaiting_review': 'In Review',
|
||||
'kanban.status.failed': 'Failed',
|
||||
'kanban.status.cancelled': 'Cancelled',
|
||||
'agent.status.idle': 'Idle',
|
||||
'agent.status.reflecting': 'Thinking...',
|
||||
'agent.status.tool_active': 'Running tool',
|
||||
'agent.summary.active': '{active}/{total} active',
|
||||
'agent.summary.count': '{count} agent',
|
||||
'agent.summary.countPlural': '{count} agents',
|
||||
'org.loading': 'Loading organization data...',
|
||||
'org.company': 'Company',
|
||||
'org.customOrg': 'Custom org',
|
||||
'org.corporateCompany': 'Corporate company',
|
||||
'org.kind.saved': 'Saved org',
|
||||
'org.kind.corporate': 'Corporate',
|
||||
'org.state.editableSaved': 'Editable saved architecture',
|
||||
'org.state.editableDraft': 'Editable draft architecture',
|
||||
'org.state.readOnly': 'Built-in read-only architecture',
|
||||
'org.organization': 'Organization',
|
||||
'org.corporate': 'Corporate',
|
||||
'org.newOrganization': 'New organization',
|
||||
'org.roles': 'roles',
|
||||
'org.employees': 'employees',
|
||||
'org.runtimeTeams': 'runtime teams',
|
||||
'org.tab.team': 'Team',
|
||||
'org.tab.runtime': 'Runtime',
|
||||
'org.tab.architecture': 'Architecture',
|
||||
'org.tab.employees': 'Employees',
|
||||
'org.createdToast': 'Created {name} and saved automatically',
|
||||
'org.architectureApplied': 'Architecture applied successfully',
|
||||
}
|
||||
|
||||
type MessageKey = keyof typeof en
|
||||
|
||||
const zh: Record<MessageKey, string> = {
|
||||
'app.page.workspace': '工作台',
|
||||
'app.page.office': '办公室',
|
||||
'app.page.org': '组织',
|
||||
'app.page.mapEditor': '地图编辑',
|
||||
'app.metric.agents': 'Agent',
|
||||
'app.metric.skills': '技能',
|
||||
'app.metric.tasks': '任务',
|
||||
'language.label': '语言',
|
||||
'language.english': 'EN',
|
||||
'language.chinese': '简',
|
||||
'outdoor.title': '户外光照',
|
||||
'outdoor.auto': '户外自动',
|
||||
'outdoor.day': '户外白天',
|
||||
'outdoor.night': '户外夜晚',
|
||||
'theme.midnight': '午夜',
|
||||
'theme.neon': '霓虹',
|
||||
'theme.paper': '纸张',
|
||||
'theme.retro': '复古',
|
||||
'theme.terminal': '终端',
|
||||
'theme.cozy': '舒适',
|
||||
'theme.openopc': 'OpenOPC',
|
||||
'dev.tools': '开发工具',
|
||||
'dev.connection': '连接',
|
||||
'dev.evolution': '进化流水线',
|
||||
'dev.events': '事件',
|
||||
'dev.channels': '频道',
|
||||
'dev.phase.trace': '追踪',
|
||||
'dev.phase.reflect': '反思',
|
||||
'dev.phase.synthesize': '合成',
|
||||
'dev.phase.practice': '练习',
|
||||
'dev.phase.lifecycle': '生命周期',
|
||||
'project.switch': '切换项目',
|
||||
'project.new': '新建项目',
|
||||
'project.delete': '删除项目',
|
||||
'project.deleteButton': '删',
|
||||
'project.placeholder': '项目名',
|
||||
'project.confirmTitle': '删除项目“{name}”?',
|
||||
'project.confirmBody': '此项目中的所有会话、消息、任务和 Agent 数据都会被永久删除。此操作无法撤销。',
|
||||
'common.cancel': '取消',
|
||||
'common.delete': '删除',
|
||||
'common.yes': '是',
|
||||
'common.no': '否',
|
||||
'common.loading': '加载中...',
|
||||
'common.ready': '就绪',
|
||||
'common.autoSaved': '已自动保存',
|
||||
'common.active': '活跃',
|
||||
'common.idle': '空闲',
|
||||
'common.general': '通用',
|
||||
'common.state': '状态',
|
||||
'common.tool': '工具',
|
||||
'common.task': '任务',
|
||||
'common.seat': '座位',
|
||||
'common.role': '角色',
|
||||
'common.employee': '员工',
|
||||
'common.manager': '经理',
|
||||
'common.origin': '来源',
|
||||
'common.projection': '投影',
|
||||
'common.gate': '关卡',
|
||||
'common.runtime': '运行时',
|
||||
'office.modeHint.org': '在“组织”页管理你的团队',
|
||||
'office.modeHint.switch': '切换到“组织”模式以创建或管理 Agent',
|
||||
'office.offices': '办公室',
|
||||
'office.activeAgents': '活跃 Agent',
|
||||
'office.rename': '重命名',
|
||||
'office.noSeat': '未分配座位',
|
||||
'office.moveHere': '+ 移到这里',
|
||||
'office.characters': '角色',
|
||||
'office.hideSub': '隐藏子项',
|
||||
'office.showSub': '显示子项',
|
||||
'office.hideSubagents': '隐藏子 Agent',
|
||||
'office.showSubagents': '显示子 Agent',
|
||||
'office.showSidePanel': '显示侧栏',
|
||||
'office.hideSidePanel': '隐藏侧栏',
|
||||
'office.removeAgent': '移除 {name}',
|
||||
'office.deleteQuestion': '删除?',
|
||||
'office.emptyAgents': '还没有 Agent,点击上方模板生成一个。',
|
||||
'session.newChat': '新聊天',
|
||||
'session.secretary': '秘书',
|
||||
'session.search': '搜索...',
|
||||
'session.activity': '动态',
|
||||
'session.noSessions': '还没有会话',
|
||||
'session.today': '今天',
|
||||
'session.yesterday': '昨天',
|
||||
'session.earlier': '更早',
|
||||
'session.runtimeSuffix': '运行时会话',
|
||||
'session.justNow': '刚刚',
|
||||
'session.minutes': '{count} 分钟',
|
||||
'session.hours': '{count} 小时',
|
||||
'session.days': '{count} 天',
|
||||
'session.companyRuntime': '公司运行时',
|
||||
'session.expand': '展开',
|
||||
'session.collapse': '折叠',
|
||||
'session.subTask': '{count} 个子任务',
|
||||
'session.subTasks': '{count} 个子任务',
|
||||
'workspace.selectRuntimeSession': '在左侧选择一个运行时会话来查看它的工作项看板。',
|
||||
'workspace.noWorkItems': '还没有工作项,开始委派后会填充此看板。',
|
||||
'workspace.clickEditTitle': '点击编辑会话标题',
|
||||
'workspace.channel.secretary': '秘书',
|
||||
'workspace.channel.activity': '动态',
|
||||
'workspace.confirmNoRoles': '你的组织还没有定义角色。\n\n运行任务前请至少设置一个角色。\n\n现在前往“组织”页吗?',
|
||||
'workspace.confirmNoDecider': '你的组织有多个顶层角色,但尚未选择最终决策者。\n\n运行任务前请在“组织”页选择一个最终决策者。\n\n现在前往“组织”页吗?',
|
||||
'workspace.warningNoTeams': '尚未定义运行时团队,系统会根据角色自动生成',
|
||||
'workspace.warningVacantRoles': '{count} 个角色没有员工:{names}',
|
||||
'workspace.confirmWarnings': '运行此任务前:\n\n{warnings}\n\n仍然运行吗?',
|
||||
'kanban.addTask': '添加任务',
|
||||
'kanban.taskTitle': '任务标题...',
|
||||
'kanban.startTask': '启动任务',
|
||||
'kanban.startWorkItem': '启动工作项',
|
||||
'kanban.upstreamDeps': '{count} 个上游依赖',
|
||||
'kanban.dep': '{count} 依赖',
|
||||
'kanban.crossOffice': '跨办公室',
|
||||
'kanban.executionTurn': '执行轮次:{id}',
|
||||
'kanban.column.Todo': '待办',
|
||||
'kanban.column.In Progress': '进行中',
|
||||
'kanban.column.Done': '完成',
|
||||
'kanban.column.Ready': '就绪',
|
||||
'kanban.column.Running': '运行中',
|
||||
'kanban.column.Review': '评审',
|
||||
'kanban.column.Approved': '已批准',
|
||||
'kanban.status.todo': '待办',
|
||||
'kanban.status.in_progress': '进行中',
|
||||
'kanban.status.in_review': '评审中',
|
||||
'kanban.status.done': '完成',
|
||||
'kanban.status.running': '运行中',
|
||||
'kanban.status.idle': '空闲',
|
||||
'kanban.status.blocked': '阻塞',
|
||||
'kanban.status.awaiting_peer': '等待同伴',
|
||||
'kanban.status.awaiting_manager_review': '经理评审',
|
||||
'kanban.status.awaiting_human': '人工评审',
|
||||
'kanban.status.awaiting_review': '评审中',
|
||||
'kanban.status.failed': '失败',
|
||||
'kanban.status.cancelled': '已取消',
|
||||
'agent.status.idle': '空闲',
|
||||
'agent.status.reflecting': '思考中...',
|
||||
'agent.status.tool_active': '运行工具',
|
||||
'agent.summary.active': '{active}/{total} 活跃',
|
||||
'agent.summary.count': '{count} 个 Agent',
|
||||
'agent.summary.countPlural': '{count} 个 Agent',
|
||||
'org.loading': '正在加载组织数据...',
|
||||
'org.company': '公司',
|
||||
'org.customOrg': '自定义组织',
|
||||
'org.corporateCompany': '企业公司',
|
||||
'org.kind.saved': '已保存组织',
|
||||
'org.kind.corporate': '企业',
|
||||
'org.state.editableSaved': '可编辑的已保存架构',
|
||||
'org.state.editableDraft': '可编辑草稿架构',
|
||||
'org.state.readOnly': '内置只读架构',
|
||||
'org.organization': '组织',
|
||||
'org.corporate': '企业',
|
||||
'org.newOrganization': '新建组织',
|
||||
'org.roles': '角色',
|
||||
'org.employees': '员工',
|
||||
'org.runtimeTeams': '运行时团队',
|
||||
'org.tab.team': '团队',
|
||||
'org.tab.runtime': '运行时',
|
||||
'org.tab.architecture': '架构',
|
||||
'org.tab.employees': '员工',
|
||||
'org.createdToast': '已创建 {name} 并自动保存',
|
||||
'org.architectureApplied': '架构已成功应用',
|
||||
}
|
||||
|
||||
const dictionaries = { en, 'zh-CN': zh } satisfies Record<Locale, Record<MessageKey, string>>
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale
|
||||
setLocale: (locale: Locale) => void
|
||||
t: (key: MessageKey, params?: Record<string, string | number>) => string
|
||||
translateMaybe: (namespace: string, value: string | undefined | null) => string
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null)
|
||||
|
||||
function readInitialLocale(): Locale {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'en' || stored === 'zh-CN') return stored
|
||||
} catch {
|
||||
// private mode
|
||||
}
|
||||
try {
|
||||
return navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en'
|
||||
} catch {
|
||||
return 'en'
|
||||
}
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => (
|
||||
Object.prototype.hasOwnProperty.call(params, key) ? String(params[key]) : `{${key}}`
|
||||
))
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(() => readInitialLocale())
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
}, [locale])
|
||||
|
||||
const setLocale = useCallback((next: Locale) => {
|
||||
setLocaleState(next)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
document.documentElement.lang = next === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
} catch {
|
||||
// private mode
|
||||
}
|
||||
}, [])
|
||||
|
||||
const t = useCallback<I18nContextValue['t']>((key, params) => {
|
||||
const template = dictionaries[locale][key] ?? dictionaries.en[key] ?? key
|
||||
return interpolate(template, params)
|
||||
}, [locale])
|
||||
|
||||
const translateMaybe = useCallback<I18nContextValue['translateMaybe']>((namespace, value) => {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (!normalized) return ''
|
||||
const key = `${namespace}.${normalized}` as MessageKey
|
||||
return dictionaries[locale][key] ?? normalized
|
||||
}, [locale])
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t, translateMaybe }), [locale, setLocale, t, translateMaybe])
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext)
|
||||
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -426,6 +426,41 @@ html, body, #root {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.language-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
height: 30px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.language-toggle-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 34px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.language-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.language-toggle-btn.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { AgentAnimStatus, KanbanTask } from '../types/kanban'
|
||||
import { AGENT_STATUS_LABEL } from '../types/kanban'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { AgentAnimStatus, KanbanTask } from '../types/kanban'
|
||||
import { AGENT_STATUS_LABEL } from '../types/kanban'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface AgentStatusBarProps {
|
||||
agents: AgentInfo[]
|
||||
@@ -15,8 +16,9 @@ interface AgentState {
|
||||
taskDisplayId?: string
|
||||
}
|
||||
|
||||
export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
|
||||
const agentStates = useMemo<AgentState[]>(() => {
|
||||
export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
|
||||
const { t, translateMaybe } = useI18n()
|
||||
const agentStates = useMemo<AgentState[]>(() => {
|
||||
const tasksById = new Map(tasks.map((task) => [task.id, task]))
|
||||
return agents.map(agent => {
|
||||
// Find the first active (non-idle) task for this agent
|
||||
@@ -38,36 +40,41 @@ export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
|
||||
|
||||
const activeCount = agentStates.filter(s => s.status !== 'idle').length
|
||||
|
||||
return (
|
||||
<div className="agent-status-bar">
|
||||
<span className="agent-status-summary">
|
||||
{activeCount > 0
|
||||
? `${activeCount}/${agents.length} active`
|
||||
: `${agents.length} agent${agents.length !== 1 ? 's' : ''}`}
|
||||
</span>
|
||||
<div className="agent-status-chips">
|
||||
{agentStates.map(({ agent, status, currentTool, taskDisplayId }) => (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className={`agent-status-chip status-${status}`}
|
||||
title={`${agent.name}: ${status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]}${taskDisplayId ? ` (${taskDisplayId})` : ''}`}
|
||||
>
|
||||
<span className="agent-status-avatar">{agent.name.charAt(0).toUpperCase()}</span>
|
||||
<span className="agent-status-name">{agent.name}</span>
|
||||
{status !== 'idle' && (
|
||||
<>
|
||||
<span className="kanban-runtime-dot" />
|
||||
<span className="agent-status-detail">
|
||||
{status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{taskDisplayId && (
|
||||
<span className="agent-status-task">{taskDisplayId}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="agent-status-bar">
|
||||
<span className="agent-status-summary">
|
||||
{activeCount > 0
|
||||
? t('agent.summary.active', { active: activeCount, total: agents.length })
|
||||
: t(agents.length !== 1 ? 'agent.summary.countPlural' : 'agent.summary.count', { count: agents.length })}
|
||||
</span>
|
||||
<div className="agent-status-chips">
|
||||
{agentStates.map(({ agent, status, currentTool, taskDisplayId }) => {
|
||||
const statusLabel = status === 'tool_active' && currentTool
|
||||
? currentTool
|
||||
: translateMaybe('agent.status', status) || AGENT_STATUS_LABEL[status]
|
||||
return (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className={`agent-status-chip status-${status}`}
|
||||
title={`${agent.name}: ${statusLabel}${taskDisplayId ? ` (${taskDisplayId})` : ''}`}
|
||||
>
|
||||
<span className="agent-status-avatar">{agent.name.charAt(0).toUpperCase()}</span>
|
||||
<span className="agent-status-name">{agent.name}</span>
|
||||
{status !== 'idle' && (
|
||||
<>
|
||||
<span className="kanban-runtime-dot" />
|
||||
<span className="agent-status-detail">
|
||||
{statusLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{taskDisplayId && (
|
||||
<span className="agent-status-task">{taskDisplayId}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { KanbanBoard } from '../types/kanban'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface BoardSelectorProps {
|
||||
boards: KanbanBoard[]
|
||||
@@ -7,6 +8,7 @@ interface BoardSelectorProps {
|
||||
}
|
||||
|
||||
export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelectorProps) {
|
||||
const { translateMaybe } = useI18n()
|
||||
return (
|
||||
<div className="board-selector">
|
||||
<div className="board-tabs">
|
||||
@@ -18,7 +20,7 @@ export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelector
|
||||
onClick={() => onSelect(b.id)}
|
||||
>
|
||||
<span className="board-tab-dot" style={{ background: b.color }} />
|
||||
{b.name}
|
||||
{translateMaybe('kanban.column', b.name) || b.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentInfo } from '../types/visual'
|
||||
import { PRIORITY_META, AGENT_STATUS_LABEL, type KanbanTask } from '../types/kanban'
|
||||
import { getWorkItemRoleLabel, humanizeWorkItemRoleId } from '../lib/workItemIdentity'
|
||||
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
|
||||
todo: { label: 'To do', color: '#9ca3af' },
|
||||
@@ -32,7 +33,8 @@ interface KanbanCardProps {
|
||||
}
|
||||
|
||||
export function KanbanCard({ task, index, agents, officeMap, companyMode, isSelected, onClick, onStart }: KanbanCardProps) {
|
||||
const assignees = task.assigneeIds
|
||||
const { t, translateMaybe } = useI18n()
|
||||
const assignees = task.assigneeIds
|
||||
.map(id => agents.find(a => a.agent_id === id))
|
||||
.filter(Boolean) as AgentInfo[]
|
||||
const priority = task.priority ? PRIORITY_META[task.priority] : null
|
||||
@@ -54,6 +56,9 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
|
||||
const blockerLabel = (task.blockedReason ?? '').trim()
|
||||
const reworkLabel = (task.reworkFeedback ?? '').trim()
|
||||
const linkedRuntimeTaskId = getLinkedRuntimeTaskId(task)
|
||||
const runtimeStatusLabel = task.agentStatus
|
||||
? (translateMaybe('agent.status', task.agentStatus) || AGENT_STATUS_LABEL[task.agentStatus] || task.agentStatus)
|
||||
: ''
|
||||
|
||||
return (
|
||||
<Draggable draggableId={task.id} index={index} isDragDisabled={!!companyMode}>
|
||||
@@ -66,22 +71,22 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
|
||||
data-task-id={task.id}
|
||||
onMouseUp={e => { if (e.button === 0 && !snapshot.isDragging) onClick(task) }}
|
||||
>
|
||||
<div className="kanban-card-top">
|
||||
<span className="kanban-card-id">{task.displayId}</span>
|
||||
{statusBadge && (
|
||||
<span className="kanban-status-badge" style={{ color: statusBadge.color }}>
|
||||
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: statusBadge.color, marginRight: 3 }} />
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{depCount > 0 && (
|
||||
<span className="kanban-dep-badge" title={`${depCount} upstream dep(s)`}>{depCount} dep</span>
|
||||
)}
|
||||
{crossOffice && <span className="kanban-cross-badge" title="Cross-office">⇄</span>}
|
||||
<div className="kanban-card-top">
|
||||
<span className="kanban-card-id">{task.displayId}</span>
|
||||
{statusBadge && (
|
||||
<span className="kanban-status-badge" style={{ color: statusBadge.color }}>
|
||||
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: statusBadge.color, marginRight: 3 }} />
|
||||
{translateMaybe('kanban.status', phaseBadge) || statusBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{depCount > 0 && (
|
||||
<span className="kanban-dep-badge" title={t('kanban.upstreamDeps', { count: depCount })}>{t('kanban.dep', { count: depCount })}</span>
|
||||
)}
|
||||
{crossOffice && <span className="kanban-cross-badge" title={t('kanban.crossOffice')}>⇄</span>}
|
||||
{onStart && (
|
||||
<button
|
||||
className="kanban-start-btn"
|
||||
title={companyMode ? 'Start Work Item' : 'Start task'}
|
||||
title={companyMode ? t('kanban.startWorkItem') : t('kanban.startTask')}
|
||||
onClick={e => { e.stopPropagation(); onStart(task.id) }}
|
||||
>
|
||||
▶
|
||||
@@ -94,34 +99,34 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
|
||||
{(roleLabel || employee?.name || gate?.type || task.originChannel || task.workItemProjectionId) && (
|
||||
<div className="kanban-card-meta-row">
|
||||
{roleLabel && (
|
||||
<span className="kanban-meta-badge kanban-role-badge" title={`Role: ${roleLabel}`}>
|
||||
<span className="kanban-meta-badge kanban-role-badge" title={`${t('common.role')}: ${roleLabel}`}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
)}
|
||||
{employee?.name && (
|
||||
<span className="kanban-meta-badge kanban-employee-badge" title={`Employee: ${employee.name}${employee.category ? ` (${employee.category})` : ''}`}>
|
||||
<span className="kanban-meta-badge kanban-employee-badge" title={`${t('common.employee')}: ${employee.name}${employee.category ? ` (${employee.category})` : ''}`}>
|
||||
<span className="kanban-meta-icon">👤</span>
|
||||
{employee.name}
|
||||
</span>
|
||||
)}
|
||||
{employee.name}
|
||||
</span>
|
||||
)}
|
||||
{task.workItemProjectionId && (
|
||||
<span className="kanban-meta-badge kanban-projection-badge" title={`Projection: ${task.workItemProjectionId}`}>
|
||||
<span className="kanban-meta-badge kanban-projection-badge" title={`${t('common.projection')}: ${task.workItemProjectionId}`}>
|
||||
{task.workItemProjectionId}
|
||||
</span>
|
||||
)}
|
||||
{gate?.type && (
|
||||
<span className={`kanban-meta-badge kanban-gate-badge kanban-gate-${gate.type}`} title={`Gate: ${gate.type}${gate.reviewerRole ? ` by ${gate.reviewerRole}` : ''}`}>
|
||||
{gate.type === 'review' ? '\u2709' : gate.type === 'approval' ? '\u2713' : '\u270B'}
|
||||
{gate.type}
|
||||
</span>
|
||||
)}
|
||||
{gate?.type && (
|
||||
<span className={`kanban-meta-badge kanban-gate-badge kanban-gate-${gate.type}`} title={`${t('common.gate')}: ${gate.type}${gate.reviewerRole ? ` by ${gate.reviewerRole}` : ''}`}>
|
||||
{gate.type === 'review' ? '\u2709' : gate.type === 'approval' ? '\u2713' : '\u270B'}
|
||||
{gate.type}
|
||||
</span>
|
||||
)}
|
||||
{task.originChannel && (
|
||||
<span className="kanban-meta-badge kanban-origin-badge" title={`Origin: ${task.originChannel}`}>
|
||||
<span className="kanban-meta-badge kanban-origin-badge" title={`${t('common.origin')}: ${task.originChannel}`}>
|
||||
#{task.originChannel}
|
||||
</span>
|
||||
)}
|
||||
{managerLabel && (
|
||||
<span className="kanban-meta-badge" title={`Manager: ${managerLabel}`}>
|
||||
<span className="kanban-meta-badge" title={`${t('common.manager')}: ${managerLabel}`}>
|
||||
{managerLabel}
|
||||
</span>
|
||||
)}
|
||||
@@ -133,8 +138,8 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
|
||||
{blockerLabel && <span className="kanban-tag">{blockerLabel}</span>}
|
||||
{reworkLabel && <span className="kanban-tag">{reworkLabel}</span>}
|
||||
{linkedRuntimeTaskId && (
|
||||
<span className="kanban-tag" title={`Execution Turn: ${linkedRuntimeTaskId}`}>
|
||||
Runtime
|
||||
<span className="kanban-tag" title={t('kanban.executionTurn', { id: linkedRuntimeTaskId })}>
|
||||
{t('common.runtime')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -144,10 +149,10 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
|
||||
<div className={`kanban-card-runtime status-${task.agentStatus}`}>
|
||||
<span className="kanban-runtime-dot" />
|
||||
<span className="kanban-runtime-label">
|
||||
{task.agentStatus === 'tool_active' && task.currentTool
|
||||
? task.currentTool
|
||||
: AGENT_STATUS_LABEL[task.agentStatus!] ?? task.agentStatus}
|
||||
</span>
|
||||
{task.agentStatus === 'tool_active' && task.currentTool
|
||||
? task.currentTool
|
||||
: runtimeStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Droppable } from '@hello-pangea/dnd'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban'
|
||||
import { KanbanCard } from './KanbanCard'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban'
|
||||
import { KanbanCard } from './KanbanCard'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface KanbanColumnProps {
|
||||
column: KanbanColumnType
|
||||
@@ -17,7 +18,8 @@ interface KanbanColumnProps {
|
||||
}
|
||||
|
||||
export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, selectedTaskId, onCardClick, onStartTask, onQuickCreate }: KanbanColumnProps) {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const { t, translateMaybe } = useI18n()
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const committedRef = useRef(false)
|
||||
|
||||
@@ -35,12 +37,12 @@ export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, se
|
||||
return (
|
||||
<div className="kanban-column">
|
||||
<div className="kanban-column-header">
|
||||
<span className="kanban-col-dot" style={{ background: column.color }} />
|
||||
<span className="kanban-col-label">{column.name}</span>
|
||||
<span className="kanban-col-count">{tasks.length}</span>
|
||||
{onQuickCreate && (
|
||||
<button className="kanban-col-add" title="Add task" onClick={() => { committedRef.current = false; setAdding(true) }}>+</button>
|
||||
)}
|
||||
<span className="kanban-col-dot" style={{ background: column.color }} />
|
||||
<span className="kanban-col-label">{translateMaybe('kanban.column', column.name) || column.name}</span>
|
||||
<span className="kanban-col-count">{tasks.length}</span>
|
||||
{onQuickCreate && (
|
||||
<button className="kanban-col-add" title={t('kanban.addTask')} onClick={() => { committedRef.current = false; setAdding(true) }}>+</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
@@ -54,7 +56,7 @@ export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, se
|
||||
if (e.key === 'Escape') { committedRef.current = true; setDraft(''); setAdding(false) }
|
||||
}}
|
||||
onBlur={commitAdd}
|
||||
placeholder="Task title..."
|
||||
placeholder={t('kanban.taskTitle')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { I18nProvider } from './i18n'
|
||||
import './index.css'
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
@@ -66,7 +67,9 @@ if (!root) {
|
||||
try {
|
||||
createRoot(root).render(
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { EmployeesMarketplace } from './EmployeesMarketplace'
|
||||
import { ConfigImportExportPanel } from './ConfigImportExportPanel'
|
||||
import { OrgCreateModal } from './OrgCreateModal'
|
||||
import { getRuntimeOrgView } from '../lib/runtimeOrg'
|
||||
import { useI18n } from '../i18n'
|
||||
import './org.css'
|
||||
import './team.css'
|
||||
import './marketplace.css'
|
||||
@@ -122,6 +123,7 @@ export function OrgTab({
|
||||
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgCreate, onSavedOrgLoad, onSavedOrgDelete, savedOrgsList,
|
||||
activeSavedOrg, activeSavedOrgVersionAtLoad, orgCreatePending, orgCreateResult, onSelectCorporate,
|
||||
}: OrgTabProps) {
|
||||
const { t } = useI18n()
|
||||
const [activeTab, setActiveTab] = useState<SubTab>('team')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
@@ -166,8 +168,8 @@ export function OrgTab({
|
||||
if (!orgCreateResult || !orgCreateResult.ok) return
|
||||
setCreateOpen(false)
|
||||
setActiveTab('team')
|
||||
showToast(`Created ${orgCreateResult.organization_name || orgCreateResult.name} and saved automatically`)
|
||||
}, [orgCreateResult, showToast])
|
||||
showToast(t('org.createdToast', { name: orgCreateResult.organization_name || orgCreateResult.name }))
|
||||
}, [orgCreateResult, showToast, t])
|
||||
|
||||
// In org mode: show only user-owned roles. In company mode: show all roles read-only.
|
||||
const allRoles = data?.roles ?? []
|
||||
@@ -176,13 +178,13 @@ export function OrgTab({
|
||||
const runtimeView = useMemo(() => getRuntimeOrgView(data), [data])
|
||||
const activeAgents = useMemo(() => displayEmployees.filter(e => e.linked_agent_id), [displayEmployees])
|
||||
const configuredOrgName = data?.organization_name?.trim()
|
||||
const activeOrgLabel = configuredOrgName || humanizeOrgName(activeSavedOrg) || (isCustomMode ? 'Custom org' : 'Corporate company')
|
||||
const activeOrgLabel = configuredOrgName || humanizeOrgName(activeSavedOrg) || (isCustomMode ? t('org.customOrg') : t('org.corporateCompany'))
|
||||
const activeOrgId = (isCustomMode ? (data?.organization_id || activeSavedOrg) : 'corporate') || ''
|
||||
const architectureKindLabel = isCustomMode ? 'Saved org' : 'Corporate'
|
||||
const architectureKindLabel = isCustomMode ? t('org.kind.saved') : t('org.kind.corporate')
|
||||
const architectureStateLabel = isCustomMode
|
||||
? activeSavedOrg ? 'Editable saved architecture' : 'Editable draft architecture'
|
||||
: 'Built-in read-only architecture'
|
||||
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || 'ready'
|
||||
? activeSavedOrg ? t('org.state.editableSaved') : t('org.state.editableDraft')
|
||||
: t('org.state.readOnly')
|
||||
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || t('common.ready')
|
||||
|
||||
// Roles that already have at least one non-placeholder employee.
|
||||
const filledRoleIds = useMemo(
|
||||
@@ -210,9 +212,9 @@ export function OrgTab({
|
||||
setApplyingPresetId(null)
|
||||
versionAtApply.current = -1
|
||||
setActiveTab('team')
|
||||
showToast('Architecture applied successfully')
|
||||
showToast(t('org.architectureApplied'))
|
||||
}
|
||||
}, [orgVersion, applyingPresetId, showToast])
|
||||
}, [orgVersion, applyingPresetId, showToast, t])
|
||||
|
||||
const handleApplyPreset = (presetId: string, strategy: string) => {
|
||||
versionAtApply.current = orgVersion // snapshot current version
|
||||
@@ -232,7 +234,7 @@ export function OrgTab({
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="org-tab"><div className="org-loading">Loading organization data...</div></div>
|
||||
return <div className="org-tab"><div className="org-loading">{t('org.loading')}</div></div>
|
||||
}
|
||||
|
||||
const installedPackages = data.installed_packages ?? []
|
||||
@@ -244,7 +246,7 @@ export function OrgTab({
|
||||
<div className={`org-header${isCustomMode ? ' org-header--custom' : ' org-header--corporate'}`}>
|
||||
<div className="org-header-main">
|
||||
<div className="org-eyebrow">
|
||||
<span>Company</span>
|
||||
<span>{t('org.company')}</span>
|
||||
<span className="org-eyebrow-separator">/</span>
|
||||
<span>{architectureKindLabel}</span>
|
||||
</div>
|
||||
@@ -259,13 +261,13 @@ export function OrgTab({
|
||||
<span className="org-meta-pill">{data.company_profile || (isCustomMode ? 'custom' : 'corporate')}</span>
|
||||
{activeOrgId && <code className="org-meta-code">{activeOrgId}</code>}
|
||||
<span className="org-meta-pill org-meta-pill--runtime">{runtimeStateLabel}</span>
|
||||
<span className="org-meta-pill org-meta-pill--saved">Auto-saved</span>
|
||||
<span className="org-meta-pill org-meta-pill--saved">{t('common.autoSaved')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="org-control-panel">
|
||||
<label className="org-switcher">
|
||||
<span className="org-switcher-label">Organization</span>
|
||||
<span className="org-switcher-label">{t('org.organization')}</span>
|
||||
<span className="org-switcher-select-wrap">
|
||||
<select
|
||||
className="org-switcher-select"
|
||||
@@ -273,9 +275,9 @@ export function OrgTab({
|
||||
onChange={e => handleOrgSelection(e.target.value)}
|
||||
onFocus={() => onSavedOrgsList?.()}
|
||||
onPointerDown={() => onSavedOrgsList?.()}
|
||||
aria-label="Organization"
|
||||
aria-label={t('org.organization')}
|
||||
>
|
||||
<option value="corporate">Corporate</option>
|
||||
<option value="corporate">{t('org.corporate')}</option>
|
||||
{savedOrgOptions.map(org => (
|
||||
<option key={org.name} value={`org:${org.name}`}>
|
||||
{(org.organization_name || org.name).trim() || org.name}
|
||||
@@ -286,36 +288,36 @@ export function OrgTab({
|
||||
</label>
|
||||
<button type="button" className="org-create-trigger" onClick={() => setCreateOpen(true)}>
|
||||
<span className="org-create-trigger-icon" aria-hidden>+</span>
|
||||
New organization
|
||||
{t('org.newOrganization')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="org-stats-strip">
|
||||
<span className="org-stat">
|
||||
<img src={STAT_ICON.agents} alt="" className="org-stat-icon" />
|
||||
<b>{displayRoles.length}</b> roles
|
||||
<b>{displayRoles.length}</b> {t('org.roles')}
|
||||
</span>
|
||||
<span className="org-stat">
|
||||
<img src={TAB_ICON.employees} alt="" className="org-stat-icon" />
|
||||
<b>{displayEmployees.length}</b> employees
|
||||
<b>{displayEmployees.length}</b> {t('org.employees')}
|
||||
</span>
|
||||
<span className="org-stat">
|
||||
<img src={TAB_ICON.runtime} alt="" className="org-stat-icon" />
|
||||
<b>{runtimeView.runtimeTeams.length}</b> runtime teams
|
||||
<b>{runtimeView.runtimeTeams.length}</b> {t('org.runtimeTeams')}
|
||||
</span>
|
||||
<span className="org-stat">
|
||||
<img src={STAT_ICON.active} alt="" className="org-stat-icon" />
|
||||
<b>{activeAgents.length}</b> active
|
||||
<b>{activeAgents.length}</b> {t('common.active')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="org-subtabs">
|
||||
{([
|
||||
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: 'Team', count: displayRoles.length },
|
||||
{ id: 'runtime' as SubTab, icon: TAB_ICON.runtime, label: 'Runtime', count: runtimeView.runtimeTeams.length },
|
||||
{ id: 'architecture' as SubTab, icon: TAB_ICON.architecture, label: 'Architecture', count: marketPresets?.length ?? 0 },
|
||||
{ id: 'employees' as SubTab, icon: TAB_ICON.employees, label: 'Employees', count: talents.length },
|
||||
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: t('org.tab.team'), count: displayRoles.length },
|
||||
{ id: 'runtime' as SubTab, icon: TAB_ICON.runtime, label: t('org.tab.runtime'), count: runtimeView.runtimeTeams.length },
|
||||
{ id: 'architecture' as SubTab, icon: TAB_ICON.architecture, label: t('org.tab.architecture'), count: marketPresets?.length ?? 0 },
|
||||
{ id: 'employees' as SubTab, icon: TAB_ICON.employees, label: t('org.tab.employees'), count: talents.length },
|
||||
]).map(tab => (
|
||||
<button key={tab.id}
|
||||
className={`org-subtab${activeTab === tab.id ? ' org-subtab--active' : ''}`}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getRuntimeOrgView } from '../lib/runtimeOrg'
|
||||
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
|
||||
import { ContextPanel } from './ContextPanel'
|
||||
import { useResizePanel } from './useResizePanel'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
type ActiveView =
|
||||
| { kind: 'session'; taskId: string }
|
||||
@@ -42,11 +43,25 @@ function makeOptimisticUserMessageId(): string {
|
||||
|
||||
/* ── Org mode pre-run readiness check ─────────────────────────────────── */
|
||||
|
||||
type TFunction = ReturnType<typeof useI18n>['t']
|
||||
|
||||
function checkOrgModeReadiness(
|
||||
mode: string,
|
||||
orgInfoData: OrgInfoPayload | null | undefined,
|
||||
onNavigateToOrg?: () => void,
|
||||
t?: TFunction,
|
||||
): boolean {
|
||||
const tr = t ?? ((key: string, params?: Record<string, string | number>) => {
|
||||
const fallbacks: Record<string, string> = {
|
||||
'workspace.confirmNoRoles': 'Your org has no roles defined.\n\nSet up at least one role before running a task.\n\nGo to Org tab now?',
|
||||
'workspace.confirmNoDecider': 'Your organization has multiple top-level roles but no final decider selected.\n\nChoose one final decider in the Org tab before running a task.\n\nGo to Org tab now?',
|
||||
'workspace.warningNoTeams': 'No runtime teams defined — the system will auto-generate from your roles',
|
||||
'workspace.warningVacantRoles': '{count} role(s) have no employees: {names}',
|
||||
'workspace.confirmWarnings': 'Before running this task:\n\n{warnings}\n\nRun anyway?',
|
||||
}
|
||||
const template = fallbacks[key] ?? key
|
||||
return template.replace(/\{(\w+)\}/g, (_, name) => String(params?.[name] ?? `{${name}}`))
|
||||
}) as TFunction
|
||||
if (mode !== 'org' && mode !== 'custom') return true
|
||||
if (!orgInfoData) return true
|
||||
|
||||
@@ -57,21 +72,13 @@ function checkOrgModeReadiness(
|
||||
|
||||
// Block: no roles defined
|
||||
if (roles.length === 0) {
|
||||
const goToOrg = confirm(
|
||||
'Your org has no roles defined.\n\n' +
|
||||
'Set up at least one role before running a task.\n\n' +
|
||||
'Go to Org tab now?'
|
||||
)
|
||||
const goToOrg = confirm(tr('workspace.confirmNoRoles'))
|
||||
if (goToOrg) onNavigateToOrg?.()
|
||||
return false
|
||||
}
|
||||
|
||||
if (topLevelRoleIds.length > 1 && !finalDeciderRoleId) {
|
||||
const goToOrg = confirm(
|
||||
'Your organization has multiple top-level roles but no final decider selected.\n\n' +
|
||||
'Choose one final decider in the Org tab before running a task.\n\n' +
|
||||
'Go to Org tab now?'
|
||||
)
|
||||
const goToOrg = confirm(tr('workspace.confirmNoDecider'))
|
||||
if (goToOrg) onNavigateToOrg?.()
|
||||
return false
|
||||
}
|
||||
@@ -84,7 +91,7 @@ function checkOrgModeReadiness(
|
||||
|| (team.manager_role_id && roleIds.has(team.manager_role_id))
|
||||
))
|
||||
if (relevantTeams.length === 0) {
|
||||
warnings.push('No runtime teams defined \u2014 the system will auto-generate from your roles')
|
||||
warnings.push(tr('workspace.warningNoTeams'))
|
||||
}
|
||||
|
||||
// Warn: roles without employees
|
||||
@@ -92,15 +99,11 @@ function checkOrgModeReadiness(
|
||||
const vacantRoles = roles.filter(r => !employeeRoleIds.has(r.role_id))
|
||||
if (vacantRoles.length > 0) {
|
||||
const names = vacantRoles.map(r => r.name).join(', ')
|
||||
warnings.push(`${vacantRoles.length} role(s) have no employees: ${names}`)
|
||||
warnings.push(tr('workspace.warningVacantRoles', { count: vacantRoles.length, names }))
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
return confirm(
|
||||
'Before running this task:\n\n' +
|
||||
warnings.map(w => '\u2022 ' + w).join('\n') +
|
||||
'\n\nRun anyway?'
|
||||
)
|
||||
return confirm(tr('workspace.confirmWarnings', { warnings: warnings.map(w => '\u2022 ' + w).join('\n') }))
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -158,6 +161,7 @@ function BoardTitleEditor({
|
||||
title: string
|
||||
onCommit: (next: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(title)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -206,7 +210,7 @@ function BoardTitleEditor({
|
||||
<span
|
||||
className="board-tab-title"
|
||||
onClick={startEditing}
|
||||
title="Click to edit session title"
|
||||
title={t('workspace.clickEditTitle')}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
@@ -310,6 +314,7 @@ export function WorkspacePage({
|
||||
onSavedOrgsList,
|
||||
onSavedOrgLoad,
|
||||
}: WorkspacePageProps) {
|
||||
const { t } = useI18n()
|
||||
const { sessions, activeSessionId, activeSession } = sessionStore
|
||||
const { markRead } = chatStore
|
||||
|
||||
@@ -933,7 +938,7 @@ export function WorkspacePage({
|
||||
: undefined
|
||||
|
||||
// Pre-run readiness check for org mode
|
||||
if (!checkOrgModeReadiness(sessionExecMode, orgInfoData, onNavigateToOrg)) {
|
||||
if (!checkOrgModeReadiness(sessionExecMode, orgInfoData, onNavigateToOrg, t)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -948,7 +953,7 @@ export function WorkspacePage({
|
||||
if (inProgressCol) {
|
||||
boardStore.moveTask(taskId, inProgressCol.id, 0)
|
||||
}
|
||||
}, [boardStore, onRunTask, sessions, execMode, companyProfile, orgInfoData, onNavigateToOrg])
|
||||
}, [boardStore, onRunTask, sessions, execMode, companyProfile, orgInfoData, onNavigateToOrg, t])
|
||||
|
||||
const handleSessionConfigChange = useCallback((taskId: string, sessionMode: string, sessionCompanyProfile?: string, orgId?: string) => {
|
||||
onSessionConfigChange?.(taskId, sessionMode, sessionCompanyProfile, orgId)
|
||||
@@ -1094,7 +1099,7 @@ export function WorkspacePage({
|
||||
}, [handleOpenWorkItemSession, onOpenExecutionPanel])
|
||||
|
||||
const isSecretary = effectiveView.kind === 'secretary'
|
||||
const channelName = isSecretary ? 'Secretary' : activeSession ? activeSession.title : 'Activity'
|
||||
const channelName = isSecretary ? t('workspace.channel.secretary') : activeSession ? activeSession.title : t('workspace.channel.activity')
|
||||
|
||||
return (
|
||||
<div className={`workspace-page${panelState === 'maximized' ? ' panel-maximized' : ''}`}>
|
||||
@@ -1137,7 +1142,7 @@ export function WorkspacePage({
|
||||
)}
|
||||
{isCompanyMode && !boardStore.activeBoard ? (
|
||||
<div className="kanban-empty-state">
|
||||
<p>Select a Runtime Session on the left to view its Work Item board.</p>
|
||||
<p>{t('workspace.selectRuntimeSession')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -1156,7 +1161,7 @@ export function WorkspacePage({
|
||||
/>
|
||||
{isCompanyMode && boardStore.activeBoard && boardStore.tasks.filter(t => t.boardId === boardStore.activeBoardId).length === 0 && (
|
||||
<div className="kanban-empty-state kanban-empty-state-inline">
|
||||
<p>No work items yet — start delegation to populate this board.</p>
|
||||
<p>{t('workspace.noWorkItems')}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -920,6 +920,53 @@ class CompanyCollaborationTests(unittest.IsolatedAsyncioTestCase):
|
||||
any(item.startswith("[Workspace] Prepared workspace roots:") for item in task.metadata.get("progress_log", []))
|
||||
)
|
||||
|
||||
async def test_failed_work_item_transitions_out_of_running_phase(self) -> None:
|
||||
execute_task = AsyncMock(
|
||||
return_value=TaskResult(status=TaskStatus.FAILED, content="provider limit")
|
||||
)
|
||||
save_task = AsyncMock()
|
||||
executor = CompanyWorkItemExecutor(
|
||||
org_engine=DummyOrgEngine(),
|
||||
communication=SimpleNamespace(),
|
||||
approval_engine=SimpleNamespace(),
|
||||
memory=None,
|
||||
execute_task=execute_task,
|
||||
save_task=save_task,
|
||||
)
|
||||
task = Task(
|
||||
id="failed-work-item-task",
|
||||
title="Failed Work Item",
|
||||
assigned_to="executor",
|
||||
status=TaskStatus.PENDING,
|
||||
metadata={
|
||||
"work_item_projection_id": "failed_execution",
|
||||
"work_item_role_id": "executor",
|
||||
"work_item_turn_type": "execute",
|
||||
"work_item_execution_strategy": "native",
|
||||
"progress_log": [],
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"opc.layer2_organization.company_mode.transition_work_item_from_task",
|
||||
new_callable=AsyncMock,
|
||||
) as transition:
|
||||
result = await executor._run_work_item(task, {task.id: task, "failed_execution": task})
|
||||
|
||||
self.assertEqual(result.status, TaskStatus.FAILED)
|
||||
self.assertTrue(
|
||||
any(
|
||||
call.kwargs.get("target_status_or_phase") == TaskStatus.FAILED
|
||||
and call.kwargs.get("reason") == "work_item_failed"
|
||||
for call in transition.await_args_list
|
||||
)
|
||||
)
|
||||
self.assertIn(
|
||||
"Work item ended with status failed.",
|
||||
task.metadata.get("progress_log", []),
|
||||
)
|
||||
save_task.assert_awaited()
|
||||
|
||||
async def test_workspace_bootstrap_work_item_records_manifest_and_reserved_layout(self) -> None:
|
||||
with _workspace_tempdir() as tmpdir:
|
||||
workspace = tmpdir / "runtime-root"
|
||||
|
||||
@@ -276,6 +276,154 @@ class CompanyRuntimeSuspendResumeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(refreshed_item.metadata.get("dispatch_hold"), "")
|
||||
self.assertEqual(refreshed_item.claimed_by_role_runtime_session_id, "")
|
||||
|
||||
async def test_continue_merges_task_suspended_after_checkpoint_creation(self) -> None:
|
||||
store = await self._store()
|
||||
plan, task = await self._seed_runtime(store)
|
||||
engine = self._engine(store)
|
||||
await engine.suspend_company_runtime(
|
||||
origin_task_id=task.id,
|
||||
session_id="sess-parent",
|
||||
reason="startup_interrupted",
|
||||
)
|
||||
|
||||
review_item = DelegationWorkItem(
|
||||
work_item_id="review-item-v3",
|
||||
run_id="run-1",
|
||||
role_id="executor",
|
||||
seat_id="seat-1",
|
||||
title="Review v3",
|
||||
summary="Review the completed report.",
|
||||
kind="review",
|
||||
projection_id="review-v3",
|
||||
phase=Phase.RUNNING,
|
||||
metadata={
|
||||
"work_item_projection_id": "review-v3",
|
||||
"dispatch_hold": "company_runtime_suspended",
|
||||
"suspend_checkpoint_type": "company_runtime_interrupted",
|
||||
"suspended_phase": Phase.RUNNING.value,
|
||||
},
|
||||
)
|
||||
await store.save_delegation_work_item(review_item)
|
||||
review_task = Task(
|
||||
id="review-task-v3",
|
||||
title="Review v3",
|
||||
session_id="sess-review-v3",
|
||||
parent_session_id="sess-parent",
|
||||
status=TaskStatus.RUNNING,
|
||||
project_id="proj1",
|
||||
assigned_to="executor",
|
||||
assigned_external_agent="codex",
|
||||
metadata={
|
||||
"company_profile": "corporate",
|
||||
"execution_model": "multi_team_org",
|
||||
"runtime_model": "multi_team_org",
|
||||
"work_item_runtime": True,
|
||||
"work_item_projection_id": "review-v3",
|
||||
"delegation_run_id": "run-1",
|
||||
"delegation_role_session_id": "role-runtime-1",
|
||||
"company_work_item_plan": serialize_company_work_item_runtime_plan(plan),
|
||||
"company_runtime_stop_state": "suspended",
|
||||
"company_runtime_suspend_checkpoint_type": "company_runtime_interrupted",
|
||||
"company_runtime_suspended_at": datetime.now().isoformat(),
|
||||
"dispatch_hold": "company_runtime_suspended",
|
||||
"suspended_task_status": TaskStatus.RUNNING.value,
|
||||
},
|
||||
)
|
||||
set_linked_work_item_id(review_task, review_item.work_item_id)
|
||||
await store.save_task(review_task)
|
||||
await store.link_work_item_runtime_task(review_item.work_item_id, review_task.id)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class DummyCompanyExecutor:
|
||||
async def execute(
|
||||
self,
|
||||
resumed_plan: CompanyWorkItemRuntimePlan,
|
||||
tasks: list[Task],
|
||||
) -> str:
|
||||
captured["plan"] = resumed_plan
|
||||
captured["tasks"] = tasks
|
||||
return "runtime resumed"
|
||||
|
||||
engine.company_executor = DummyCompanyExecutor()
|
||||
await engine._maybe_resume_checkpoint(
|
||||
"continue",
|
||||
"sess-parent",
|
||||
reply_metadata={"ui_force_resume": True},
|
||||
)
|
||||
|
||||
resumed_ids = {item.id for item in captured["tasks"]}
|
||||
refreshed_review_item = await store.get_delegation_work_item(
|
||||
review_item.work_item_id
|
||||
)
|
||||
resolved = await store.get_execution_checkpoints(
|
||||
project_id="proj1",
|
||||
session_id="sess-parent",
|
||||
checkpoint_types=["company_runtime_suspended"],
|
||||
statuses=["resolved"],
|
||||
)
|
||||
|
||||
self.assertEqual(resumed_ids, {task.id, review_task.id})
|
||||
assert refreshed_review_item is not None
|
||||
self.assertEqual(refreshed_review_item.metadata.get("dispatch_hold"), "")
|
||||
self.assertEqual(len(resolved), 1)
|
||||
self.assertEqual(
|
||||
set(resolved[0].payload.get("task_ids", [])),
|
||||
{task.id, review_task.id},
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
snapshot.get("task_id")
|
||||
for snapshot in resolved[0].payload.get("task_snapshots", [])
|
||||
},
|
||||
{task.id, review_task.id},
|
||||
)
|
||||
|
||||
async def test_runtime_refreshes_canonical_adapter_state_before_persisting(self) -> None:
|
||||
store = await self._store()
|
||||
_, task = await self._seed_runtime(store)
|
||||
role_session_id = "role-runtime-1"
|
||||
stale_state = {
|
||||
"selected_execution_agent": "codex",
|
||||
"external_resume_session_id": "thread-terminal",
|
||||
"external_resume_agent_type": "codex",
|
||||
"codex": {
|
||||
"resume_session_id": "thread-terminal",
|
||||
"provider_session_id": "thread-terminal",
|
||||
},
|
||||
}
|
||||
role_session = await store.get_delegation_role_session(role_session_id)
|
||||
assert role_session is not None
|
||||
role_session.adapter_session_state = dict(stale_state)
|
||||
await store.save_delegation_role_session(role_session)
|
||||
|
||||
runtime = CompanyRuntime(org_engine=None, communication=None, store=store)
|
||||
runtime.role_sessions[role_session_id] = role_session
|
||||
member = CompanyMemberSession(
|
||||
member_session_id="member-operations",
|
||||
role_session_id=role_session_id,
|
||||
role_id="executor",
|
||||
employee_id="employee-1",
|
||||
adapter_session_state=dict(stale_state),
|
||||
)
|
||||
await store.update_role_session_adapter_state(
|
||||
role_session_id,
|
||||
"codex",
|
||||
None,
|
||||
)
|
||||
|
||||
await runtime._refresh_adapter_session_state_from_store(member)
|
||||
|
||||
self.assertNotIn("codex", member.adapter_session_state)
|
||||
self.assertNotIn(
|
||||
"external_resume_session_id",
|
||||
member.adapter_session_state,
|
||||
)
|
||||
self.assertEqual(
|
||||
runtime.role_sessions[role_session_id].adapter_session_state,
|
||||
member.adapter_session_state,
|
||||
)
|
||||
|
||||
async def test_second_stop_during_resumed_execution_restores_pending_checkpoint(self) -> None:
|
||||
store = await self._store()
|
||||
_, task = await self._seed_runtime(store)
|
||||
|
||||
@@ -2513,7 +2513,8 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_codex_adapter_resolves_launch_binary_before_spawn(self) -> None:
|
||||
metadata: dict[str, object] = {}
|
||||
|
||||
with patch("opc.layer3_agent.adapters.base.shutil.which", return_value=r"C:\Tools\codex.cmd"):
|
||||
with patch("opc.layer3_agent.adapters.base.os.name", "posix"), \
|
||||
patch("opc.layer3_agent.adapters.base.shutil.which", return_value=r"C:\Tools\codex.cmd"):
|
||||
cmd = CodexAdapter._resolve_launch_command(
|
||||
["codex", "exec", "--json", "-"],
|
||||
launch_metadata=metadata,
|
||||
@@ -2523,6 +2524,39 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(metadata["configured_binary"], "codex")
|
||||
self.assertEqual(metadata["resolved_binary"], r"C:\Tools\codex.cmd")
|
||||
|
||||
def test_codex_adapter_wraps_windows_cmd_shim_before_spawn(self) -> None:
|
||||
metadata: dict[str, object] = {}
|
||||
which_results = {
|
||||
"codex": r"D:\nodejs\codex",
|
||||
"cmd.exe": r"C:\Windows\System32\cmd.exe",
|
||||
}
|
||||
|
||||
with patch("opc.layer3_agent.adapters.base.os.name", "nt"), \
|
||||
patch("opc.layer3_agent.adapters.base.os.path.isfile", side_effect=lambda path: path == r"D:\nodejs\codex.cmd"), \
|
||||
patch("opc.layer3_agent.adapters.base.shutil.which", side_effect=lambda name, path=None: which_results.get(name)), \
|
||||
patch.dict(os.environ, {"COMSPEC": r"C:\Windows\System32\cmd.exe"}, clear=False):
|
||||
cmd = CodexAdapter._resolve_launch_command(
|
||||
["codex", "exec", "--json", "-"],
|
||||
launch_metadata=metadata,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
cmd,
|
||||
[
|
||||
r"C:\Windows\System32\cmd.exe",
|
||||
"/d",
|
||||
"/s",
|
||||
"/c",
|
||||
r"D:\nodejs\codex.cmd",
|
||||
"exec",
|
||||
"--json",
|
||||
"-",
|
||||
],
|
||||
)
|
||||
self.assertEqual(metadata["configured_binary"], "codex")
|
||||
self.assertEqual(metadata["resolved_binary"], r"D:\nodejs\codex.cmd")
|
||||
self.assertEqual(metadata["launch_wrapper"], r"C:\Windows\System32\cmd.exe")
|
||||
|
||||
def test_codex_adapter_resolves_launch_binary_with_extra_env_path(self) -> None:
|
||||
with patch("opc.layer3_agent.adapters.base.shutil.which", return_value=r"D:\bin\codex.cmd") as which_mock:
|
||||
CodexAdapter._resolve_launch_command(
|
||||
@@ -2532,6 +2566,40 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertEqual(which_mock.call_args.kwargs["path"], r"D:\bin")
|
||||
|
||||
def test_codex_adapter_forwards_only_openopc_runtime_env_to_shell_policy(self) -> None:
|
||||
metadata: dict[str, object] = {}
|
||||
|
||||
cmd = CodexAdapter._inject_runtime_shell_environment(
|
||||
["codex", "exec", "--json", "-"],
|
||||
{
|
||||
"OPC_ALLOWED_COLLAB_TOOLS": json.dumps(["delegate_work", "inbox"]),
|
||||
"OPC_COLLAB_RPC_TOKEN": "rpc-token",
|
||||
"PYTHONPATH": r"D:\OpenOPC",
|
||||
"UNRELATED_SECRET": "do-not-forward",
|
||||
},
|
||||
launch_metadata=metadata,
|
||||
)
|
||||
|
||||
self.assertEqual(cmd[:2], ["codex", "exec"])
|
||||
config_values = [cmd[index + 1] for index, value in enumerate(cmd[:-1]) if value == "-c"]
|
||||
self.assertIn(
|
||||
"shell_environment_policy.set.OPC_ALLOWED_COLLAB_TOOLS='''delegate_work,inbox'''",
|
||||
config_values,
|
||||
)
|
||||
self.assertIn(
|
||||
"shell_environment_policy.set.OPC_COLLAB_RPC_TOKEN='''rpc-token'''",
|
||||
config_values,
|
||||
)
|
||||
self.assertIn(
|
||||
"shell_environment_policy.set.PYTHONPATH='''D:\\OpenOPC'''",
|
||||
config_values,
|
||||
)
|
||||
self.assertFalse(any("UNRELATED_SECRET" in value for value in config_values))
|
||||
self.assertEqual(
|
||||
metadata["runtime_shell_environment_keys"],
|
||||
["OPC_ALLOWED_COLLAB_TOOLS", "OPC_COLLAB_RPC_TOKEN", "PYTHONPATH"],
|
||||
)
|
||||
|
||||
def test_codex_adapter_full_auto_uses_dangerous_bypass_flag(self) -> None:
|
||||
adapter = CodexAdapter(
|
||||
config=ExternalAgentConfig(command="codex", approval_mode="full-auto")
|
||||
@@ -5242,10 +5310,11 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = _SessionStoreStub()
|
||||
adapter = _CollabSurfaceScriptAdapter("print('company-mode external run')\n")
|
||||
broker = ExternalAgentBroker(store, _ApprovalStub())
|
||||
from opc.layer2_organization.org_engine import OrgEngine
|
||||
from opc.core.config import OPCConfig
|
||||
broker.org_engine = OrgEngine(OPCConfig(), Path(tmpdir))
|
||||
org_engine = OrgEngine(OPCConfig(), Path(tmpdir))
|
||||
broker = ExternalAgentBroker(store, _ApprovalStub(), org_engine=org_engine)
|
||||
self.assertIs(broker.org_engine, org_engine)
|
||||
task = Task(
|
||||
id="collab-env-ceo",
|
||||
title="collab env ceo",
|
||||
|
||||
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -37,6 +38,7 @@ from opc.core.models import (
|
||||
from opc.database.store import OPCStore
|
||||
from opc.layer3_agent.adapters.base import ExternalAgentAdapter
|
||||
from opc.layer3_agent.external_broker import ExternalAgentBroker
|
||||
from opc.layer3_agent.external_session_identity import select_best_external_resume_session
|
||||
|
||||
|
||||
# ── Store helper unit tests ──────────────────────────────────────────────
|
||||
@@ -97,9 +99,28 @@ class AdapterSessionStateHelperTests(unittest.IsolatedAsyncioTestCase):
|
||||
await self.store.update_role_session_adapter_state(
|
||||
self.sid, "codex", {"resume_session_id": "thread-abc"},
|
||||
)
|
||||
role_session = await self.store.get_delegation_role_session(self.sid)
|
||||
role_session.adapter_session_state.update({
|
||||
"selected_execution_agent": "codex",
|
||||
"external_resume_session_id": "thread-abc",
|
||||
"external_resume_session_scope_id": "sess-a",
|
||||
"external_resume_agent_type": "codex",
|
||||
"resume_source_session": "thread-abc",
|
||||
})
|
||||
await self.store.save_delegation_role_session(role_session)
|
||||
await self.store.update_role_session_adapter_state(self.sid, "codex", None)
|
||||
entry = await self.store.get_role_session_adapter_state(self.sid, "codex")
|
||||
refreshed = await self.store.get_delegation_role_session(self.sid)
|
||||
self.assertIsNone(entry)
|
||||
self.assertEqual(
|
||||
refreshed.adapter_session_state.get("selected_execution_agent"),
|
||||
"codex",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"external_resume_session_id",
|
||||
refreshed.adapter_session_state,
|
||||
)
|
||||
self.assertNotIn("resume_source_session", refreshed.adapter_session_state)
|
||||
|
||||
async def test_missing_session_returns_false(self) -> None:
|
||||
ok = await self.store.update_role_session_adapter_state(
|
||||
@@ -245,6 +266,49 @@ class BrokerPersistWritesRoleStateTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
self.assertIsNone(entry)
|
||||
|
||||
async def test_failed_resume_clears_matching_older_role_token_and_task_metadata(self) -> None:
|
||||
await self._persist_run(
|
||||
task_id="task-old",
|
||||
agent_type="codex",
|
||||
resume_session_id="thread-full",
|
||||
)
|
||||
adapter = _MiniAdapter(agent_type="codex")
|
||||
adapter.config.session_mode = "resume"
|
||||
adapter.config.session_id = "thread-full"
|
||||
task = self._task(task_id="task-retry")
|
||||
task.metadata.update({
|
||||
"external_resume_session_id": "thread-full",
|
||||
"external_resume_session_scope_id": "sess-a",
|
||||
"external_resume_agent_type": "codex",
|
||||
})
|
||||
await self.broker._persist_session(
|
||||
adapter=adapter,
|
||||
task=task,
|
||||
workspace_path="/tmp/ws",
|
||||
metadata={
|
||||
"command": "codex exec resume",
|
||||
"model": "(cli default)",
|
||||
"resume_session_id": "thread-full",
|
||||
},
|
||||
result=TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content="context window full",
|
||||
artifacts={"resume_session_id": "thread-full"},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertIsNone(
|
||||
await self.store.get_role_session_adapter_state(
|
||||
self.role_session_id,
|
||||
"codex",
|
||||
)
|
||||
)
|
||||
self.assertNotIn("external_resume_session_id", task.metadata)
|
||||
self.assertEqual(
|
||||
task.metadata.get("external_resume_fallback"),
|
||||
"provider_terminal_failure",
|
||||
)
|
||||
|
||||
async def test_consecutive_tasks_overwrite_with_latest_token(self) -> None:
|
||||
await self._persist_run(
|
||||
task_id="task-1", agent_type="codex", resume_session_id="thread-1",
|
||||
@@ -258,6 +322,34 @@ class BrokerPersistWritesRoleStateTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(entry["resume_session_id"], "thread-2")
|
||||
self.assertEqual(entry["last_task_id"], "task-2")
|
||||
|
||||
def test_newer_failed_row_vetoes_older_done_row_for_same_token(self) -> None:
|
||||
now = datetime.now()
|
||||
older = ExternalSession(
|
||||
agent_type="codex",
|
||||
project_id="proj1",
|
||||
session_id="thread-full",
|
||||
status="done",
|
||||
metadata={"resume_session_id": "thread-full"},
|
||||
updated_at=now - timedelta(minutes=1),
|
||||
)
|
||||
newer = ExternalSession(
|
||||
agent_type="codex",
|
||||
project_id="proj1",
|
||||
session_id="thread-full",
|
||||
status="failed",
|
||||
metadata={"resume_session_id": "thread-full"},
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
selected, token = select_best_external_resume_session(
|
||||
[older, newer],
|
||||
agent_type="codex",
|
||||
project_id="proj1",
|
||||
)
|
||||
|
||||
self.assertIsNone(selected)
|
||||
self.assertEqual(token, "")
|
||||
|
||||
async def test_per_agent_isolation_codex_claude_opencode(self) -> None:
|
||||
# All three external adapters land in the same broker path. Each
|
||||
# gets its own slot under adapter_session_state keyed by agent_type.
|
||||
@@ -430,6 +522,55 @@ class BrokerRestorePrefersRoleStateTests(unittest.IsolatedAsyncioTestCase):
|
||||
"codex",
|
||||
))
|
||||
|
||||
async def test_restore_rejects_terminal_normal_role_token_even_when_preconfigured(self) -> None:
|
||||
token = "thread-terminal"
|
||||
await self.store.update_role_session_adapter_state(
|
||||
self.role_session_id,
|
||||
"codex",
|
||||
{
|
||||
"resume_session_id": token,
|
||||
"provider_session_id": token,
|
||||
"last_task_id": "task-old",
|
||||
},
|
||||
)
|
||||
await self.store.save_external_session(ExternalSession(
|
||||
agent_type="codex",
|
||||
project_id="proj1",
|
||||
session_id=token,
|
||||
opc_session_id=self.role_session_id,
|
||||
task_id="task-failed",
|
||||
workspace_path="/tmp/ws",
|
||||
run_mode="exec",
|
||||
status="failed",
|
||||
metadata={
|
||||
"resume_session_id": token,
|
||||
"provider_session_id": token,
|
||||
},
|
||||
))
|
||||
adapter = _MiniAdapter(agent_type="codex", can_resume_blank=False)
|
||||
adapter.config.session_mode = "resume"
|
||||
adapter.config.session_id = token
|
||||
task = self._task()
|
||||
task.metadata.update({
|
||||
"external_resume_session_id": token,
|
||||
"external_resume_session_scope_id": "sess-a",
|
||||
"external_resume_agent_type": "codex",
|
||||
})
|
||||
|
||||
await self.broker._restore_session_resume_from_store(adapter, task)
|
||||
|
||||
self.assertEqual(adapter.config.session_mode, "new")
|
||||
self.assertEqual(adapter.config.session_id, "")
|
||||
self.assertIsNone(await self.store.get_role_session_adapter_state(
|
||||
self.role_session_id,
|
||||
"codex",
|
||||
))
|
||||
self.assertNotIn("external_resume_session_id", task.metadata)
|
||||
self.assertEqual(
|
||||
task.metadata.get("external_resume_fallback"),
|
||||
"provider_terminal_failure",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -164,6 +164,32 @@ def _make_review_task(
|
||||
|
||||
|
||||
class VerdictParseRetryTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_terminal_failed_review_card_can_record_parse_failure_outcome(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
store = OPCStore(root / "tasks.db")
|
||||
await store.initialize()
|
||||
try:
|
||||
executor = _build_executor(store, _make_org_engine(root))
|
||||
review = _make_review_card(review_card_id="review-failed")
|
||||
review.phase = Phase.FAILED
|
||||
await store.save_delegation_work_item(review)
|
||||
|
||||
persisted = await executor._persist_terminal_review_card(
|
||||
"review-failed",
|
||||
phase=Phase.CANCELLED,
|
||||
outcome="verdict_parse_failed",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(persisted)
|
||||
self.assertEqual(persisted.phase, Phase.FAILED)
|
||||
self.assertEqual(
|
||||
persisted.metadata.get("review_work_item_outcome"),
|
||||
"verdict_parse_failed",
|
||||
)
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
async def test_unparseable_verdict_spawns_retry_with_hint(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
|
||||
Reference in New Issue
Block a user