From 3c1bf2da4d3151e7b2141e0adaab0d851fbcdc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 18 Sep 2026 15:13:01 +0100 Subject: [PATCH] feat(office): implement taskless runs and Office mode repairs (#3777) --- apps/backend/AGENTS.md | 2 +- apps/backend/internal/agent/runtime/facade.go | 49 +++ .../agent/runtime/lifecycle/event_types.go | 43 ++- .../agent/runtime/lifecycle/events.go | 30 +- .../runtime/lifecycle/events_profile_test.go | 8 +- .../agent/runtime/lifecycle/manager.go | 11 +- .../runtime/lifecycle/manager_interaction.go | 7 +- .../agent/runtime/lifecycle/manager_launch.go | 81 +++- .../runtime/lifecycle/manager_lifecycle.go | 2 + .../runtime/lifecycle/manager_startup.go | 13 + .../runtime/lifecycle/manager_streaming.go | 6 + .../agent/runtime/lifecycle/persistence.go | 14 +- .../provider_diagnostic_marker_test.go | 10 + .../lifecycle/run_owner_persistence.go | 85 +++++ .../lifecycle/run_owner_persistence_test.go | 108 ++++++ .../internal/agent/runtime/lifecycle/types.go | 49 ++- .../backend/internal/agent/runtime/runtime.go | 36 ++ .../agent/runtime/runtime_contract_test.go | 115 ++++++ apps/backend/internal/backendapp/main.go | 21 +- .../backendapp/office_run_session_launcher.go | 354 ++++++++++++++++++ apps/backend/internal/office/AGENTS.md | 1 + apps/backend/internal/office/config/dto.go | 15 + .../backend/internal/office/config/handler.go | 40 ++ .../internal/office/config/handler_test.go | 41 ++ .../backend/internal/office/config/service.go | 169 ++++++++- .../internal/office/config/service_test.go | 98 ++++- .../office/dashboard/activity_labels.go | 98 +++++ .../office/dashboard/activity_labels_test.go | 59 +++ apps/backend/internal/office/dashboard/dto.go | 4 + .../internal/office/dashboard/handler_test.go | 15 +- .../internal/office/dashboard/run_detail.go | 80 +++- .../office/dashboard/run_detail_test.go | 72 +++- .../internal/office/dashboard/service.go | 14 +- .../office/dashboard/service_agents.go | 1 + apps/backend/internal/office/models/models.go | 58 ++- apps/backend/internal/office/pause/service.go | 17 + .../internal/office/pause/service_test.go | 96 ++++- apps/backend/internal/office/pause/sweep.go | 42 +++ .../internal/office/repository/sqlite/base.go | 26 ++ .../repository/sqlite/base_migrations.go | 26 ++ .../office/repository/sqlite/costs.go | 37 +- .../office/repository/sqlite/run_sessions.go | 284 ++++++++++++++ .../sqlite/run_sessions_postgres_test.go | 63 ++++ .../repository/sqlite/run_sessions_test.go | 123 ++++++ .../office/repository/sqlite/skills.go | 17 +- .../office/repository/sqlite/skills_test.go | 30 ++ .../repository/sqlite/workspace_deletion.go | 1 + .../sqlite/workspace_deletion_test.go | 2 + .../office/scheduler/dispatch_routing.go | 30 +- apps/backend/internal/office/scheduler/run.go | 16 +- .../internal/office/service/base_test.go | 3 + .../office/service/event_subscribers.go | 240 +++++++++++- .../office/service/scheduler_integration.go | 70 ++-- .../service/scheduler_taskless_launch_test.go | 12 +- .../internal/office/service/service.go | 79 +++- .../internal/office/service/skill_manifest.go | 2 + .../office/service/taskless_completion.go | 36 ++ .../office/service/taskless_lifecycle_test.go | 328 ++++++++++++++++ .../internal/office/service/taskless_usage.go | 22 ++ .../office/service/workspace_deletion.go | 51 +++ .../office/service/workspace_deletion_test.go | 79 ++++ .../internal/runs/repository/sqlite/runs.go | 6 +- .../[id]/runs/components/run-header.test.tsx | 6 + .../[id]/runs/components/run-header.tsx | 5 + .../runs/components/runtime-panel.test.tsx | 32 ++ .../[id]/runs/components/runtime-panel.tsx | 3 +- .../app/office/components/office-shell.tsx | 23 +- .../workspace-pause-banner.test.tsx | 87 +++++ .../components/workspace-pause-banner.tsx | 180 ++++++--- .../components/workspace-pause-controls.tsx | 56 +-- .../workspace/activity/activity-row.test.tsx | 17 + .../workspace/activity/activity-row.tsx | 133 ++++--- .../settings/export/export-preview.tsx | 330 +++++++++++++--- apps/web/e2e/helpers/office-api-client.ts | 4 + .../e2e/tests/office/config-export.spec.ts | 55 +++ .../tests/office/mobile-config-export.spec.ts | 16 + .../mobile-routine-catch-up-policy-ui.spec.ts | 11 +- .../office/mobile-run-observation.spec.ts | 20 + .../mobile-workspace-kill-switch.spec.ts | 32 +- .../office/routine-catch-up-policy-ui.spec.ts | 8 +- .../e2e/tests/office/run-observation.spec.ts | 38 ++ .../office/taskless-routine-session.spec.ts | 87 +++++ .../office/workspace-kill-switch.spec.ts | 8 +- .../api/domains/office-activity-normalize.ts | 37 +- apps/web/lib/api/domains/office-api.ts | 2 + .../api/domains/office-config-export.test.ts | 78 ++++ .../lib/api/domains/office-config-export.ts | 38 ++ .../lib/api/domains/office-extended-api.ts | 3 +- apps/web/lib/api/domains/office-runs-api.ts | 4 + .../lib/desktop/external-links-usage.test.ts | 14 +- apps/web/lib/state/slices/office/types.ts | 3 + apps/web/src/locales/en/office.json | 6 + apps/web/src/locales/pseudo/office.json | 6 + apps/web/src/locales/pt-pt/office.json | 6 + apps/web/src/locales/zh-cn/office.json | 6 + apps/web/src/locales/zh-hk/office.json | 6 + apps/web/src/locales/zh-tw/office.json | 6 + apps/web/src/office-routes.tsx | 2 + ...2026-09-17-office-taskless-run-sessions.md | 58 +++ docs/plans/office-mode-repairs/plan.md | 255 +++++++++++++ .../task-01-run-session-foundation.md | 108 ++++++ .../task-02-taskless-lifecycle.md | 119 ++++++ .../task-03-run-observation.md | 132 +++++++ .../task-04-workspace-topbar.md | 131 +++++++ .../task-05-config-export.md | 136 +++++++ ...task-01-repair-session-open-eligibility.md | 2 +- .../task-02-remove-parking-presentation.md | 2 +- docs/public/office-config-sync.md | 6 + docs/public/office-provider-routing.md | 4 +- docs/specs/office/requirements/overview.md | 17 + .../office/requirements/run-observation.md | 41 ++ .../requirements/taskless-run-sessions.md | 43 +++ .../requirements/workspace-kill-switch.md | 35 +- .../office/system-design/config-export.md | 79 ++++ .../office/system-design/run-observation.md | 69 ++++ .../office/system-design/scheduler-02.md | 8 + .../system-design/taskless-run-sessions.md | 180 +++++++++ .../system-design/workspace-topbar-actions.md | 51 +++ .../requirements/queued-session-ownership.md | 4 +- 119 files changed, 5921 insertions(+), 474 deletions(-) create mode 100644 apps/backend/internal/agent/runtime/lifecycle/run_owner_persistence.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/run_owner_persistence_test.go create mode 100644 apps/backend/internal/backendapp/office_run_session_launcher.go create mode 100644 apps/backend/internal/office/dashboard/activity_labels.go create mode 100644 apps/backend/internal/office/dashboard/activity_labels_test.go create mode 100644 apps/backend/internal/office/repository/sqlite/run_sessions.go create mode 100644 apps/backend/internal/office/repository/sqlite/run_sessions_postgres_test.go create mode 100644 apps/backend/internal/office/repository/sqlite/run_sessions_test.go create mode 100644 apps/backend/internal/office/service/taskless_completion.go create mode 100644 apps/backend/internal/office/service/taskless_lifecycle_test.go create mode 100644 apps/backend/internal/office/service/taskless_usage.go create mode 100644 apps/web/app/office/components/workspace-pause-banner.test.tsx create mode 100644 apps/web/e2e/tests/office/config-export.spec.ts create mode 100644 apps/web/e2e/tests/office/mobile-config-export.spec.ts create mode 100644 apps/web/e2e/tests/office/mobile-run-observation.spec.ts create mode 100644 apps/web/e2e/tests/office/run-observation.spec.ts create mode 100644 apps/web/e2e/tests/office/taskless-routine-session.spec.ts create mode 100644 apps/web/lib/api/domains/office-config-export.test.ts create mode 100644 apps/web/lib/api/domains/office-config-export.ts create mode 100644 docs/decisions/2026-09-17-office-taskless-run-sessions.md create mode 100644 docs/plans/office-mode-repairs/plan.md create mode 100644 docs/plans/office-mode-repairs/task-01-run-session-foundation.md create mode 100644 docs/plans/office-mode-repairs/task-02-taskless-lifecycle.md create mode 100644 docs/plans/office-mode-repairs/task-03-run-observation.md create mode 100644 docs/plans/office-mode-repairs/task-04-workspace-topbar.md create mode 100644 docs/plans/office-mode-repairs/task-05-config-export.md create mode 100644 docs/specs/office/requirements/run-observation.md create mode 100644 docs/specs/office/requirements/taskless-run-sessions.md create mode 100644 docs/specs/office/system-design/config-export.md create mode 100644 docs/specs/office/system-design/run-observation.md create mode 100644 docs/specs/office/system-design/taskless-run-sessions.md create mode 100644 docs/specs/office/system-design/workspace-topbar-actions.md diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 4e50d3d730..933e3f9cdd 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -138,7 +138,7 @@ replace state verification, installation association, or HMAC verification. - `RequiresApproval` on actions: transitions requiring review gating are skipped - Idempotent by `OperationID`; session-scoped data bag via `MachineState.Data` -**Agent Runtime** (`internal/agent/runtime/`) is the single seam for launching, resuming, stopping, and observing agent executions. ADR 0004 introduced this in Phase 1 of task-model-unification. The public surface is `runtime.Runtime` (`runtime.go`); a thin facade (`facade.go`) delegates to a `Backend` (satisfied by `*lifecycle.Manager`). +**Agent Runtime** (`internal/agent/runtime/`) is the single seam for launching, resuming, stopping, and observing agent executions. ADR 0004 introduced this in Phase 1 of task-model-unification. The public surface is `runtime.Runtime` (`runtime.go`); a thin facade (`facade.go`) delegates to a `Backend` (satisfied by `*lifecycle.Manager`). Run-owned executions use `runtime.LaunchSpec.Owner` (`kind=run`) with durable run-session identity; admission fails closed before allocation and lifecycle registration, and `runtime.Start` rolls back failed startup while task launches keep task/session checks. **Runtime environment invariant:** `Agent.Runtime().Env` applies to every ACP subprocess entry point. Route new overrides through host-utility probes and sessionless prompts into agentctl child processes before sanitization; cover probe DTO, prompt DTO, and child-process boundaries. diff --git a/apps/backend/internal/agent/runtime/facade.go b/apps/backend/internal/agent/runtime/facade.go index 7c917e0189..f5cce40d5a 100644 --- a/apps/backend/internal/agent/runtime/facade.go +++ b/apps/backend/internal/agent/runtime/facade.go @@ -35,6 +35,14 @@ type facade struct { // multi-repo specs, attachments) reach the runtime in Phase 1 without // canonicalising them onto LaunchSpec yet. func (f *facade) Launch(ctx context.Context, spec LaunchSpec) (ExecutionRef, error) { + if spec.Owner.Kind == ExecutionOwnerRun { + if spec.OwnerAdmission == nil { + return ExecutionRef{}, fmt.Errorf("execution owner %q has no admission provider", spec.Owner.Kind) + } + if err := spec.OwnerAdmission.AdmitExecution(ctx, spec.Owner); err != nil { + return ExecutionRef{}, err + } + } req := launchRequestFromSpec(spec) exec, err := f.backend.Launch(ctx, req) if err != nil { @@ -43,6 +51,42 @@ func (f *facade) Launch(ctx context.Context, spec LaunchSpec) (ExecutionRef, err return executionRefFromAgentExecution(exec), nil } +// Start launches and starts an execution. The lifecycle manager owns initial +// prompt delivery because it already coordinates ACP session initialization; +// the facade only provides the atomic launch/start envelope and rolls back a +// registered execution when startup fails. +func (f *facade) Start(ctx context.Context, spec LaunchSpec) (ExecutionRef, error) { + ref, err := f.Launch(ctx, spec) + if err != nil { + return ExecutionRef{}, err + } + if err := f.StartExecution(ctx, ref.ID); err != nil { + cleanupErr := f.Stop(context.WithoutCancel(ctx), ref.ID, "runtime_start_failed") + return ExecutionRef{}, errors.Join(err, cleanupErr) + } + return ref, nil +} + +// StartExecution starts a registered execution after rechecking its durable +// owner admission. The lifecycle manager repeats the same check immediately +// before process creation, so a pause or reassignment cannot be crossed by a +// late process start. +func (f *facade) StartExecution(ctx context.Context, executionID string) error { + if executionID == "" { + return fmt.Errorf("runtime: executionID is required") + } + execution, ok := f.backend.GetExecution(executionID) + if !ok || execution == nil { + return ErrNotFound + } + if execution.Owner.Kind == ExecutionOwnerRun && execution.OwnerAdmission != nil { + if err := execution.OwnerAdmission.AdmitExecution(ctx, execution.Owner); err != nil { + return err + } + } + return f.backend.StartAgentProcess(ctx, executionID) +} + // Resume sends a follow-up prompt to an existing execution. Attachments // and dispatchOnly are not surfaced on Runtime.Resume in Phase 1; callers // needing them should use the lifecycle Manager directly until Phase 3 @@ -131,6 +175,10 @@ func launchRequestFromSpec(spec LaunchSpec) *lifecycle.LaunchRequest { if spec.McpMode != "" { req.McpMode = spec.McpMode } + if spec.Owner.Kind != "" { + req.Owner = spec.Owner + req.OwnerAdmission = spec.OwnerAdmission + } if len(spec.Metadata) > 0 { if req.Metadata == nil { req.Metadata = map[string]interface{}{} @@ -174,6 +222,7 @@ func executionFromAgentExecution(exec *lifecycle.AgentExecution) *Execution { ExitCode: exec.ExitCode, ErrorMessage: exec.ErrorMessage, ACPSessionID: exec.ACPSessionID, + Owner: exec.OwnerSnapshot(), Metadata: exec.MetadataSnapshot(), } return out diff --git a/apps/backend/internal/agent/runtime/lifecycle/event_types.go b/apps/backend/internal/agent/runtime/lifecycle/event_types.go index 40108eef39..b46ae9fa88 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/event_types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/event_types.go @@ -12,7 +12,11 @@ import ( type AgentEventPayload struct { AgentExecutionID string `json:"agent_execution_id"` AttemptID string `json:"attempt_id,omitempty"` + OwnerKind ExecutionOwnerKind `json:"owner_kind,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` RunID string `json:"run_id,omitempty"` + RunSessionID string `json:"run_session_id,omitempty"` + RunAttempt int `json:"run_attempt,omitempty"` TaskID string `json:"task_id"` SessionID string `json:"session_id,omitempty"` TaskEnvironmentID string `json:"task_environment_id,omitempty"` @@ -77,17 +81,22 @@ type AgentStalledPayload struct { // AgentctlEventPayload is the payload for agentctl lifecycle events (starting, ready, error). type AgentctlEventPayload struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id"` - TaskEnvironmentID string `json:"task_environment_id,omitempty"` - AgentExecutionID string `json:"agent_execution_id"` - AttemptID string `json:"attempt_id,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - FailureCode string `json:"failure_code,omitempty"` - FailureDetails string `json:"failure_details,omitempty"` - WorktreeID string `json:"worktree_id,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` + OwnerKind ExecutionOwnerKind `json:"owner_kind,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + RunID string `json:"run_id,omitempty"` + RunSessionID string `json:"run_session_id,omitempty"` + RunAttempt int `json:"run_attempt,omitempty"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + TaskEnvironmentID string `json:"task_environment_id,omitempty"` + AgentExecutionID string `json:"agent_execution_id"` + AttemptID string `json:"attempt_id,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + FailureCode string `json:"failure_code,omitempty"` + FailureDetails string `json:"failure_details,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` // TaskWorkspacePath is the task root that contains every per-repo // worktree as a sibling subdir, populated when the event signals a // sibling worktree being added (multi-branch add_branch flow) rather @@ -280,10 +289,16 @@ type AgentStreamEventData struct { type AgentStreamEventPayload struct { Type string `json:"type"` // Always "agent/event" Timestamp string `json:"timestamp"` - AgentID string `json:"agent_id"` // Historical: execution.ID. Prefer ExecutionID. - ExecutionID string `json:"execution_id"` // Lifecycle execution ID; stable across the payload's lifetime. - AttemptID string `json:"attempt_id,omitempty"` // Immutable recovery attempt that owns this callback. + AgentID string `json:"agent_id"` // Historical: execution.ID. Prefer ExecutionID. + ExecutionID string `json:"execution_id"` // Lifecycle execution ID; stable across the payload's lifetime. + AttemptID string `json:"attempt_id,omitempty"` // Immutable recovery attempt that owns this callback. + OwnerKind ExecutionOwnerKind `json:"owner_kind,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + RunID string `json:"run_id,omitempty"` + RunSessionID string `json:"run_session_id,omitempty"` + RunAttempt int `json:"run_attempt,omitempty"` AgentProfileID string `json:"agent_profile_id,omitempty"` // Stable Office identity (execution.officeProfileID()); the agent that is actually running, not the task's assignee. + AgentType string `json:"agent_type,omitempty"` TaskID string `json:"task_id"` SessionID string `json:"session_id"` // Task session ID Data *AgentStreamEventData `json:"data"` diff --git a/apps/backend/internal/agent/runtime/lifecycle/events.go b/apps/backend/internal/agent/runtime/lifecycle/events.go index 5de46ff338..135d808114 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/events.go +++ b/apps/backend/internal/agent/runtime/lifecycle/events.go @@ -137,7 +137,11 @@ func newAgentEventPayloadWithTurnIDAndEvidence( payload := AgentEventPayload{ AgentExecutionID: execution.ID, AttemptID: execution.currentStartupAttemptID(), + OwnerKind: executionOwnerKind(execution), + WorkspaceID: execution.WorkspaceID, RunID: execution.RunID, + RunSessionID: execution.RunSessionID, + RunAttempt: execution.RunAttempt, TaskID: execution.TaskID, SessionID: execution.SessionID, TaskEnvironmentID: execution.TaskEnvironmentID, @@ -182,6 +186,11 @@ func (p *EventPublisher) PublishAgentctlEvent(ctx context.Context, eventType str } payload := AgentctlEventPayload{ + OwnerKind: executionOwnerKind(execution), + WorkspaceID: execution.WorkspaceID, + RunID: execution.RunID, + RunSessionID: execution.RunSessionID, + RunAttempt: execution.RunAttempt, TaskID: execution.TaskID, SessionID: execution.SessionID, TaskEnvironmentID: execution.TaskEnvironmentID, @@ -275,14 +284,22 @@ func (p *EventPublisher) publishAgentStreamEventWithAttempt( AgentID: execution.ID, ExecutionID: execution.ID, AttemptID: attemptID, + OwnerKind: executionOwnerKind(execution), + WorkspaceID: execution.WorkspaceID, + RunID: execution.RunID, + RunSessionID: execution.RunSessionID, + RunAttempt: execution.RunAttempt, AgentProfileID: execution.officeProfileID(), + AgentType: execution.AgentID, TaskID: execution.TaskID, SessionID: execution.SessionID, Data: eventData, } busEvent := bus.NewEvent(events.AgentStream, "agent-manager", payload) - subject := events.BuildAgentStreamSubject(execution.SessionID) + subject := events.BuildAgentStreamSubject(agentStreamSubjectID( + executionOwnerKind(execution), execution.SessionID, execution.RunSessionID, + )) if err := p.eventBus.Publish(context.Background(), subject, busEvent); err != nil { p.logger.Error("failed to publish agent stream event", @@ -351,7 +368,9 @@ func (p *EventPublisher) PublishAgentStreamEventPayload(payload *AgentStreamEven } busEvent := bus.NewEvent(events.AgentStream, "agent-manager", *payload) - subject := events.BuildAgentStreamSubject(payload.SessionID) + subject := events.BuildAgentStreamSubject(agentStreamSubjectID( + payload.OwnerKind, payload.SessionID, payload.RunSessionID, + )) if err := p.eventBus.Publish(context.Background(), subject, busEvent); err != nil { p.logger.Error("failed to publish agent stream event payload", @@ -361,6 +380,13 @@ func (p *EventPublisher) PublishAgentStreamEventPayload(payload *AgentStreamEven } } +func agentStreamSubjectID(ownerKind ExecutionOwnerKind, sessionID, runSessionID string) string { + if ownerKind == ExecutionOwnerRun && runSessionID != "" { + return runSessionID + } + return sessionID +} + // PublishGitEvent publishes a unified git event to the event bus. func (p *EventPublisher) PublishGitEvent(payload *GitEventPayload) { if p.eventBus == nil { diff --git a/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go b/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go index 6ed61f39c7..8c64227685 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go @@ -41,10 +41,16 @@ func TestAgentEventPayloadCarriesProviderErrorAndAgentID(t *testing.T) { } func TestAgentEventPayloadCarriesRunID(t *testing.T) { - payload := newAgentEventPayload(&AgentExecution{ID: "exec-1", RunID: "run-1"}) + payload := newAgentEventPayload(&AgentExecution{ + ID: "exec-1", WorkspaceID: "ws-1", RunID: "run-1", RunSessionID: "run-session-1", RunAttempt: 2, + }) if payload.RunID != "run-1" { t.Fatalf("run ID = %q, want run-1", payload.RunID) } + if payload.OwnerKind != ExecutionOwnerRun || payload.WorkspaceID != "ws-1" || + payload.RunSessionID != "run-session-1" || payload.RunAttempt != 2 { + t.Fatalf("run owner = %#v, want exact run identity", payload) + } } func TestAgentEventPayloadCarriesPromptTurnID(t *testing.T) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager.go b/apps/backend/internal/agent/runtime/lifecycle/manager.go index e0aeb6f295..a171ab30b2 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager.go @@ -44,6 +44,7 @@ type Manager struct { eventBus bus.EventBus credsMgr CredentialsManager profileResolver ProfileResolver + ownerAdmission OwnerAdmission worktreeMgr *worktree.Manager mcpProvider McpConfigProvider logger *logger.Logger @@ -235,7 +236,8 @@ type Manager struct { // runningWriter persists the executors_running row in lockstep with executionStore. // See SetExecutorRunningWriter and persistence.go. The lifecycle manager is the // only component allowed to write the lifecycle-owned columns of this table. - runningWriter ExecutorRunningWriter + runningWriter ExecutorRunningWriter + runRecoveryErr error // executorProfileReader resolves the executor profile bound to a task // environment so user shell terminals can be given the same profile env @@ -285,6 +287,13 @@ type Manager struct { activityGeneration uint64 } +// SetOwnerAdmission wires the durable owner gate used by run-owned launches. +// Task launches keep their existing task/session admission when no owner gate +// is configured. +func (m *Manager) SetOwnerAdmission(admission OwnerAdmission) { + m.ownerAdmission = admission +} + // ManagedGoCacheEnvironmentProvider supplies the environment for one new // local execution. Implementations must return an absolute GOCACHE path. type ManagedGoCacheEnvironmentProvider interface { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index 8a19da5f5b..46d004cf81 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -1226,6 +1226,11 @@ func (m *Manager) StopAgentWithReason(ctx context.Context, executionID string, r exec.FinishedAt = &now }) + if execution.Owner.Kind == ExecutionOwnerRun { + if err := m.persistExecutorRunningResult(ctx, execution); err != nil { + return err + } + } // End session trace span execution.EndSessionSpan() @@ -2642,7 +2647,7 @@ func (m *Manager) stopAgentViaBackend(ctx context.Context, executionID string, e runtimeInstance := &ExecutorInstance{ InstanceID: execution.ID, TaskID: execution.TaskID, - SessionID: execution.SessionID, + SessionID: executionInventorySessionID(execution), ContainerID: execution.ContainerID, StandaloneInstanceID: execution.standaloneInstanceID, StandalonePort: execution.standalonePort, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 5a600eab14..d70a2fee3e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -746,7 +746,7 @@ func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchReq // (session-scoped, cleaned up on task delete via performTaskCleanup). // Office tasks that have no repo (onboarding, planning) take the // non-ephemeral branch and land under /tasks/... - if workspacePath == "" && req.SessionID != "" && m.dataDir != "" { + if workspacePath == "" && (req.SessionID != "" || req.Owner.Kind == ExecutionOwnerRun) && m.dataDir != "" { workspacePath = m.resolveScratchWorkspace(ctx, req) } return @@ -773,7 +773,7 @@ func (m *Manager) resolveScratchWorkspace(ctx context.Context, req *LaunchReques zap.Error(err)) return "" } - if !req.IsEphemeral { + if !req.IsEphemeral && req.Owner.Kind != ExecutionOwnerRun { if err := storageworkspaces.WriteOwnershipMarker(scratchPath, storageworkspaces.OwnershipMarker{ TaskID: req.TaskID, WorkspaceID: req.WorkspaceID, TaskDirName: req.TaskID, LayoutVersion: storageworkspaces.LayoutVersionScratch, @@ -800,6 +800,15 @@ func (m *Manager) resolveScratchWorkspace(ctx context.Context, req *LaunchReques // scratchWorkspacePath computes the scratch workspace path for a launch request. // Returns empty string if the inputs are invalid (path traversal guard, missing IDs). func (m *Manager) scratchWorkspacePath(req *LaunchRequest) string { + if req.Owner.Kind == ExecutionOwnerRun { + if req.WorkspaceID == "" || req.Owner.RunSessionID == "" || strings.ContainsAny(req.Owner.RunSessionID, `/\\`) { + m.logger.Warn("run-owned scratch workspace requires safe workspace and session IDs", + zap.String("run_session_id", req.Owner.RunSessionID), + zap.String("workspace_id", req.WorkspaceID)) + return "" + } + return filepath.Join(m.dataDir, "office-runs", req.WorkspaceID, req.Owner.RunSessionID) + } if req.IsEphemeral { // Legacy quick-chat path — session-scoped, kept for backward compat with // ephemeral one-shot flows. @@ -1046,7 +1055,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st InstanceID: executionID, TaskID: reqWithWorktree.TaskID, TaskTitle: reqWithWorktree.TaskTitle, - SessionID: reqWithWorktree.SessionID, + SessionID: launchInventorySessionID(reqWithWorktree), TaskEnvironmentID: reqWithWorktree.TaskEnvironmentID, WorkspaceReuseRequired: reqWithWorktree.WorkspaceReuseRequired, AgentProfileID: executionProfileID(reqWithWorktree), @@ -1360,6 +1369,12 @@ func (m *Manager) publishLaunchPrepareCompleted(req *LaunchRequest, result *EnvP // If req.SessionID is empty (quick chat / pre-session contexts), no // deduplication key exists and we fall through to direct execution. func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecution, error) { + if req == nil { + return nil, errors.New("launch request is required") + } + if err := m.admitExecutionOwner(ctx, req); err != nil { + return nil, err + } // AC-EXECUTORS-SURVIVAL-002.8/002.16: refuse rather than queue or block a // launch for a session whose recovery guard is currently held. Checked // first, before any activity lease or singleflight coalescing, so a @@ -1712,6 +1727,33 @@ func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*Agen return execution, nil } +func (m *Manager) admitExecutionOwner(ctx context.Context, req *LaunchRequest) error { + if req == nil || req.Owner.Kind != ExecutionOwnerRun { + return nil + } + admission := req.OwnerAdmission + if admission == nil { + admission = m.ownerAdmission + } + if admission == nil { + return fmt.Errorf("execution owner %q has no admission provider", req.Owner.Kind) + } + return admission.AdmitExecution(ctx, req.Owner) +} + +func executionOwnerKind(execution *AgentExecution) ExecutionOwnerKind { + if execution == nil { + return "" + } + if execution.RunID != "" || execution.RunSessionID != "" { + return ExecutionOwnerRun + } + if execution.TaskID != "" || execution.SessionID != "" { + return ExecutionOwnerTask + } + return "" +} + // validateLaunchWorkspaceAdmission checks host-side repository identity before // any durable folder or repository links are reconciled. A linked worktree is // valid when its Git common directory matches the selected repository. Remote @@ -1787,8 +1829,18 @@ func (m *Manager) buildExecutionFromInstance( prepResult *EnvPrepareResult, ) (*AgentExecution, error) { execution := execInstance.ToAgentExecution(execReq) + execution.SessionID = req.SessionID execution.ResumeAttemptID = ResumeAttemptIDFromContext(ctx) execution.RuntimeName = rt.Name() + execution.WorkspaceID = req.WorkspaceID + execution.RunID = req.Owner.RunID + execution.RunSessionID = req.Owner.RunSessionID + execution.RunAttempt = req.Owner.Attempt + execution.Owner = req.Owner + execution.OwnerAdmission = req.OwnerAdmission + if req.Owner.AgentProfileID != "" { + execution.OfficeAgentProfileID = req.Owner.AgentProfileID + } if req.ACPSessionID != "" { execution.ACPSessionID = req.ACPSessionID } @@ -1823,6 +1875,27 @@ func (m *Manager) registerAndPublishExecution( execInstance *ExecutorInstance, sessionID string, ) error { + if execution != nil { + ownerReq := &LaunchRequest{ + OwnerAdmission: execution.OwnerAdmission, + Owner: ExecutionOwner{ + Kind: executionOwnerKind(execution), + WorkspaceID: execution.WorkspaceID, + TaskID: execution.TaskID, + SessionID: execution.SessionID, + RunID: execution.RunID, + RunSessionID: execution.RunSessionID, + Attempt: execution.RunAttempt, + AgentProfileID: execution.OfficeAgentProfileID, + }, + } + if ownerReq.Owner.Kind == ExecutionOwnerRun { + if err := m.admitExecutionOwner(ctx, ownerReq); err != nil { + m.rollbackLaunchExecution(ctx, rt, execInstance, execution, "owner admission failed") + return err + } + } + } if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionAgent); err != nil { m.rollbackLaunchExecution(ctx, rt, execInstance, execution, "session ended during runtime creation") return err @@ -2102,7 +2175,7 @@ func (m *Manager) finishRegisteredLaunchRollback(execution *AgentExecution, task cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if !discardDurable || taskCleanupActive { - m.deleteExecutorRunning(cleanupCtx, execution.SessionID) + m.deleteExecutorRunning(cleanupCtx, executionInventorySessionID(execution)) } else if reader, ok := m.runningWriter.(executorRunningReader); ok { running, err := reader.GetExecutorRunningBySessionID(cleanupCtx, execution.SessionID) if err == nil && running != nil && running.AgentExecutionID == execution.ID { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go index 5d3da0b07f..8e72a2eef4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go @@ -53,6 +53,7 @@ func (m *Manager) Start(ctx context.Context) error { // AC-EXECUTORS-SURVIVAL-002.8) before recovery contacts any control // server, and hand them to every runtime's RecoverInstances unchanged. records, listErr := m.ListLiveStandaloneExecutorsRunning(ctx) + m.runRecoveryErr = listErr if listErr != nil { // A failed read leaves the record set unknown, which is not the same // thing as empty. AC-EXECUTORS-SURVIVAL-002.6 stops a live instance @@ -113,6 +114,7 @@ func (m *Manager) Start(ctx context.Context) error { var err error recovered, err = m.executorRegistry.RecoverAll(recoveryCtx, records) if err != nil { + m.runRecoveryErr = err m.logger.Warn("failed to recover executions from some runtimes", zap.Error(err)) } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go b/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go index db7d2dbed2..ea4bbee217 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go @@ -110,6 +110,12 @@ func (m *Manager) startAgentProcess(ctx context.Context, executionID string) (re if err := execution.contextResetAdmissionError(); err != nil { return err } + if err := m.admitExecutionOwner(ctx, &LaunchRequest{ + Owner: execution.Owner, + OwnerAdmission: execution.OwnerAdmission, + }); err != nil { + return err + } defer func() { retErr = wrapBootstrapFailure(execution, retErr) }() @@ -193,6 +199,13 @@ func (m *Manager) startAgentProcess(ctx context.Context, executionID string) (re approvalPolicy, agentDisplayName := m.resolveApprovalPolicyAndDisplayName(operationCtx, execution) execution.remoteInstanceLifecycleMu.Lock() + if err := m.admitExecutionOwner(operationCtx, &LaunchRequest{ + Owner: execution.Owner, + OwnerAdmission: execution.OwnerAdmission, + }); err != nil { + execution.remoteInstanceLifecycleMu.Unlock() + return err + } if err := m.ensureLaunchSessionStillActive(operationCtx, execution.SessionID, executionAdmissionAgent); err != nil { execution.remoteInstanceLifecycleMu.Unlock() return err diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_streaming.go b/apps/backend/internal/agent/runtime/lifecycle/manager_streaming.go index f5b60ef2c3..2f31008e5a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_streaming.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_streaming.go @@ -360,7 +360,13 @@ func (m *Manager) publishStreamingContentNow( AgentID: execution.ID, ExecutionID: execution.ID, AttemptID: attemptID, + OwnerKind: executionOwnerKind(execution), + WorkspaceID: execution.WorkspaceID, + RunID: execution.RunID, + RunSessionID: execution.RunSessionID, + RunAttempt: execution.RunAttempt, AgentProfileID: execution.officeProfileID(), + AgentType: execution.AgentID, TaskID: execution.TaskID, SessionID: execution.SessionID, Data: &event, diff --git a/apps/backend/internal/agent/runtime/lifecycle/persistence.go b/apps/backend/internal/agent/runtime/lifecycle/persistence.go index 21b78e5154..df283a6a5c 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/persistence.go +++ b/apps/backend/internal/agent/runtime/lifecycle/persistence.go @@ -80,8 +80,8 @@ func buildRunningFromExecution(execution *AgentExecution, prior *models.Executor metadata[MetadataKeyOfficeAgentProfileID] = execution.OfficeAgentProfileID } running := &models.ExecutorRunning{ - ID: execution.SessionID, - SessionID: execution.SessionID, + ID: executionInventorySessionID(execution), + SessionID: executionInventorySessionID(execution), TaskID: execution.TaskID, ExecutorID: strings.TrimSpace(getMetadataString(metadata, "executor_id")), ExecutionProfileID: execution.AgentProfileID, @@ -105,6 +105,14 @@ func buildRunningFromExecution(execution *AgentExecution, prior *models.Executor } running.Metadata[MetadataKeyOfficeAgentProfileID] = officeProfileID } + if execution.Owner.Kind == ExecutionOwnerRun { + if running.Metadata == nil { + running.Metadata = make(map[string]interface{}) + } + running.Metadata[runExecutionOwnerMetadataKey] = execution.Owner + running.Resumable = false + running.WorktreePath = execution.WorkspacePath + } if prior != nil { if strings.TrimSpace(prior.ExecutorID) != "" { running.ExecutorID = prior.ExecutorID @@ -279,7 +287,7 @@ func (m *Manager) persistExecutorRunningResult(ctx context.Context, execution *A // its current columns and the next transition (or reconciliation) re-persists. var prior *models.ExecutorRunning if reader, ok := m.runningWriter.(executorRunningReader); ok { - existing, err := reader.GetExecutorRunningBySessionID(ctx, execution.SessionID) + existing, err := reader.GetExecutorRunningBySessionID(ctx, executionInventorySessionID(execution)) switch { case err == nil: prior = existing diff --git a/apps/backend/internal/agent/runtime/lifecycle/provider_diagnostic_marker_test.go b/apps/backend/internal/agent/runtime/lifecycle/provider_diagnostic_marker_test.go index 45103ff347..69e66c1d30 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/provider_diagnostic_marker_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/provider_diagnostic_marker_test.go @@ -4,6 +4,7 @@ import ( "testing" agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/stretchr/testify/require" ) // TestPublishAgentStreamEvent_CarriesProviderDiagnosticCandidate pins @@ -33,3 +34,12 @@ func TestPublishAgentStreamEvent_CarriesProviderDiagnosticCandidate(t *testing.T t.Fatalf("published AgentStreamEventData did not carry the marker: %+v", streamEvents[0].Data) } } + +func TestAgentStreamSubjectUsesRunSessionForTasklessExecution(t *testing.T) { + require.Equal(t, "run-session-1", agentStreamSubjectID( + ExecutionOwnerRun, "", "run-session-1", + )) + require.Equal(t, "task-session-1", agentStreamSubjectID( + ExecutionOwnerTask, "task-session-1", "run-session-ignored", + )) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/run_owner_persistence.go b/apps/backend/internal/agent/runtime/lifecycle/run_owner_persistence.go new file mode 100644 index 0000000000..4a1c09f60c --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/run_owner_persistence.go @@ -0,0 +1,85 @@ +package lifecycle + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/kandev/kandev/internal/task/models" +) + +const runExecutionOwnerMetadataKey = "execution_owner" + +// Runtime inventory and executor correlation use the durable owner's session. +// AgentExecution.SessionID remains exclusively a task-session identity. +func executionInventorySessionID(execution *AgentExecution) string { + if execution.Owner.Kind == ExecutionOwnerRun { + return execution.Owner.RunSessionID + } + return execution.SessionID +} + +func launchInventorySessionID(req *LaunchRequest) string { + if req.Owner.Kind == ExecutionOwnerRun { + return req.Owner.RunSessionID + } + return req.SessionID +} + +// StopRunOwnerForRecovery proves the predecessor has stopped using its durable +// runtime inventory. A missing in-memory execution is never liveness evidence. +func (m *Manager) StopRunOwnerForRecovery(ctx context.Context, owner ExecutionOwner) error { + if owner.Kind != ExecutionOwnerRun || owner.RunSessionID == "" { + return errors.New("invalid run recovery owner") + } + if err := m.recoveryGuard.CheckLaunchAllowed(owner.RunSessionID); err != nil { + return err + } + reader, ok := m.runningWriter.(executorRunningReader) + if !ok { + return errors.New("run recovery requires durable runtime inventory") + } + row, err := reader.GetExecutorRunningBySessionID(ctx, owner.RunSessionID) + if errors.Is(err, models.ErrExecutorRunningNotFound) || (err == nil && row == nil) { + return m.runRecoveryErr + } + if err != nil { + return err + } + if err := validateRunRecoveryOwner(row, owner); err != nil { + return err + } + if row.Status == models.ExecutorRunningStatusStopped { + return nil + } + backend, err := m.executorRegistry.GetBackend(row.Runtime) + if err != nil { + return err + } + instance := &ExecutorInstance{ + InstanceID: row.AgentExecutionID, SessionID: row.SessionID, + StandaloneInstanceID: row.AgentExecutionID, StandalonePort: row.AgentctlPort, + ContainerID: row.ContainerID, RuntimeName: row.Runtime, Metadata: row.Metadata, + WorkspacePath: row.WorktreePath, StopReason: "office_run_recovery", + } + if err := backend.StopInstance(ctx, instance, true); err != nil { + return fmt.Errorf("stop predecessor run execution: %w", err) + } + return m.runningWriter.RepairExecutorRunningDead(ctx, row.SessionID) +} + +func validateRunRecoveryOwner(row *models.ExecutorRunning, owner ExecutionOwner) error { + var recorded ExecutionOwner + raw, err := json.Marshal(row.Metadata[runExecutionOwnerMetadataKey]) + if err != nil { + return err + } + if err := json.Unmarshal(raw, &recorded); err != nil { + return err + } + if recorded != owner || row.TaskID != "" || row.AgentExecutionID == "" { + return errors.New("run recovery inventory owner mismatch") + } + return nil +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/run_owner_persistence_test.go b/apps/backend/internal/agent/runtime/lifecycle/run_owner_persistence_test.go new file mode 100644 index 0000000000..7593f13117 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/run_owner_persistence_test.go @@ -0,0 +1,108 @@ +package lifecycle + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/db" + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/stretchr/testify/require" +) + +func TestRunOwnerRegistrationPersistsWithoutTaskSession(t *testing.T) { + ctx := context.Background() + repo, database := runOwnerTestRepository(t) + mgr := newTestManager(t) + mgr.SetExecutorRunningWriter(repo) + owner := ExecutionOwner{Kind: ExecutionOwnerRun, WorkspaceID: "ws", RunID: "run", RunSessionID: "run-session", Attempt: 1, AgentProfileID: "office-agent"} + execution := &AgentExecution{ID: "execution", WorkspaceID: "ws", RunID: "run", RunSessionID: "run-session", RunAttempt: 1, Owner: owner, OwnerAdmission: allowRunOwner{}, OfficeAgentProfileID: "office-agent", Status: v1.AgentStatusRunning, RuntimeName: executor.NameStandalone} + backend := &runOwnerRecoveryExecutor{MockExecutor: MockExecutor{name: executor.NameStandalone}} + mgr.executorRegistry = NewExecutorRegistry(mgr.logger) + mgr.executorRegistry.Register(backend) + require.NoError(t, mgr.registerAndPublishExecution(ctx, execution, backend, &ExecutorInstance{InstanceID: execution.ID}, "")) + row, err := repo.GetExecutorRunningBySessionID(ctx, "run-session") + require.NoError(t, err) + require.Equal(t, "execution", row.AgentExecutionID) + require.Empty(t, row.TaskID) + require.Empty(t, execution.SessionID) + var count int + require.NoError(t, database.Get(&count, "SELECT COUNT(*) FROM task_sessions")) + require.Zero(t, count) + recovery, ok := any(mgr).(interface { + StopRunOwnerForRecovery(context.Context, ExecutionOwner) error + }) + require.True(t, ok, "runtime must reconcile the durable run-owned inventory") + mgr.RemoveExecution(execution.ID) + backend.stopErr = errors.New("runtime unavailable") + require.Error(t, recovery.StopRunOwnerForRecovery(ctx, owner)) + row, err = repo.GetExecutorRunningBySessionID(ctx, "run-session") + require.NoError(t, err) + require.NotEqual(t, "stopped", row.Status) + backend.stopErr = nil + wrongOwner := owner + wrongOwner.Attempt++ + require.Error(t, recovery.StopRunOwnerForRecovery(ctx, wrongOwner)) + require.NoError(t, recovery.StopRunOwnerForRecovery(ctx, owner)) + require.Equal(t, "execution", backend.stoppedID) + row, err = repo.GetExecutorRunningBySessionID(ctx, "run-session") + require.NoError(t, err) + require.Equal(t, "stopped", row.Status) + execution.Status = v1.AgentStatusRunning + require.NoError(t, mgr.executionStore.Add(execution)) + require.NoError(t, mgr.persistExecutorRunningResult(ctx, execution)) + require.NoError(t, mgr.StopAgentWithReason(ctx, execution.ID, "office_turn_complete", false)) + row, err = repo.GetExecutorRunningBySessionID(ctx, "run-session") + require.NoError(t, err) + require.Equal(t, "stopped", row.Status, "normal stop must settle the durable run inventory") +} + +type allowRunOwner struct{} + +func (allowRunOwner) AdmitExecution(context.Context, ExecutionOwner) error { return nil } + +type runOwnerRecoveryExecutor struct { + MockExecutor + stopErr error + stoppedID string +} + +func (b *runOwnerRecoveryExecutor) StopInstance(_ context.Context, instance *ExecutorInstance, _ bool) error { + b.stoppedID = instance.StandaloneInstanceID + return b.stopErr +} + +func TestRunOwnerLaunchCreatesWorkspaceWithoutTaskMarker(t *testing.T) { + mgr := newTestManager(t) + mgr.dataDir = t.TempDir() + repo, _ := runOwnerTestRepository(t) + mgr.SetExecutorRunningWriter(repo) + backend := &createInstanceExecutor{MockExecutor: MockExecutor{name: executor.NameStandalone}} + mgr.executorRegistry = NewExecutorRegistry(mgr.logger) + mgr.executorRegistry.Register(backend) + req := &LaunchRequest{WorkspaceID: "ws", AgentProfileID: "profile-1", ExecutorType: "local_pc", + Owner: ExecutionOwner{Kind: ExecutionOwnerRun, WorkspaceID: "ws", RunID: "run", RunSessionID: "run-session", Attempt: 1, AgentProfileID: "office-agent"}, OwnerAdmission: allowRunOwner{}, + } + execution, err := mgr.Launch(context.Background(), req) + require.NoError(t, err) + require.Equal(t, filepath.Join(mgr.dataDir, "office-runs", "ws", "run-session"), execution.WorkspacePath) + require.DirExists(t, execution.WorkspacePath) + require.Empty(t, execution.SessionID) + require.Equal(t, "run-session", backend.lastRequest.SessionID) +} + +func runOwnerTestRepository(t *testing.T) (*tasksqlite.Repository, *sqlx.DB) { + t.Helper() + conn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "runtime.db")) + require.NoError(t, err) + database := sqlx.NewDb(conn, "sqlite3") + t.Cleanup(func() { require.NoError(t, database.Close()) }) + repo, err := tasksqlite.NewWithDB(database, database, nil) + require.NoError(t, err) + + return repo, database +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index e77d6259b7..8a4d58df0e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -37,10 +37,17 @@ type AgentExecution struct { // RunID identifies the Office run that launched this execution. It is // retained after runtime environment cleanup so delayed stop events can // still be attributed to the correct run. - RunID string + RunID string + RunSessionID string + RunAttempt int + Owner ExecutionOwner + // OwnerAdmission is retained with the execution so the registration gate + // uses the same durable owner authority as the pre-allocation gate. + OwnerAdmission OwnerAdmission TaskID string SessionID string TaskEnvironmentID string // Env owning this execution; sessions in the same task share one env + WorkspaceID string // AgentProfileID is the concrete profile used by the running CLI. The // historical name is retained inside lifecycle because profile resolution, // MCP, env, and command construction all consume this value. @@ -341,6 +348,44 @@ type AgentExecution struct { startupCallbackMu sync.RWMutex } +// OwnerSnapshot returns the immutable durable owner carried by this +// execution. The value is copied so restart reconciliation cannot mutate the +// lifecycle store through a runtime observation. +func (e *AgentExecution) OwnerSnapshot() ExecutionOwner { + if e == nil { + return ExecutionOwner{} + } + return e.Owner +} + +// ExecutionOwnerKind identifies the durable coordinator that owns a runtime +// execution. Task and run owners have different admission and event rules. +type ExecutionOwnerKind string + +const ( + ExecutionOwnerTask ExecutionOwnerKind = "task" + ExecutionOwnerRun ExecutionOwnerKind = "run" +) + +// ExecutionOwner is immutable identity carried through launch and lifecycle +// callbacks. Run-owned executions never need a synthetic task ID. +type ExecutionOwner struct { + Kind ExecutionOwnerKind + WorkspaceID string + TaskID string + SessionID string + RunID string + RunSessionID string + Attempt int + AgentProfileID string +} + +// OwnerAdmission is supplied by the durable owner (for example Office) and +// must fail closed when the owner is missing, stale, paused or unreadable. +type OwnerAdmission interface { + AdmitExecution(ctx context.Context, owner ExecutionOwner) error +} + func (e *AgentExecution) isSessionInitialized() bool { e.sessionInitializedMu.RLock() defer e.sessionInitializedMu.RUnlock() @@ -1203,6 +1248,8 @@ type LaunchRequest struct { McpMode string // MCP tool mode: "task" (default), "task-title-pending", "config", "office", or "automation" McpProviders []string // Normalized provider capabilities attached to the task McpProfile *mcpprofile.Context + Owner ExecutionOwner + OwnerAdmission OwnerAdmission // Environment preparation SetupScript string // Setup script to run before agent starts diff --git a/apps/backend/internal/agent/runtime/runtime.go b/apps/backend/internal/agent/runtime/runtime.go index b9474c452d..1179d79e4a 100644 --- a/apps/backend/internal/agent/runtime/runtime.go +++ b/apps/backend/internal/agent/runtime/runtime.go @@ -24,6 +24,20 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) +// These aliases keep higher-level callers on the runtime seam while the +// lifecycle package remains the implementation owner of the wire contracts. +type ExecutionOwnerKind = lifecycle.ExecutionOwnerKind +type ExecutionOwner = lifecycle.ExecutionOwner +type OwnerAdmission = lifecycle.OwnerAdmission +type LaunchRequest = lifecycle.LaunchRequest +type RouteOverride = lifecycle.RouteOverride +type AgentStreamEventPayload = lifecycle.AgentStreamEventPayload + +const ( + ExecutionOwnerTask = lifecycle.ExecutionOwnerTask + ExecutionOwnerRun = lifecycle.ExecutionOwnerRun +) + // Runtime is the public surface for launching, resuming, stopping, and // observing agent executions. // @@ -33,6 +47,15 @@ type Runtime interface { // returns a reference to the execution. Launch(ctx context.Context, spec LaunchSpec) (ExecutionRef, error) + // Start prepares the process for a newly launched execution and delivers + // the initial prompt exactly once through the lifecycle startup seam. + Start(ctx context.Context, spec LaunchSpec) (ExecutionRef, error) + + // StartExecution starts a previously registered execution. Callers that + // need to persist the execution identity before process startup use this + // split launch/start boundary. + StartExecution(ctx context.Context, executionID string) error + // Resume sends a follow-up prompt to an existing execution. Resume(ctx context.Context, executionID string, prompt string) error @@ -60,6 +83,11 @@ type Runtime interface { // as a `*lifecycle.LaunchRequest` until later phases canonicalise them // onto LaunchSpec directly. type LaunchSpec struct { + // Owner and OwnerAdmission are optional for legacy task launches. Run-owned + // Office launches must provide both so admission remains strict. + Owner lifecycle.ExecutionOwner + OwnerAdmission OwnerAdmission + // AgentProfileID identifies the agent profile to run (e.g. "claude-acp-default"). AgentProfileID string @@ -123,6 +151,7 @@ type Execution struct { ExitCode *int ErrorMessage string ACPSessionID string + Owner lifecycle.ExecutionOwner Metadata map[string]interface{} } @@ -146,6 +175,7 @@ var ErrUnsupported = errors.New("runtime: operation not supported") // constructing a full lifecycle Manager. type Backend interface { Launch(ctx context.Context, req *lifecycle.LaunchRequest) (*lifecycle.AgentExecution, error) + StartAgentProcess(ctx context.Context, executionID string) error PromptAgent(ctx context.Context, executionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool) (*lifecycle.PromptResult, error) StopAgentWithReason(ctx context.Context, executionID string, reason string, force bool) error GetExecution(executionID string) (*lifecycle.AgentExecution, bool) @@ -154,3 +184,9 @@ type Backend interface { // Compile-time check: the lifecycle Manager satisfies Backend. var _ Backend = (*lifecycle.Manager)(nil) + +// RunOwnerRecovery reconciles durable run inventory before Office retries work. +// Implemented by the shared lifecycle backend; uncertain liveness is an error. +type RunOwnerRecovery interface { + StopRunOwnerForRecovery(context.Context, ExecutionOwner) error +} diff --git a/apps/backend/internal/agent/runtime/runtime_contract_test.go b/apps/backend/internal/agent/runtime/runtime_contract_test.go index e47e5409aa..ab2ffb2b1c 100644 --- a/apps/backend/internal/agent/runtime/runtime_contract_test.go +++ b/apps/backend/internal/agent/runtime/runtime_contract_test.go @@ -3,6 +3,7 @@ package runtime_test import ( "context" "errors" + "strings" "testing" "time" @@ -20,6 +21,8 @@ type fakeBackend struct { launchReq *lifecycle.LaunchRequest launchExec *lifecycle.AgentExecution launchErr error + startCalls []string + startErr error promptCalls []promptCall promptResult *lifecycle.PromptResult promptErr error @@ -46,14 +49,41 @@ type mcpCall struct { mode string } +type fakeOwnerAdmission struct { + calls int + err error +} + +func (f *fakeOwnerAdmission) AdmitExecution(context.Context, lifecycle.ExecutionOwner) error { + f.calls++ + return f.err +} + func (f *fakeBackend) Launch(_ context.Context, req *lifecycle.LaunchRequest) (*lifecycle.AgentExecution, error) { f.launchReq = req if f.launchErr != nil { return nil, f.launchErr } + if f.launchExec != nil { + f.executions[f.launchExec.ID] = f.launchExec + } return f.launchExec, nil } +// StartAgentProcess is intentionally present in the test backend even before +// Runtime.Start is wired. The failing contract test below proves the facade +// does not expose the required start-and-prompt seam yet. +func (f *fakeBackend) StartAgentProcess(_ context.Context, executionID string) error { + f.startCalls = append(f.startCalls, executionID) + if f.launchReq != nil && f.launchReq.TaskDescription != "" { + f.promptCalls = append(f.promptCalls, promptCall{ + executionID: executionID, + prompt: f.launchReq.TaskDescription, + }) + } + return f.startErr +} + func (f *fakeBackend) PromptAgent(_ context.Context, executionID, prompt string, _ []v1.MessageAttachment, dispatchOnly bool) (*lifecycle.PromptResult, error) { f.promptCalls = append(f.promptCalls, promptCall{executionID: executionID, prompt: prompt, dispatchOnly: dispatchOnly}) if f.promptErr != nil { @@ -155,6 +185,91 @@ func TestRuntime_Launch_TranslatesSpecToLaunchRequest(t *testing.T) { } } +func TestRuntime_StartDispatchesInitialPromptOnce(t *testing.T) { + t.Parallel() + backend := newFakeBackend() + backend.launchExec = &lifecycle.AgentExecution{ID: "exec-start", SessionID: "session-start"} + rt := agentruntime.New(backend) + starter, ok := rt.(interface { + Start(context.Context, agentruntime.LaunchSpec) (agentruntime.ExecutionRef, error) + }) + if !ok { + t.Fatal("runtime does not expose Start") + } + if _, err := starter.Start(context.Background(), agentruntime.LaunchSpec{Prompt: "initial"}); err != nil { + t.Fatalf("Start returned error: %v", err) + } + if len(backend.startCalls) != 1 || backend.startCalls[0] != "exec-start" { + t.Fatalf("start calls = %#v, want one call for exec-start", backend.startCalls) + } + if len(backend.promptCalls) != 1 || backend.promptCalls[0].prompt != "initial" { + t.Fatalf("prompt calls = %#v, want one initial prompt", backend.promptCalls) + } +} + +func TestRuntime_StartRollsBackRegistrationFailure(t *testing.T) { + t.Parallel() + backend := newFakeBackend() + backend.launchExec = &lifecycle.AgentExecution{ID: "exec-rollback", SessionID: "session-rollback"} + backend.startErr = errors.New("startup failed") + rt := agentruntime.New(backend) + starter, ok := rt.(interface { + Start(context.Context, agentruntime.LaunchSpec) (agentruntime.ExecutionRef, error) + }) + if !ok { + t.Fatal("runtime does not expose Start") + } + if _, err := starter.Start(context.Background(), agentruntime.LaunchSpec{}); err == nil { + t.Fatal("Start returned nil error after startup failure") + } + if len(backend.stopCalls) != 1 || backend.stopCalls[0].executionID != "exec-rollback" { + t.Fatalf("stop calls = %#v, want one rollback for exec-rollback", backend.stopCalls) + } +} + +func TestRuntime_RunOwnerAdmissionRejectsStaleAttempt(t *testing.T) { + t.Parallel() + backend := newFakeBackend() + backend.launchExec = &lifecycle.AgentExecution{ID: "exec-owner"} + admission := &fakeOwnerAdmission{err: errors.New("run attempt is stale")} + rt := agentruntime.New(backend) + _, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{ + Owner: lifecycle.ExecutionOwner{ + Kind: lifecycle.ExecutionOwnerRun, + WorkspaceID: "workspace-owner", + RunID: "run-owner", + RunSessionID: "session-owner", + Attempt: 3, + }, + OwnerAdmission: admission, + }) + if err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("Launch error = %v, want stale-owner rejection", err) + } + if admission.calls != 1 { + t.Fatalf("admission calls = %d, want 1", admission.calls) + } + if backend.launchReq != nil { + t.Fatal("backend launch ran after owner admission rejected the attempt") + } +} + +func TestRuntime_TaskOwnerDoesNotRequireRunAdmission(t *testing.T) { + t.Parallel() + backend := newFakeBackend() + backend.launchExec = &lifecycle.AgentExecution{ID: "exec-task-owner"} + rt := agentruntime.New(backend) + if _, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{ + Owner: lifecycle.ExecutionOwner{ + Kind: lifecycle.ExecutionOwnerTask, + TaskID: "task-owner", + SessionID: "session-owner", + }, + }); err != nil { + t.Fatalf("task-owned Launch returned error: %v", err) + } +} + func TestRuntime_Launch_PassesThroughLaunchRequestMetadata(t *testing.T) { t.Parallel() backend := newFakeBackend() diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index e728f30e23..bc1971f5b8 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -58,6 +58,7 @@ import ( "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" + runtimeapi "github.com/kandev/kandev/internal/agent/runtime" agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" runtimeskill "github.com/kandev/kandev/internal/agent/runtime/lifecycle/skill" @@ -1130,6 +1131,11 @@ func startGatewayAndServe( closeBoundListeners(server, listeners, log) return false } + if err := runProcessorSvc.ReconcileRunSessions(ctx); err != nil { + log.Error("Failed to reconcile Office run sessions", zap.Error(err)) + closeBoundListeners(server, listeners, log) + return false + } scheduling := startSchedulingRuntime( ctx, repos, services, eventBus, orchestratorSvc, runProcessorSvc, log, runsscheduler.TickIntervalFromConfig(cfg.Office.SchedulerTickMs), @@ -1407,7 +1413,7 @@ func initOfficeServices( runProcessorSvc := newRunProcessorService( cfg, repos, services, orchestratorSvc, eventBus, - agentctlBinaryPath, cfgLoader, cfgWriter, log, + agentctlBinaryPath, cfgLoader, cfgWriter, lifecycleMgr, log, ) // Task dependencies are a core Kanban relationship, not an Office feature. @@ -1467,7 +1473,7 @@ func initOfficeServices( // Build feature-package services and wire all inter-service dependencies. services.OfficeSvcs = buildOfficeFeatureServices( repos.Office, repos.Task, repos.AgentSettings, cfgLoader, cfgWriter, configBasePath, - agentRegistry, log, services, cfg.Office.JWTSigningKey, + agentRegistry, log, services, lifecycleMgr, cfg.Office.JWTSigningKey, ) wireOfficeSvcsDependencies(services, repos, eventBus, orchestratorSvc, agentRegistry) services.OfficeSvcs.Dashboard.SetOfficeSessionIdentity(cfg.Features.OfficeSessionIdentity) @@ -1516,13 +1522,14 @@ func newRunProcessorService( agentctlBinaryPath string, cfgLoader *configloader.ConfigLoader, cfgWriter *configloader.FileWriter, + lifecycleMgr *lifecycle.Manager, log *logger.Logger, ) *officeservice.Service { apiPort := cfg.Server.Port if apiPort == 0 { apiPort = ports.Backend } - return officeservice.NewService(officeservice.ServiceOptions{ + svc := officeservice.NewService(officeservice.ServiceOptions{ Repo: repos.Office, Logger: log, CfgLoader: cfgLoader, @@ -1537,6 +1544,8 @@ func newRunProcessorService( AgentctlBinaryPath: agentctlBinaryPath, EventBus: eventBus, }) + svc.SetRunSessionLauncher(newOfficeRunSessionLauncher(repos.Office, lifecycleMgr, log)) + return svc } // wireOfficeSvcsDependencies wires inter-service dependencies into the @@ -1637,6 +1646,7 @@ func wireOfficeProviderRouting( resolver.SetExecutionProfileStore(repos.AgentSettings, agentRegistry) scheduler.SetResolver(resolver) scheduler.SetTaskStarter(&schedulerTaskStarterAdapter{orch: orchestratorSvc}) + scheduler.SetRunSessionLauncher(services.Office.RunSessionLauncherHandle()) scheduler.SetEventBus(eventBus) services.Office.SetRoutingDispatcher(scheduler) @@ -2350,6 +2360,7 @@ func buildOfficeFeatureServices( agentRegistry *registry.Registry, log *logger.Logger, services *Services, + lifecycleMgr *lifecycle.Manager, jwtSigningKey string, ) *office.Services { activity := officeshared.NewActivityLogger(repo, log) @@ -2419,6 +2430,10 @@ func buildOfficeFeatureServices( // (it already delegates to the orchestrator via SetTaskCanceller); // services.Task satisfies WorkspaceChecker. pauseSvc := officepause.NewService(repo, services.Office, services.Task, log) + pauseSvc.SetRunExecutionStopper(runtimeapi.New(lifecycleMgr)) + if services.Office != nil { + services.Office.SetRunExecutionStopper(runtimeapi.New(lifecycleMgr)) + } routineSvc.SetPauseGate(pauseSvc) routineWakeupDispatcher.SetPauseGate(pauseSvc) schedulerSvc.SetPauseGate(pauseSvc) diff --git a/apps/backend/internal/backendapp/office_run_session_launcher.go b/apps/backend/internal/backendapp/office_run_session_launcher.go new file mode 100644 index 0000000000..02eb2a6ae8 --- /dev/null +++ b/apps/backend/internal/backendapp/office_run_session_launcher.go @@ -0,0 +1,354 @@ +package backendapp + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + runtimeapi "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/office/models" + officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" + officeservice "github.com/kandev/kandev/internal/office/service" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// officeRunSessionLauncher is the backend composition seam for taskless +// Office runs. It reserves the Office-owned attempt first, then delegates +// process allocation and prompt delivery to the shared runtime. +type officeRunSessionLauncher struct { + repo *officesqlite.Repository + runtime runtimeapi.Runtime + recovery runtimeapi.RunOwnerRecovery + logger *logger.Logger +} + +var _ officeservice.RunSessionLauncher = (*officeRunSessionLauncher)(nil) +var _ runtimeapi.OwnerAdmission = (*officeRunSessionLauncher)(nil) + +func newOfficeRunSessionLauncher( + repo *officesqlite.Repository, runtimeBackend runtimeapi.Backend, log *logger.Logger, +) *officeRunSessionLauncher { + recovery, _ := runtimeBackend.(runtimeapi.RunOwnerRecovery) + return &officeRunSessionLauncher{ + repo: repo, + runtime: runtimeapi.New(runtimeBackend), + recovery: recovery, + logger: log.WithFields(zap.String("component", "office-run-session-launcher")), + } +} + +//nolint:cyclop,funlen // The launch sequence keeps reservation, identity binding, admission, and cleanup together. +func (l *officeRunSessionLauncher) StartRunSession( + ctx context.Context, + run *models.Run, + agent *models.AgentInstance, + launch officeservice.LaunchContext, + route *officeservice.RouteOverride, +) (officeservice.RunSessionLaunch, error) { + if run == nil || agent == nil { + return officeservice.RunSessionLaunch{}, errors.New("run-session launch requires run and agent") + } + if run.ID == "" || agent.ID == "" || agent.WorkspaceID == "" { + return officeservice.RunSessionLaunch{}, errors.New("run-session launch has incomplete owner identity") + } + sessions, err := l.repo.ListRunSessions(ctx, run.ID) + if err != nil { + return officeservice.RunSessionLaunch{}, fmt.Errorf("list run sessions: %w", err) + } + attempt := 1 + for _, existing := range sessions { + if existing.Attempt >= attempt { + attempt = existing.Attempt + 1 + } + } + now := time.Now().UTC() + session := &models.RunSession{ + ID: uuid.NewString(), + WorkspaceID: agent.WorkspaceID, + AgentProfileID: agent.ID, + RunID: run.ID, + Attempt: attempt, + State: models.RunSessionStatePreparing, + CreatedAt: now, + Version: 1, + } + reserved, err := l.repo.ReserveRunSession(ctx, session) + if err != nil { + return officeservice.RunSessionLaunch{}, err + } + if !reserved { + return officeservice.RunSessionLaunch{}, fmt.Errorf("run %q attempt %d was not admitted", run.ID, attempt) + } + + executionProfileID := launch.ProfileID + if route != nil && route.ExecutionProfileID != "" { + executionProfileID = route.ExecutionProfileID + } + env := cloneStringMap(launch.Env) + if route != nil { + for key, value := range route.Env { + env[key] = value + } + } + owner := runtimeapi.ExecutionOwner{ + Kind: runtimeapi.ExecutionOwnerRun, + WorkspaceID: agent.WorkspaceID, + RunID: run.ID, + RunSessionID: session.ID, + Attempt: attempt, + AgentProfileID: agent.ID, + } + request := &runtimeapi.LaunchRequest{ + WorkspaceID: agent.WorkspaceID, + AgentProfileID: agent.ID, + ExecutionProfileID: executionProfileID, + StartAgent: true, + TaskDescription: launch.Prompt, + Env: env, + AdditionalSkillSlugs: append([]string(nil), launch.AdditionalSkillSlugs...), + ExecutorType: launch.ExecutorID, + McpMode: "office", + Owner: owner, + OwnerAdmission: l, + Metadata: map[string]interface{}{ + "office_run_id": run.ID, + "office_run_session_id": session.ID, + "office_run_attempt": attempt, + }, + } + if route != nil { + request.ModelOverride = route.Model + request.RouteOverride = &runtimeapi.RouteOverride{ + ExecutionProfileID: route.ExecutionProfileID, + ProviderID: route.ProviderID, + Model: route.Model, + Tier: route.Tier, + Mode: route.Mode, + Flags: append([]string(nil), route.Flags...), + Env: cloneStringMap(route.Env), + } + } + ref, err := l.runtime.Launch(ctx, runtimeapi.LaunchSpec{ + AgentProfileID: agent.ID, + ExecutorID: launch.ExecutorID, + Prompt: launch.Prompt, + McpMode: "office", + Metadata: map[string]any{"launch_request": request}, + Owner: owner, + OwnerAdmission: l, + }) + if err != nil { + _, _ = l.repo.FinishRunSession(context.WithoutCancel(ctx), session.ID, models.RunSessionStateFailed, err.Error()) + return officeservice.RunSessionLaunch{}, fmt.Errorf("start run session: %w", err) + } + execution, err := l.runtime.GetExecution(ctx, ref.ID) + if err != nil { + _, _ = l.repo.FinishRunSession(context.WithoutCancel(ctx), session.ID, models.RunSessionStateFailed, err.Error()) + return officeservice.RunSessionLaunch{}, fmt.Errorf("read started run session: %w", err) + } + adapter := agent.AgentID + model := agent.Model + if route != nil { + if route.ProviderID != "" { + adapter = route.ProviderID + } + if route.Model != "" { + model = route.Model + } + } + bound, err := l.repo.BindRunSessionExecution( + ctx, session.ID, ref.ID, executionProfileID, adapter, model, execution.ACPSessionID, + ) + if err != nil { + _ = l.runtime.Stop(context.WithoutCancel(ctx), ref.ID, "run_session_identity_persist_failed") + return officeservice.RunSessionLaunch{}, err + } + if !bound { + _ = l.runtime.Stop(context.WithoutCancel(ctx), ref.ID, "run_session_not_admitted") + return officeservice.RunSessionLaunch{}, fmt.Errorf("run session %q was no longer preparing", session.ID) + } + if err := l.AdmitExecution(ctx, owner); err != nil { + _ = l.runtime.Stop(context.WithoutCancel(ctx), ref.ID, "run_session_start_admission_failed") + _, _ = l.repo.FinishRunSession(context.WithoutCancel(ctx), session.ID, models.RunSessionStateInterrupted, err.Error()) + return officeservice.RunSessionLaunch{}, err + } + if err := l.runtime.StartExecution(ctx, ref.ID); err != nil { + _ = l.runtime.Stop(context.WithoutCancel(ctx), ref.ID, "run_session_start_failed") + _, _ = l.repo.FinishRunSession(context.WithoutCancel(ctx), session.ID, models.RunSessionStateFailed, err.Error()) + return officeservice.RunSessionLaunch{}, fmt.Errorf("start run session: %w", err) + } + execution, err = l.runtime.GetExecution(ctx, ref.ID) + if err != nil { + _ = l.runtime.Stop(context.WithoutCancel(ctx), ref.ID, "run_session_state_read_failed") + _, _ = l.repo.FinishRunSession(context.WithoutCancel(ctx), session.ID, models.RunSessionStateFailed, err.Error()) + return officeservice.RunSessionLaunch{}, fmt.Errorf("read started run session: %w", err) + } + started, err := l.repo.MarkRunSessionStarted(ctx, session.ID, ref.ID, executionProfileID, adapter, model, execution.ACPSessionID) + if err != nil { + _ = l.runtime.Stop(context.WithoutCancel(ctx), ref.ID, "run_session_persist_failed") + return officeservice.RunSessionLaunch{}, err + } + if !started { + _ = l.runtime.Stop(context.WithoutCancel(ctx), ref.ID, "run_session_not_admitted") + return officeservice.RunSessionLaunch{}, fmt.Errorf("run session %q was no longer preparing", session.ID) + } + l.logger.Info("started taskless Office run", + zap.String("run_id", run.ID), zap.String("run_session_id", session.ID), + zap.String("execution_id", ref.ID), zap.Int("attempt", attempt)) + return officeservice.RunSessionLaunch{ + SessionID: session.ID, + ExecutionID: ref.ID, + ExecutionProfileID: executionProfileID, + Adapter: adapter, + Model: model, + ACPSessionID: execution.ACPSessionID, + }, nil +} + +// ReconcileRunSessions runs after lifecycle recovery and before the Office +// scheduler can claim work. A replacement is allowed only after the exact +// predecessor is proven absent or terminal. +// +//nolint:cyclop,gocognit // Recovery must handle each durable predecessor state before replacement. +func (l *officeRunSessionLauncher) ReconcileRunSessions(ctx context.Context) error { + sessions, err := l.repo.ListUnfinishedRunSessions(ctx) + if err != nil { + return err + } + for _, session := range sessions { + if session.ExecutionID == "" { + if err := l.stopRecoveredRunSession(ctx, session); err != nil { + return err + } + if _, err := l.repo.FinishRunSession(ctx, session.ID, models.RunSessionStateInterrupted, "runtime execution was not registered"); err != nil { + return err + } + _, _ = l.repo.RequeueClaimedRun(ctx, session.RunID) + continue + } + execution, err := l.runtime.GetExecution(ctx, session.ExecutionID) + if err != nil { + if !runtimeapi.IsNotFound(err) { + return fmt.Errorf("reconcile run session %q: inspect execution: %w", session.ID, err) + } + if err := l.stopRecoveredRunSession(ctx, session); err != nil { + return err + } + if _, finishErr := l.repo.FinishRunSession(ctx, session.ID, models.RunSessionStateInterrupted, "runtime execution was not found after restart"); finishErr != nil { + return finishErr + } + _, _ = l.repo.RequeueClaimedRun(ctx, session.RunID) + continue + } + if !sameRunOwner(execution.Owner, session) { + return fmt.Errorf("reconcile run session %q: runtime owner identity is uncertain", session.ID) + } + if isLiveExecution(execution.Status) { + continue + } + state := models.RunSessionStateInterrupted + message := "runtime execution ended during restart" + switch execution.Status { + case v1.AgentStatusCompleted: + state = models.RunSessionStateFinished + message = "" + case v1.AgentStatusFailed: + state = models.RunSessionStateFailed + message = execution.ErrorMessage + } + if _, finishErr := l.repo.FinishRunSession(ctx, session.ID, state, message); finishErr != nil { + return finishErr + } + if state == models.RunSessionStateFinished { + outcome := officeservice.RunOutcomeProcessed + if _, finishErr := l.repo.FinishRun(ctx, session.RunID, officeservice.RunStatusFinished, &outcome); finishErr != nil { + return finishErr + } + } + if state == models.RunSessionStateInterrupted || state == models.RunSessionStateFailed { + _, _ = l.repo.RequeueClaimedRun(ctx, session.RunID) + } + } + return nil +} + +func sameRunOwner(owner runtimeapi.ExecutionOwner, session models.RunSession) bool { + return owner.Kind == runtimeapi.ExecutionOwnerRun && owner.WorkspaceID == session.WorkspaceID && + owner.RunID == session.RunID && owner.RunSessionID == session.ID && owner.Attempt == session.Attempt && + owner.AgentProfileID == session.AgentProfileID +} + +func isLiveExecution(status v1.AgentStatus) bool { + return status == v1.AgentStatusPending || status == v1.AgentStatusStarting || + status == v1.AgentStatusRunning || status == v1.AgentStatusReady +} + +// AdmitExecution is the fail-closed owner check used before allocation, at +// lifecycle registration, and immediately before process start. +// +//nolint:cyclop // The ordered fail-closed checks are the owner admission invariant. +func (l *officeRunSessionLauncher) AdmitExecution(ctx context.Context, owner runtimeapi.ExecutionOwner) error { + if owner.Kind != runtimeapi.ExecutionOwnerRun || owner.WorkspaceID == "" || owner.RunID == "" || + owner.RunSessionID == "" || owner.Attempt <= 0 || owner.AgentProfileID == "" { + return errors.New("run execution owner is incomplete") + } + run, err := l.repo.GetRun(ctx, owner.RunID) + if err != nil { + return fmt.Errorf("admit run execution: read run: %w", err) + } + if run.Status != "claimed" || run.AgentProfileID != owner.AgentProfileID { + return fmt.Errorf("admit run execution: run %q is not the claimed owner", owner.RunID) + } + session, err := l.repo.GetRunSession(ctx, owner.RunSessionID) + if err != nil { + return fmt.Errorf("admit run execution: read session: %w", err) + } + if session == nil || session.RunID != owner.RunID || session.Attempt != owner.Attempt || + session.WorkspaceID != owner.WorkspaceID || session.AgentProfileID != owner.AgentProfileID || + session.CancelRequestedAt != nil || + (session.State != models.RunSessionStatePreparing && session.State != models.RunSessionStateRunning) { + return fmt.Errorf("admit run execution: run session %q is stale", owner.RunSessionID) + } + paused, err := l.repo.GetActiveWorkspacePause(ctx, owner.WorkspaceID) + if err != nil { + return fmt.Errorf("admit run execution: read workspace pause: %w", err) + } + if paused != nil { + return fmt.Errorf("admit run execution: workspace %q is paused", owner.WorkspaceID) + } + agent, err := l.repo.GetAgentInstance(ctx, owner.AgentProfileID) + if err != nil { + return fmt.Errorf("admit run execution: read agent: %w", err) + } + if agent.Status != models.AgentStatusIdle && agent.Status != models.AgentStatusWorking { + return fmt.Errorf("admit run execution: agent %q is not eligible", owner.AgentProfileID) + } + return nil +} + +func cloneStringMap(input map[string]string) map[string]string { + if len(input) == 0 { + return map[string]string{} + } + output := make(map[string]string, len(input)) + for key, value := range input { + output[key] = value + } + return output +} + +func (l *officeRunSessionLauncher) stopRecoveredRunSession(ctx context.Context, session models.RunSession) error { + if l.recovery == nil { + return errors.New("run recovery requires runtime inventory reconciliation") + } + return l.recovery.StopRunOwnerForRecovery(ctx, runtimeapi.ExecutionOwner{ + Kind: runtimeapi.ExecutionOwnerRun, WorkspaceID: session.WorkspaceID, + RunID: session.RunID, RunSessionID: session.ID, Attempt: session.Attempt, + AgentProfileID: session.AgentProfileID, + }) +} diff --git a/apps/backend/internal/office/AGENTS.md b/apps/backend/internal/office/AGENTS.md index a0cbf11c69..12f889bc9b 100644 --- a/apps/backend/internal/office/AGENTS.md +++ b/apps/backend/internal/office/AGENTS.md @@ -34,6 +34,7 @@ The two rows above are the only entries reflecting the newer `requirements/` + ` ## Traps - **A run is not a task start.** The `runs` table carries provider routing (`resolved_provider_id`, `logical_provider_order`, `requested_tier`, plus the `office_run_route_attempts` ledger), the per-reason `assembled_prompt`, the `AppendRunEvent` timeline, and retry/backoff/coalescing (`retry_count`, `earliest_retry_at`, `coalesced_count`, `idempotency_key` with unique index `idx_run_idempotency`). `Service.QueueRun` (`service/run.go`) and `SchedulerService.QueueRun` (`scheduler/run.go`) are the entry points that populate all of it; `TaskStarter` (the `StartTask` seam office calls, `service/service.go`) runs only *after*. Calling `StartTask` directly, or any ordinary kanban start path, skips all of the above. See `scheduler.md`. +- **Run-owned sessions are not task sessions.** `office_run_sessions` records one immutable execution attempt per `(run_id, attempt)` and is reserved while the run is claimed. `runtime.LaunchSpec.Owner` carries that identity through lifecycle events; a retry gets a new run-session ID and delayed predecessor events must not finish or charge the successor. - **`heartbeat` as a wake source is retired** (`scheduler.md`: routines are "the only mechanism for periodic wakes"; `wakeup/payloads.go` states `"heartbeat"` is intentionally absent from the `Source` enum) — but three literal `"heartbeat"` constants still exist in code (`scheduler/run.go`'s and `service/run.go`'s duplicate `RunReasonHeartbeat`, `routing/types.go`'s `WakeReasonHeartbeat`, still listed in `AllWakeReasons` and used to seed a `TierPerReason` policy in `onboarding/service.go`). `checkIdleSkip` (`service/scheduler_integration.go`) delegates classification to `shared.IsPeriodicTasklessWake(run.Reason)`, true only for `RunReasonRoutineDispatchCron` and `RunReasonHeartbeat`. The lightweight routine path (`routines/service.go`) sets the reason via `shared.RoutineDispatchReason(source)` — cron gets `RunReasonRoutineDispatchCron` (skippable), manual/webhook gets `RunReasonRoutineDispatchEvent` (never skippable). The legacy `RunReasonRoutineDispatch` ("routine_dispatch") literal, written for all three triggers before the cron-only constant existed, is treated as non-skippable since a persisted run row can't say which trigger produced it. The wakeup dispatcher's claim-time coalesce (`wakeup/dispatcher.go`'s `coalesceIntoInflightRun`) promotes an in-flight `queued` run's reason to the incoming request's (or its `Source` when `Reason` is empty — same fallback as `createFreshRun`) when the merge would otherwise let a periodic-classified run swallow an event-triggered one — promotion is monotonic (periodic → event only). A **`claimed`** inflight run is never promoted in place: `processRun` (`service/scheduler_integration.go`) evaluates `checkIdleSkip` against the `*models.Run` it captured at claim time and never re-reads `Reason`, so mutating the row afterward would race a decision already in flight. An event request that would otherwise coalesce into a claimed periodic run gets its own fresh run instead. Check the reason string (and whether it could have been promoted by a coalesce, or split into a second run because the inflight one was already claimed) before assuming an idle-skip gate fires for a given run. - **Continuation summary scope must come from `summaryScopeForRun`** (`service/event_subscribers.go`), never a literal string. Its sole caller, `refreshContinuationSummary`, upserts under the same value it reads, so reader and writer can't drift. Two scope forms exist: `routine:` and `agent:`; the legacy `"heartbeat"` scope is retired. - **Participant slate: one builder, not office's own.** The AC-50 slate construction lives in `internal/workflow/engine` — `(*Engine).requiredSeats` / `requiredSeatsForWorkflow` (`workflow/engine/quorum.go`) — and `ResolveParticipantRole` (same file) reuses it so role resolution and slate membership can never disagree about who participates. Office reaches it through `engine_dispatcher.Dispatcher.ResolveParticipantRole`, which forwards verbatim. Do not add an office-local "who are this task's reviewers" query; that is a fourth answer to a question that already has exactly one. diff --git a/apps/backend/internal/office/config/dto.go b/apps/backend/internal/office/config/dto.go index 41c90b38d8..433b8e6f44 100644 --- a/apps/backend/internal/office/config/dto.go +++ b/apps/backend/internal/office/config/dto.go @@ -1,6 +1,21 @@ // Package config provides config sync, import, and export for the office domain. package config +// ExportFile is one server-serialized file in a workspace configuration +// export. The path is the exact archive entry name used by ZIP downloads. +type ExportFile struct { + Path string `json:"path"` + Content string `json:"content"` +} + +// ExportManifest is the immutable server view used by the export preview and +// selected-file download. Revision changes whenever any file path or content +// changes. +type ExportManifest struct { + Revision string `json:"revision"` + Files []ExportFile `json:"files"` +} + // ConfigBundle represents a portable workspace configuration. type ConfigBundle struct { Settings SettingsConfig `json:"settings" yaml:"settings"` diff --git a/apps/backend/internal/office/config/handler.go b/apps/backend/internal/office/config/handler.go index f0707279dd..63f562ea3e 100644 --- a/apps/backend/internal/office/config/handler.go +++ b/apps/backend/internal/office/config/handler.go @@ -2,6 +2,8 @@ package config import ( "context" + "encoding/json" + "errors" "io" "net/http" @@ -56,7 +58,9 @@ func (h *Handler) refuseIfConfigSyncActive(c *gin.Context, workspaceID string) b // RegisterRoutes registers all config HTTP routes on the given router group. func RegisterRoutes(api *gin.RouterGroup, h *Handler) { api.GET("/workspaces/:wsId/config/export", h.exportConfig) + api.GET("/workspaces/:wsId/config/export/manifest", h.exportConfigManifest) api.GET("/workspaces/:wsId/config/export/zip", h.exportConfigZip) + api.POST("/workspaces/:wsId/config/export/zip", h.exportSelectedConfigZip) api.POST("/workspaces/:wsId/config/preview", h.previewImport) api.POST("/workspaces/:wsId/config/import", h.applyImport) api.GET("/workspaces/:wsId/config/sync/incoming", h.syncIncomingDiff) @@ -74,6 +78,15 @@ func (h *Handler) exportConfig(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"bundle": bundle}) } +func (h *Handler) exportConfigManifest(c *gin.Context) { + manifest, err := h.svc.ExportManifest(c.Request.Context(), c.Param("wsId")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, manifest) +} + func (h *Handler) exportConfigZip(c *gin.Context) { reader, err := h.svc.ExportZip(c.Request.Context(), c.Param("wsId")) if err != nil { @@ -86,6 +99,33 @@ func (h *Handler) exportConfigZip(c *gin.Context) { _, _ = io.Copy(c.Writer, reader) } +func (h *Handler) exportSelectedConfigZip(c *gin.Context) { + var request struct { + Revision string `json:"revision"` + Paths []string `json:"paths"` + } + if err := json.NewDecoder(c.Request.Body).Decode(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + reader, err := h.svc.ExportSelectedZip(c.Request.Context(), c.Param("wsId"), request.Revision, request.Paths) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, ErrExportRevisionConflict): + status = http.StatusConflict + case errors.Is(err, ErrInvalidExportSelection): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", "attachment; filename=kandev-config-selected.zip") + c.Status(http.StatusOK) + _, _ = io.Copy(c.Writer, reader) +} + func (h *Handler) previewImport(c *gin.Context) { var bundle ConfigBundle if err := c.ShouldBindJSON(&bundle); err != nil { diff --git a/apps/backend/internal/office/config/handler_test.go b/apps/backend/internal/office/config/handler_test.go index 5afb5cd159..83412f7de6 100644 --- a/apps/backend/internal/office/config/handler_test.go +++ b/apps/backend/internal/office/config/handler_test.go @@ -101,7 +101,9 @@ func TestRegisterRoutes_RegistersEveryEndpoint(t *testing.T) { want := map[string]string{ "GET /api/v1/workspaces/:wsId/config/export": "", + "GET /api/v1/workspaces/:wsId/config/export/manifest": "", "GET /api/v1/workspaces/:wsId/config/export/zip": "", + "POST /api/v1/workspaces/:wsId/config/export/zip": "", "POST /api/v1/workspaces/:wsId/config/preview": "", "POST /api/v1/workspaces/:wsId/config/import": "", "GET /api/v1/workspaces/:wsId/config/sync/incoming": "", @@ -174,6 +176,45 @@ func TestHandler_ExportConfigZip_ServiceError(t *testing.T) { } } +func TestHandler_ExportSelectedConfigZip(t *testing.T) { + router, env := newTestRouter(t) + seedAgent(t, env, testWorkspaceID, "ada") + manifest, err := env.svc.ExportManifest(context.Background(), testWorkspaceID) + if err != nil { + t.Fatalf("ExportManifest: %v", err) + } + body, _ := json.Marshal(map[string]any{ + "revision": manifest.Revision, + "paths": []string{manifest.Files[0].Path}, + }) + rec := do(t, router, http.MethodPost, + "/api/v1/workspaces/"+testWorkspaceID+"/config/export/zip", string(body)) + assertStatus(t, rec, http.StatusOK) + entries := readZip(t, rec.Body) + if len(entries) != 1 { + t.Fatalf("selected archive entries = %d, want 1", len(entries)) + } + if _, ok := entries[manifest.Files[0].Path]; !ok { + t.Fatalf("selected path %q missing from %v", manifest.Files[0].Path, entries) + } +} + +func TestHandler_ExportSelectedConfigZipRejectsStaleRevision(t *testing.T) { + router, env := newTestRouter(t) + seedAgent(t, env, testWorkspaceID, "ada") + manifest, err := env.svc.ExportManifest(context.Background(), testWorkspaceID) + if err != nil { + t.Fatalf("ExportManifest: %v", err) + } + body, _ := json.Marshal(map[string]any{ + "revision": "sha256:stale", + "paths": []string{manifest.Files[0].Path}, + }) + rec := do(t, router, http.MethodPost, + "/api/v1/workspaces/"+testWorkspaceID+"/config/export/zip", string(body)) + assertStatus(t, rec, http.StatusConflict) +} + func TestHandler_PreviewImport(t *testing.T) { router, env := newTestRouter(t) seedAgent(t, env, testWorkspaceID, "ada") diff --git a/apps/backend/internal/office/config/service.go b/apps/backend/internal/office/config/service.go index e193674976..1df9af70b1 100644 --- a/apps/backend/internal/office/config/service.go +++ b/apps/backend/internal/office/config/service.go @@ -4,8 +4,12 @@ import ( "archive/zip" "bytes" "context" + "crypto/sha256" "fmt" "io" + "path" + "sort" + "strings" "sync" "github.com/kandev/kandev/internal/common/logger" @@ -69,8 +73,8 @@ func (s *ConfigService) ExportBundle(ctx context.Context, workspaceID string) (* return bundle, nil } -func (s *ConfigService) exportAgents(ctx context.Context, _ string, bundle *ConfigBundle) error { - agents, err := s.repo.ListAgentInstances(ctx, "") +func (s *ConfigService) exportAgents(ctx context.Context, workspaceID string, bundle *ConfigBundle) error { + agents, err := s.repo.ListAgentInstances(ctx, workspaceID) if err != nil { return fmt.Errorf("list agents: %w", err) } @@ -95,8 +99,8 @@ func (s *ConfigService) exportAgents(ctx context.Context, _ string, bundle *Conf return nil } -func (s *ConfigService) exportSkills(ctx context.Context, _ string, bundle *ConfigBundle) error { - skills, err := s.repo.ListSkills(ctx, "") +func (s *ConfigService) exportSkills(ctx context.Context, workspaceID string, bundle *ConfigBundle) error { + skills, err := s.repo.ListSkills(ctx, workspaceID) if err != nil { return fmt.Errorf("list skills: %w", err) } @@ -113,12 +117,12 @@ func (s *ConfigService) exportSkills(ctx context.Context, _ string, bundle *Conf return nil } -func (s *ConfigService) exportRoutines(ctx context.Context, _ string, bundle *ConfigBundle) error { - routines, err := s.repo.ListRoutines(ctx, "") +func (s *ConfigService) exportRoutines(ctx context.Context, workspaceID string, bundle *ConfigBundle) error { + routines, err := s.repo.ListRoutines(ctx, workspaceID) if err != nil { return fmt.Errorf("list routines: %w", err) } - agents, err := s.repo.ListAgentInstances(ctx, "") + agents, err := s.repo.ListAgentInstances(ctx, workspaceID) if err != nil { return fmt.Errorf("list agents for routine resolution: %w", err) } @@ -139,12 +143,12 @@ func (s *ConfigService) exportRoutines(ctx context.Context, _ string, bundle *Co return nil } -func (s *ConfigService) exportProjects(ctx context.Context, _ string, bundle *ConfigBundle) error { - projects, err := s.repo.ListProjects(ctx, "") +func (s *ConfigService) exportProjects(ctx context.Context, workspaceID string, bundle *ConfigBundle) error { + projects, err := s.repo.ListProjects(ctx, workspaceID) if err != nil { return fmt.Errorf("list projects: %w", err) } - agents, err := s.repo.ListAgentInstances(ctx, "") + agents, err := s.repo.ListAgentInstances(ctx, workspaceID) if err != nil { return fmt.Errorf("list agents for project resolution: %w", err) } @@ -170,38 +174,156 @@ func (s *ConfigService) exportProjects(ctx context.Context, _ string, bundle *Co // ExportZip exports the workspace configuration as a zip archive. func (s *ConfigService) ExportZip(ctx context.Context, workspaceID string) (io.Reader, error) { + manifest, err := s.ExportManifest(ctx, workspaceID) + if err != nil { + return nil, err + } + return exportFilesToZip(manifest.Files) +} + +// ExportManifest serializes a workspace once so preview and download operate +// on the same bytes rather than independently generated client YAML. +func (s *ConfigService) ExportManifest(ctx context.Context, workspaceID string) (*ExportManifest, error) { bundle, err := s.ExportBundle(ctx, workspaceID) if err != nil { return nil, err } - return bundleToZip(bundle) + files, err := bundleToFiles(bundle) + if err != nil { + return nil, err + } + return &ExportManifest{Revision: exportRevision(files), Files: files}, nil } +// ExportSelectedZip returns only files from the currently advertised +// workspace manifest. The revision check prevents a selection made against an +// older preview from downloading a different configuration. +func (s *ConfigService) ExportSelectedZip( + ctx context.Context, + workspaceID, revision string, + paths []string, +) (io.Reader, error) { + if len(paths) == 0 { + return nil, ErrInvalidExportSelection + } + manifest, err := s.ExportManifest(ctx, workspaceID) + if err != nil { + return nil, err + } + if revision == "" || revision != manifest.Revision { + return nil, ErrExportRevisionConflict + } + selected := make(map[string]struct{}, len(paths)) + for _, path := range paths { + selected[path] = struct{}{} + } + files := make([]ExportFile, 0, len(paths)) + for _, file := range manifest.Files { + if _, ok := selected[file.Path]; ok { + files = append(files, file) + delete(selected, file.Path) + } + } + if len(selected) != 0 { + return nil, ErrInvalidExportSelection + } + return exportFilesToZip(files) +} + +var ( + ErrExportRevisionConflict = fmt.Errorf("configuration export is stale") + ErrInvalidExportSelection = fmt.Errorf("configuration export selection is invalid") +) + // bundleToZip converts a ConfigBundle to a zip archive. func bundleToZip(bundle *ConfigBundle) (io.Reader, error) { - buf := new(bytes.Buffer) - w := zip.NewWriter(buf) + files, err := bundleToFiles(bundle) + if err != nil { + return nil, err + } + return exportFilesToZip(files) +} - if err := writeYAMLFile(w, ".kandev/kandev.yml", bundle.Settings); err != nil { +func bundleToFiles(bundle *ConfigBundle) ([]ExportFile, error) { + files := []ExportFile{} + if err := appendYAMLFile(&files, ".kandev/kandev.yml", bundle.Settings); err != nil { return nil, err } for _, a := range bundle.Agents { - if err := writeYAMLFile(w, ".kandev/agents/"+a.Name+".yml", a); err != nil { + if err := appendYAMLFile(&files, ".kandev/agents/"+a.Name+".yml", a); err != nil { return nil, err } } for _, sk := range bundle.Skills { - if err := writeYAMLFile(w, ".kandev/skills/"+sk.Slug+".yml", sk); err != nil { + if err := appendYAMLFile(&files, ".kandev/skills/"+sk.Slug+".yml", sk); err != nil { return nil, err } } for _, r := range bundle.Routines { - if err := writeYAMLFile(w, ".kandev/routines/"+r.Name+".yml", r); err != nil { + if err := appendYAMLFile(&files, ".kandev/routines/"+r.Name+".yml", r); err != nil { return nil, err } } for _, p := range bundle.Projects { - if err := writeYAMLFile(w, ".kandev/projects/"+p.Name+".yml", p); err != nil { + if err := appendYAMLFile(&files, ".kandev/projects/"+p.Name+".yml", p); err != nil { + return nil, err + } + } + return files, nil +} + +func appendYAMLFile(files *[]ExportFile, name string, data interface{}) error { + if err := validateExportPath(files, name); err != nil { + return err + } + b, err := yaml.Marshal(data) + if err != nil { + return fmt.Errorf("marshal %s: %w", name, err) + } + *files = append(*files, ExportFile{Path: name, Content: string(b)}) + return nil +} + +// validateExportPath keeps entity-derived archive names inside the +// .kandev tree and rejects duplicate entries. ZIP readers commonly normalize +// path separators, so both slash and backslash traversal are rejected before +// an entry is created. +func validateExportPath(files *[]ExportFile, name string) error { + if name == "" || strings.ContainsRune(name, '\x00') || strings.Contains(name, "\\") || + path.IsAbs(name) || !strings.HasPrefix(name, ".kandev/") || path.Clean(name) != name { + return fmt.Errorf("invalid configuration export path %q", name) + } + for _, segment := range strings.Split(name, "/") { + if segment == "" || segment == "." || segment == ".." { + return fmt.Errorf("invalid configuration export path %q", name) + } + } + for _, file := range *files { + if file.Path == name { + return fmt.Errorf("duplicate configuration export path %q", name) + } + } + return nil +} + +func exportRevision(files []ExportFile) string { + ordered := append([]ExportFile(nil), files...) + sort.Slice(ordered, func(i, j int) bool { return ordered[i].Path < ordered[j].Path }) + h := sha256.New() + for _, file := range ordered { + _, _ = h.Write([]byte(file.Path)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(file.Content)) + _, _ = h.Write([]byte{0}) + } + return fmt.Sprintf("sha256:%x", h.Sum(nil)) +} + +func exportFilesToZip(files []ExportFile) (io.Reader, error) { + buf := new(bytes.Buffer) + w := zip.NewWriter(buf) + for _, file := range files { + if err := writeZipFile(w, file.Path, []byte(file.Content)); err != nil { return nil, err } } @@ -211,6 +333,17 @@ func bundleToZip(bundle *ConfigBundle) (io.Reader, error) { return buf, nil } +func writeZipFile(w *zip.Writer, name string, content []byte) error { + f, err := w.Create(name) + if err != nil { + return fmt.Errorf("create zip entry %s: %w", name, err) + } + if _, err := f.Write(content); err != nil { + return fmt.Errorf("write zip entry %s: %w", name, err) + } + return nil +} + func writeYAMLFile(w *zip.Writer, name string, data interface{}) error { f, err := w.Create(name) if err != nil { diff --git a/apps/backend/internal/office/config/service_test.go b/apps/backend/internal/office/config/service_test.go index 4def22b897..b5bf282642 100644 --- a/apps/backend/internal/office/config/service_test.go +++ b/apps/backend/internal/office/config/service_test.go @@ -197,18 +197,7 @@ func TestExportBundle_UnresolvableReferencesBecomeEmpty(t *testing.T) { assertEqual(t, "project lead", bundle.Projects[0].LeadAgentName, "") } -// TestExportBundle_LeaksOtherWorkspaces_KnownDefect records a suspected bug, -// not an endorsed contract. Every export helper calls ListX(ctx, "") and so -// lists across all workspaces; the workspace ID argument only labels the -// settings block. PreviewImport on the same service *is* scoped (see -// TestPreviewImport_ScopesToWorkspace), so the two disagree, and under -// enabled auth a config export or zip for one workspace can carry another -// user's agents, routines, projects and skill content. -// -// This is reported separately rather than fixed here: this package's tests -// were added under a test-only mandate. Scoping the export is the fix, and it -// must delete or invert this test — that failure is the intended signal. -func TestExportBundle_LeaksOtherWorkspaces_KnownDefect(t *testing.T) { +func TestExportBundle_ScopesToWorkspace(t *testing.T) { env := newTestEnv(t) seedAgent(t, env, testWorkspaceID, "ada") seedAgent(t, env, "ws-other", "grace") @@ -223,12 +212,10 @@ func TestExportBundle_LeaksOtherWorkspaces_KnownDefect(t *testing.T) { names := agentBundleNames(bundle.Agents) sort.Strings(names) - assertStrings(t, "agents across workspaces", names, []string{"ada", "grace"}) - assertStrings(t, "skills across workspaces", skillBundleSlugs(bundle.Skills), []string{"triage"}) - assertStrings(t, "routines across workspaces", - routineBundleNames(bundle.Routines), []string{"retro"}) - assertStrings(t, "projects across workspaces", - projectBundleNames(bundle.Projects), []string{"gemini"}) + assertStrings(t, "agents in workspace", names, []string{"ada"}) + assertStrings(t, "skills in workspace", skillBundleSlugs(bundle.Skills), []string{}) + assertStrings(t, "routines in workspace", routineBundleNames(bundle.Routines), []string{}) + assertStrings(t, "projects in workspace", projectBundleNames(bundle.Projects), []string{}) } func TestExportBundle_EmptyDatabase(t *testing.T) { @@ -409,3 +396,78 @@ func TestExportZip_PropagatesExportError(t *testing.T) { t.Error("reader should be nil on error") } } + +func TestExportManifestMatchesZipBytes(t *testing.T) { + env := newTestEnv(t) + seedFullDBState(t, env, testWorkspaceID) + + manifest, err := env.svc.ExportManifest(context.Background(), testWorkspaceID) + if err != nil { + t.Fatalf("ExportManifest: %v", err) + } + archive, err := env.svc.ExportZip(context.Background(), testWorkspaceID) + if err != nil { + t.Fatalf("ExportZip: %v", err) + } + entries := readZip(t, archive) + if len(entries) != len(manifest.Files) { + t.Fatalf("archive entries = %d, manifest files = %d", len(entries), len(manifest.Files)) + } + for _, file := range manifest.Files { + if got := string(entries[file.Path]); got != file.Content { + t.Errorf("%s differs between manifest and archive", file.Path) + } + } +} + +func TestExportSelectedZipRevisionConflict(t *testing.T) { + env := newTestEnv(t) + seedAgent(t, env, testWorkspaceID, "ada") + manifest, err := env.svc.ExportManifest(context.Background(), testWorkspaceID) + if err != nil { + t.Fatalf("ExportManifest: %v", err) + } + if _, err := env.svc.ExportSelectedZip(context.Background(), testWorkspaceID, "sha256:stale", []string{manifest.Files[0].Path}); err != ErrExportRevisionConflict { + t.Fatalf("error = %v, want ErrExportRevisionConflict", err) + } +} + +func TestExportSelectedZipRejectsInvalidPaths(t *testing.T) { + env := newTestEnv(t) + seedAgent(t, env, testWorkspaceID, "ada") + manifest, err := env.svc.ExportManifest(context.Background(), testWorkspaceID) + if err != nil { + t.Fatalf("ExportManifest: %v", err) + } + if _, err := env.svc.ExportSelectedZip(context.Background(), testWorkspaceID, manifest.Revision, []string{".kandev/secret.yml"}); err != ErrInvalidExportSelection { + t.Fatalf("error = %v, want ErrInvalidExportSelection", err) + } +} + +func TestExportSelectedZipRejectsEmptySelection(t *testing.T) { + env := newTestEnv(t) + if _, err := env.svc.ExportSelectedZip(context.Background(), testWorkspaceID, "sha256:any", nil); err != ErrInvalidExportSelection { + t.Fatalf("error = %v, want ErrInvalidExportSelection", err) + } +} + +func TestBundleToFilesRejectsUnsafeAndDuplicatePaths(t *testing.T) { + unsafe := []string{"../escape", "/tmp/escape"} + for _, name := range unsafe { + t.Run(name, func(t *testing.T) { + _, err := bundleToFiles(&ConfigBundle{ + Agents: []AgentConfig{{Name: name}}, + }) + if err == nil { + t.Fatalf("bundleToFiles(%q) returned nil error", name) + } + }) + } + + _, err := bundleToFiles(&ConfigBundle{ + Agents: []AgentConfig{{Name: "same"}, {Name: "same"}}, + }) + if err == nil { + t.Fatal("bundleToFiles accepted duplicate archive paths") + } +} diff --git a/apps/backend/internal/office/dashboard/activity_labels.go b/apps/backend/internal/office/dashboard/activity_labels.go new file mode 100644 index 0000000000..d8df0bc1a4 --- /dev/null +++ b/apps/backend/internal/office/dashboard/activity_labels.go @@ -0,0 +1,98 @@ +package dashboard + +import ( + "context" + + "github.com/kandev/kandev/internal/office/models" +) + +// enrichActivityLabels resolves all names in bounded workspace-scoped reads. +// Activity rows are intentionally not joined one-by-one: the feed is a hot +// read path and historical identifiers must remain useful when a target was +// later removed. +func (s *DashboardService) enrichActivityLabels( + ctx context.Context, + workspaceID string, + entries []*models.ActivityEntry, + agents []*models.AgentInstance, +) { + if len(entries) == 0 { + return + } + agentNames := s.activityAgentNames(ctx, workspaceID, agents) + tasksByID := s.activityTaskLabels(ctx, workspaceID, entries) + s.applyActivityLabels(entries, agentNames, tasksByID) +} + +func (s *DashboardService) activityAgentNames( + ctx context.Context, + workspaceID string, + agents []*models.AgentInstance, +) map[string]string { + if agents == nil && s.agents != nil { + if listed, err := s.agents.ListAgentInstances(ctx, workspaceID); err == nil { + agents = listed + } + } + agentNames := make(map[string]string, len(agents)) + for _, agent := range agents { + if agent != nil && agent.ID != "" { + agentNames[agent.ID] = agent.Name + } + } + return agentNames +} + +func (s *DashboardService) activityTaskLabels( + ctx context.Context, + workspaceID string, + entries []*models.ActivityEntry, +) map[string]taskLabel { + for _, entry := range entries { + if entry != nil && entry.TargetType == models.ActivityTargetType("task") && entry.TargetID != "" { + return s.loadActivityTaskLabels(ctx, workspaceID) + } + } + return map[string]taskLabel{} +} + +func (s *DashboardService) loadActivityTaskLabels( + ctx context.Context, + workspaceID string, +) map[string]taskLabel { + tasksByID := map[string]taskLabel{} + tasks, err := s.repo.ListTasksByWorkspace(ctx, workspaceID, true) + if err != nil { + return tasksByID + } + for _, task := range tasks { + if task != nil { + tasksByID[task.ID] = taskLabel{Name: task.Title, Identifier: task.Identifier} + } + } + return tasksByID +} + +func (s *DashboardService) applyActivityLabels( + entries []*models.ActivityEntry, + agentNames map[string]string, + tasksByID map[string]taskLabel, +) { + for _, entry := range entries { + if entry == nil { + continue + } + if entry.ActorType == models.ActivityActorType("agent") { + entry.ActorName = agentNames[entry.ActorID] + } + if label, ok := tasksByID[entry.TargetID]; ok { + entry.TargetName = label.Name + entry.TargetIdentifier = label.Identifier + } + } +} + +type taskLabel struct { + Name string + Identifier string +} diff --git a/apps/backend/internal/office/dashboard/activity_labels_test.go b/apps/backend/internal/office/dashboard/activity_labels_test.go new file mode 100644 index 0000000000..c82eba6bfb --- /dev/null +++ b/apps/backend/internal/office/dashboard/activity_labels_test.go @@ -0,0 +1,59 @@ +package dashboard_test + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/office/models" +) + +func TestActivityLabelsWorkspaceScoped(t *testing.T) { + deps := newTestDeps(t) + ctx := context.Background() + + deps.agents.instances = []*models.AgentInstance{ + {ID: "agent-ws-1", WorkspaceID: "ws-1", Name: "Workspace One Agent"}, + {ID: "agent-ws-2", WorkspaceID: "ws-2", Name: "Workspace Two Agent"}, + } + insertTestTask(t, deps.db, "task-ws-1", "ws-1", "Workspace One Task", "todo", 2) + insertTestTask(t, deps.db, "task-ws-2", "ws-2", "Workspace Two Task", "todo", 2) + + createdAt := time.Now().UTC() + if err := deps.repo.CreateActivityEntry(ctx, &models.ActivityEntry{ + WorkspaceID: "ws-1", + ActorType: models.ActivityActorType("agent"), + ActorID: "agent-ws-1", + Action: models.ActivityAction("task.updated"), + TargetType: models.ActivityTargetType("task"), + TargetID: "task-ws-1", + CreatedAt: createdAt, + }); err != nil { + t.Fatalf("create workspace-one activity: %v", err) + } + if err := deps.repo.CreateActivityEntry(ctx, &models.ActivityEntry{ + WorkspaceID: "ws-1", + ActorType: models.ActivityActorType("agent"), + ActorID: "agent-ws-2", + Action: models.ActivityAction("task.updated"), + TargetType: models.ActivityTargetType("task"), + TargetID: "task-ws-2", + CreatedAt: createdAt.Add(-time.Second), + }); err != nil { + t.Fatalf("create cross-workspace activity: %v", err) + } + + entries, err := deps.svc.ListActivityFiltered(ctx, "ws-1", "all", 10) + if err != nil { + t.Fatalf("list activity: %v", err) + } + if len(entries) != 2 { + t.Fatalf("activity entry count = %d, want 2", len(entries)) + } + if entries[0].ActorName != "Workspace One Agent" || entries[0].TargetName != "Workspace One Task" { + t.Fatalf("workspace-one labels = actor %q target %q, want scoped names", entries[0].ActorName, entries[0].TargetName) + } + if entries[1].ActorName != "" || entries[1].TargetName != "" { + t.Fatalf("cross-workspace labels = actor %q target %q, want empty", entries[1].ActorName, entries[1].TargetName) + } +} diff --git a/apps/backend/internal/office/dashboard/dto.go b/apps/backend/internal/office/dashboard/dto.go index b8932a6f29..1534ba4f5c 100644 --- a/apps/backend/internal/office/dashboard/dto.go +++ b/apps/backend/internal/office/dashboard/dto.go @@ -277,6 +277,7 @@ type RunDetailResponse struct { ID string `json:"id"` IDShort string `json:"id_short"` AgentID string `json:"agent_id"` + AgentName string `json:"agent_name,omitempty"` Reason string `json:"reason"` Status string `json:"status"` CancelReason *string `json:"cancel_reason,omitempty"` @@ -329,6 +330,9 @@ type RunRuntimeDTO struct { type RunSkillDTO struct { SkillID string `json:"skill_id"` + DisplayName string `json:"display_name,omitempty"` + Slug string `json:"slug,omitempty"` + LabelSource string `json:"label_source,omitempty"` Version string `json:"version"` ContentHash string `json:"content_hash"` MaterializedPath string `json:"materialized_path"` diff --git a/apps/backend/internal/office/dashboard/handler_test.go b/apps/backend/internal/office/dashboard/handler_test.go index 33549ff069..4d39956b2e 100644 --- a/apps/backend/internal/office/dashboard/handler_test.go +++ b/apps/backend/internal/office/dashboard/handler_test.go @@ -254,7 +254,10 @@ func newTestDepsWithLogger(t *testing.T, log *logger.Logger) *testDeps { // stubAgentReader returns nil/nil by default; tests that need agent // resolution (e.g. pending_approvers name lookup) populate `names`. -type stubAgentReader struct{ names map[string]string } +type stubAgentReader struct { + names map[string]string + instances []*models.AgentInstance +} func (s *stubAgentReader) GetAgentInstance(_ context.Context, id string) (*models.AgentInstance, error) { if name, ok := s.names[id]; ok { @@ -263,8 +266,14 @@ func (s *stubAgentReader) GetAgentInstance(_ context.Context, id string) (*model return nil, nil } -func (s *stubAgentReader) ListAgentInstances(_ context.Context, _ string) ([]*models.AgentInstance, error) { - return nil, nil +func (s *stubAgentReader) ListAgentInstances(_ context.Context, workspaceID string) ([]*models.AgentInstance, error) { + var out []*models.AgentInstance + for _, instance := range s.instances { + if instance != nil && instance.WorkspaceID == workspaceID { + out = append(out, instance) + } + } + return out, nil } func (s *stubAgentReader) ListAgentInstancesByIDs(_ context.Context, ids []string) ([]*models.AgentInstance, error) { diff --git a/apps/backend/internal/office/dashboard/run_detail.go b/apps/backend/internal/office/dashboard/run_detail.go index 10e97b704b..917100b039 100644 --- a/apps/backend/internal/office/dashboard/run_detail.go +++ b/apps/backend/internal/office/dashboard/run_detail.go @@ -29,6 +29,14 @@ type RunDetailRepo interface { ListRouteAttempts(ctx context.Context, runID string) ([]models.RouteAttempt, error) } +type runSessionReader interface { + GetRunSession(ctx context.Context, id string) (*models.RunSession, error) +} + +type runSessionHistoryReader interface { + ListRunSessions(ctx context.Context, runID string) ([]models.RunSession, error) +} + // ErrRunNotFound is returned when GetRunDetail can't find the run id. var ErrRunNotFound = errors.New("run not found") @@ -251,8 +259,9 @@ func GetRunDetail( } } - invocation := buildInvocation(ctx, repo, run) - sessionDTO := RunSessionDTO{SessionID: sessionIDFromPayload(run.Payload)} + invocation, agentName := buildInvocation(ctx, repo, run) + _, sessionID := currentRunSession(ctx, repo, run) + sessionDTO := RunSessionDTO{SessionID: sessionID} runtimeDTO, err := buildRuntimeDTO(ctx, repo, run) if err != nil { return nil, err @@ -262,6 +271,7 @@ func GetRunDetail( ID: run.ID, IDShort: shortID(run.ID), AgentID: run.AgentProfileID, + AgentName: agentName, Reason: run.Reason, Status: string(run.Status), ErrorMessage: run.ErrorMessage, @@ -382,6 +392,9 @@ func buildRuntimeDTO(ctx context.Context, repo RunDetailRepo, run *models.Run) ( for _, snap := range snapshots { skills = append(skills, RunSkillDTO{ SkillID: snap.SkillID, + DisplayName: snap.DisplayName, + Slug: snap.Slug, + LabelSource: snap.LabelSource, Version: snap.Version, ContentHash: snap.ContentHash, MaterializedPath: snap.MaterializedPath, @@ -436,27 +449,60 @@ func mergeTaskIDs(touched []string, primary string) []string { return out } -// buildInvocation populates the invocation panel best-effort from -// the agent instance + run payload. The agent profile carries the -// adapter family and model; the working directory is workspace- -// relative for now (orchestrator logs will fill in the rest in a -// later wave). Missing fields stay empty so the frontend can hide -// them gracefully. +// buildInvocation uses the persisted launch snapshot. It never infers an +// adapter from the database row id: provider-routing and run-owned sessions +// record the concrete invocation that actually launched. func buildInvocation( ctx context.Context, repo RunDetailRepo, run *models.Run, -) RunInvocationDTO { +) (RunInvocationDTO, string) { dto := RunInvocationDTO{} + if run.ResolvedProviderID != nil { + dto.Adapter = *run.ResolvedProviderID + } + if run.ResolvedModel != nil { + dto.Model = *run.ResolvedModel + } + if session, _ := currentRunSession(ctx, repo, run); session != nil { + if session.Adapter != "" { + dto.Adapter = session.Adapter + } + if session.Model != "" { + dto.Model = session.Model + } + } agent, err := repo.GetAgentInstance(ctx, run.AgentProfileID) if err != nil || agent == nil { - return dto + return dto, "" } - // Wave G: AgentInstance.ID == agent_profiles.id under the unified model. - dto.Adapter = agent.ID - // Model lives on the agent profile, not the agent instance — - // surfacing it requires plumbing the profile reader through. - // Wave 2.E will fold the adapter+model lookup in; for v1 we - // surface what we have. - return dto + return dto, agent.Name +} + +// currentRunSession prefers the newest durable attempt. A retry reuses the +// logical run ID but receives a new run-session row, so the run's first +// session_id projection must not overwrite the newer invocation snapshot. +func currentRunSession( + ctx context.Context, + repo RunDetailRepo, + run *models.Run, +) (*models.RunSession, string) { + fallbackID := run.SessionID + if fallbackID == "" { + fallbackID = sessionIDFromPayload(run.Payload) + } + if reader, ok := repo.(runSessionHistoryReader); ok { + if sessions, err := reader.ListRunSessions(ctx, run.ID); err == nil && len(sessions) > 0 { + latest := sessions[len(sessions)-1] + if latest.ID != "" { + return &latest, latest.ID + } + } + } + if reader, ok := repo.(runSessionReader); ok && fallbackID != "" { + if session, err := reader.GetRunSession(ctx, fallbackID); err == nil && session != nil { + return session, session.ID + } + } + return nil, fallbackID } diff --git a/apps/backend/internal/office/dashboard/run_detail_test.go b/apps/backend/internal/office/dashboard/run_detail_test.go index 459504241e..fa6557ce8f 100644 --- a/apps/backend/internal/office/dashboard/run_detail_test.go +++ b/apps/backend/internal/office/dashboard/run_detail_test.go @@ -219,6 +219,49 @@ func TestListAgentRunsPaged_UsesSourceTaskWithoutCommentID(t *testing.T) { } } +func TestGetRunDetailUsesNewestRunSessionInvocation(t *testing.T) { + deps := newRunDetailDeps(t) + ctx := context.Background() + seedRunDetailAgent(t, deps, "agent-retry", "agent-retry") + runID := seedRunDetailRun(t, deps, "agent-retry", "finished", "task-retry", time.Now().UTC()) + if _, err := deps.db.Exec(` + UPDATE runs + SET session_id = ?, resolved_provider_id = ?, resolved_model = ? + WHERE id = ? + `, "session-old", "run-provider", "run-model", runID); err != nil { + t.Fatalf("update run snapshot: %v", err) + } + for _, session := range []struct { + id string + attempt int + adapter string + model string + }{ + {"session-old", 1, "old-provider", "old-model"}, + {"session-new", 2, "new-provider", "new-model"}, + } { + if _, err := deps.db.Exec(` + INSERT INTO office_run_sessions ( + id, workspace_id, agent_profile_id, run_id, attempt, state, + adapter, model, created_at + ) VALUES (?, 'ws-1', 'agent-retry', ?, ?, 'finished', ?, ?, ?) + `, session.id, runID, session.attempt, session.adapter, session.model, time.Now().UTC()); err != nil { + t.Fatalf("insert session %s: %v", session.id, err) + } + } + + detail, err := dashboard.GetRunDetail(ctx, deps.repo, "agent-retry", runID) + if err != nil { + t.Fatalf("GetRunDetail: %v", err) + } + if detail.Session.SessionID != "session-new" { + t.Fatalf("session id = %q, want session-new", detail.Session.SessionID) + } + if detail.Invocation.Adapter != "new-provider" || detail.Invocation.Model != "new-model" { + t.Fatalf("invocation = %+v, want newest attempt", detail.Invocation) + } +} + func TestListAgentRunsPaged_TieBreakOnID(t *testing.T) { deps := newRunDetailDeps(t) // Two runs with identical requested_at — must still be strictly @@ -289,12 +332,29 @@ func TestGetRunDetail_HappyPathWithCosts(t *testing.T) { if len(resp.TasksTouched) != 1 || resp.TasksTouched[0] != "task-1" { t.Errorf("want primary task in tasks_touched, got %v", resp.TasksTouched) } - // After ADR 0005 the agent profile id IS the agent instance id, so - // the adapter slug surfaced in the invocation panel matches the agent - // row id directly. The legacy "office instance → shallow profile" - // indirection is gone. - if resp.Invocation.Adapter != "agent-1" { - t.Errorf("adapter mismatch: %q", resp.Invocation.Adapter) + if resp.Invocation.Adapter != "" { + t.Errorf("unrecorded adapter should stay empty, got %q", resp.Invocation.Adapter) + } + if resp.AgentName != "Agent" { + t.Errorf("agent name = %q, want Agent", resp.AgentName) + } +} + +func TestGetRunDetailActualInvocation(t *testing.T) { + deps := newRunDetailDeps(t) + seedRunDetailAgent(t, deps, "agent-1", "ignored") + ctx := context.Background() + runID := seedRunDetailRun(t, deps, "agent-1", "finished", "task-1", time.Now().UTC()) + if err := deps.repo.SetRunResolvedRoute(ctx, runID, "profile-1", "codex", "gpt-5"); err != nil { + t.Fatalf("set resolved route: %v", err) + } + + resp, err := dashboard.GetRunDetail(ctx, deps.repo, "agent-1", runID) + if err != nil { + t.Fatalf("detail: %v", err) + } + if resp.Invocation.Adapter != "codex" || resp.Invocation.Model != "gpt-5" { + t.Fatalf("invocation = %+v, want recorded provider/model", resp.Invocation) } } diff --git a/apps/backend/internal/office/dashboard/service.go b/apps/backend/internal/office/dashboard/service.go index b32621113b..341b269233 100644 --- a/apps/backend/internal/office/dashboard/service.go +++ b/apps/backend/internal/office/dashboard/service.go @@ -753,10 +753,20 @@ func (s *DashboardService) SetRoutineLister(rl RoutineLister) { // ListActivityFiltered returns activity entries filtered by optional type. func (s *DashboardService) ListActivityFiltered(ctx context.Context, wsID, filterType string, limit int) ([]*models.ActivityEntry, error) { + var ( + entries []*models.ActivityEntry + err error + ) if filterType == "" || filterType == "all" { - return s.repo.ListActivityEntries(ctx, wsID, limit) + entries, err = s.repo.ListActivityEntries(ctx, wsID, limit) + } else { + entries, err = s.repo.ListActivityEntriesByType(ctx, wsID, filterType, limit) } - return s.repo.ListActivityEntriesByType(ctx, wsID, filterType, limit) + if err != nil { + return nil, err + } + s.enrichActivityLabels(ctx, wsID, entries, nil) + return entries, nil } // ListActivityForTarget returns activity entries scoped to one target entity. diff --git a/apps/backend/internal/office/dashboard/service_agents.go b/apps/backend/internal/office/dashboard/service_agents.go index 69ecdec4df..4ffe2dddb0 100644 --- a/apps/backend/internal/office/dashboard/service_agents.go +++ b/apps/backend/internal/office/dashboard/service_agents.go @@ -590,6 +590,7 @@ func (s *DashboardService) GetDashboardData(ctx context.Context, wsID string) (* if err := g.Wait(); err != nil { return nil, err } + s.enrichActivityLabels(ctx, wsID, b.activity, b.agents) agentCounts := countAgentsByStatus(b.agents) return &models.DashboardData{ diff --git a/apps/backend/internal/office/models/models.go b/apps/backend/internal/office/models/models.go index 64df520228..faaad5ca6d 100644 --- a/apps/backend/internal/office/models/models.go +++ b/apps/backend/internal/office/models/models.go @@ -95,11 +95,50 @@ type Skill struct { type RunSkillSnapshot struct { RunID string `json:"run_id" db:"run_id"` SkillID string `json:"skill_id" db:"skill_id"` + DisplayName string `json:"display_name" db:"display_name"` + Slug string `json:"slug" db:"slug"` + LabelSource string `json:"label_source" db:"label_source"` Version string `json:"version" db:"version"` ContentHash string `json:"content_hash" db:"content_hash"` MaterializedPath string `json:"materialized_path" db:"materialized_path"` } +// RunSessionState is the durable lifecycle state of an Office execution that +// is owned by a run rather than by a task session. +type RunSessionState string + +const ( + RunSessionStatePreparing RunSessionState = "preparing" + RunSessionStateRunning RunSessionState = "running" + RunSessionStateFinished RunSessionState = "finished" + RunSessionStateFailed RunSessionState = "failed" + RunSessionStateCancelled RunSessionState = "cancelled" + RunSessionStateInterrupted RunSessionState = "interrupted" +) + +// RunSession records one immutable attempt to execute an Office run. A retry +// receives a new ID and attempt number, so delayed predecessor events cannot +// be accepted as updates to the successor. +type RunSession struct { + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` + RunID string `json:"run_id" db:"run_id"` + Attempt int `json:"attempt" db:"attempt"` + State RunSessionState `json:"state" db:"state"` + ExecutionID string `json:"execution_id" db:"execution_id"` + ExecutionProfileID string `json:"execution_profile_id" db:"execution_profile_id"` + Adapter string `json:"adapter" db:"adapter"` + Model string `json:"model" db:"model"` + ACPSessionID string `json:"acp_session_id" db:"acp_session_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty" db:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"` + CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty" db:"cancel_requested_at"` + ErrorMessage string `json:"error_message,omitempty" db:"error_message"` + Version int64 `json:"version" db:"version"` +} + // ProjectStatus represents the status of a project. type ProjectStatus string @@ -668,14 +707,17 @@ type Approval struct { // ActivityEntry represents an entry in the activity log. type ActivityEntry struct { - ID string `json:"id" db:"id"` - WorkspaceID string `json:"workspace_id" db:"workspace_id"` - ActorType ActivityActorType `json:"actor_type" db:"actor_type"` - ActorID string `json:"actor_id" db:"actor_id"` - Action ActivityAction `json:"action" db:"action"` - TargetType ActivityTargetType `json:"target_type" db:"target_type"` - TargetID string `json:"target_id" db:"target_id"` - Details string `json:"details" db:"details"` + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + ActorType ActivityActorType `json:"actor_type" db:"actor_type"` + ActorID string `json:"actor_id" db:"actor_id"` + Action ActivityAction `json:"action" db:"action"` + TargetType ActivityTargetType `json:"target_type" db:"target_type"` + TargetID string `json:"target_id" db:"target_id"` + ActorName string `json:"actor_name,omitempty" db:"-"` + TargetName string `json:"target_name,omitempty" db:"-"` + TargetIdentifier string `json:"target_identifier,omitempty" db:"-"` + Details string `json:"details" db:"details"` // RunID + SessionID let the run detail page join activity rows // back to the originating run for the "Tasks Touched" surface. // Empty string for activity not produced under a run (manual diff --git a/apps/backend/internal/office/pause/service.go b/apps/backend/internal/office/pause/service.go index 7e2f1688ab..c4d79fd8aa 100644 --- a/apps/backend/internal/office/pause/service.go +++ b/apps/backend/internal/office/pause/service.go @@ -64,6 +64,9 @@ type Repository interface { ListInflightRunsForWorkspace(ctx context.Context, workspaceID string) ([]models.InflightRun, error) ListLiveOfficeTaskIDsForWorkspace(ctx context.Context, workspaceID string) ([]string, error) ListLiveRoutineTaskIDsForWorkspace(ctx context.Context, workspaceID string) ([]string, error) + ListLiveRunSessionsForWorkspace(ctx context.Context, workspaceID string) ([]models.RunSession, error) + RequestRunSessionCancellation(ctx context.Context, sessionID string) (bool, error) + FinishRunSession(ctx context.Context, sessionID string, state models.RunSessionState, errorMessage string) (bool, error) CancelRunsForWorkspace(ctx context.Context, runIDs []string, reason string) (int64, error) ReleaseCheckoutsForWorkspace(ctx context.Context, runIDs []string) error } @@ -75,6 +78,13 @@ type TaskCanceller interface { CancelTaskExecution(ctx context.Context, taskID string, reason string, force bool) error } +// RunExecutionStopper stops a run-owned shared-runtime execution by its +// exact execution identity. It is optional in fixtures and older startup +// compositions, but production pause wiring supplies it. +type RunExecutionStopper interface { + Stop(ctx context.Context, executionID string, reason string) error +} + // WorkspaceChecker resolves a workspace by id, used only for the // existence check every pause/resume/read performs (AC-006.9). No Office // table carries a foreign key to workspaces(id), so this check-then-act is @@ -89,6 +99,7 @@ type WorkspaceChecker interface { type Service struct { repo Repository canceller TaskCanceller + runStopper RunExecutionStopper workspaces WorkspaceChecker logger *logger.Logger } @@ -98,6 +109,12 @@ func NewService(repo Repository, canceller TaskCanceller, workspaces WorkspaceCh return &Service{repo: repo, canceller: canceller, workspaces: workspaces, logger: log} } +// SetRunExecutionStopper wires the shared runtime stop seam used by the +// workspace halt sweep for taskless Office sessions. +func (s *Service) SetRunExecutionStopper(stopper RunExecutionStopper) { + s.runStopper = stopper +} + // PauseState is the exported gate predicate every launch site consults. // It satisfies office/shared.PauseGate structurally. Returns (nil, nil) // when the workspace is running, the record when paused, and a non-nil diff --git a/apps/backend/internal/office/pause/service_test.go b/apps/backend/internal/office/pause/service_test.go index c6404a5bd5..35fd313cb4 100644 --- a/apps/backend/internal/office/pause/service_test.go +++ b/apps/backend/internal/office/pause/service_test.go @@ -6,6 +6,7 @@ import ( "errors" "testing" + runtimeapi "github.com/kandev/kandev/internal/agent/runtime" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/office/models" "github.com/kandev/kandev/internal/office/pause" @@ -31,16 +32,21 @@ type fakeRepo struct { activityEntries []*models.ActivityEntry activityErr error - inflightRuns []models.InflightRun - inflightErr error - inflightSequences [][]models.InflightRun - liveOfficeIDs []string - liveOfficeErr error - liveRoutineIDs []string - liveRoutineErr error - cancelRunsCount int64 - cancelRunsErr error - releaseCkoutErr error + inflightRuns []models.InflightRun + inflightErr error + inflightSequences [][]models.InflightRun + liveOfficeIDs []string + liveOfficeErr error + liveRoutineIDs []string + liveRoutineErr error + liveRunSessions []models.RunSession + liveRunSessionErr error + runSessionCancelErr error + finishRunSessionErr error + finishedRunSessions map[string]models.RunSessionState + cancelRunsCount int64 + cancelRunsErr error + releaseCkoutErr error } // GetActiveWorkspacePause pops the next scripted (record, error) pair off @@ -94,6 +100,22 @@ func (f *fakeRepo) ListLiveRoutineTaskIDsForWorkspace(context.Context, string) ( return f.liveRoutineIDs, f.liveRoutineErr } +func (f *fakeRepo) ListLiveRunSessionsForWorkspace(context.Context, string) ([]models.RunSession, error) { + return f.liveRunSessions, f.liveRunSessionErr +} + +func (f *fakeRepo) RequestRunSessionCancellation(context.Context, string) (bool, error) { + return f.runSessionCancelErr == nil, f.runSessionCancelErr +} + +func (f *fakeRepo) FinishRunSession(_ context.Context, id string, state models.RunSessionState, _ string) (bool, error) { + if f.finishedRunSessions == nil { + f.finishedRunSessions = make(map[string]models.RunSessionState) + } + f.finishedRunSessions[id] = state + return f.finishRunSessionErr == nil, f.finishRunSessionErr +} + func (f *fakeRepo) CancelRunsForWorkspace(context.Context, []string, string) (int64, error) { return f.cancelRunsCount, f.cancelRunsErr } @@ -166,6 +188,60 @@ type fakeCanceller struct { calls []string } +type fakeRunStopper struct { + results map[string]error + calls []string +} + +func (f *fakeRunStopper) Stop(_ context.Context, executionID, _ string) error { + f.calls = append(f.calls, executionID) + return f.results[executionID] +} + +func TestPauseStopsMixedRunSessionInventory(t *testing.T) { + repo := &fakeRepo{ + liveRunSessions: []models.RunSession{ + {ID: "run-session-live", ExecutionID: "execution-live", State: models.RunSessionStateRunning}, + {ID: "run-session-failed", ExecutionID: "execution-failed", State: models.RunSessionStateRunning}, + }, + } + stopper := &fakeRunStopper{results: map[string]error{"execution-failed": errors.New("stop failed")}} + svc := pause.NewService(repo, &fakeCanceller{}, &fakeWorkspaces{known: map[string]bool{"ws-1": true}}, logger.Default()) + svc.SetRunExecutionStopper(stopper) + result, err := svc.Pause(context.Background(), "ws-1", "incident", "user-1", "user") + if err != nil { + t.Fatalf("Pause: %v", err) + } + if result.Sweep.ExecutionsCancelled != 1 || result.Sweep.Failures != 1 { + t.Fatalf("sweep = %+v, want one stopped and one failure", result.Sweep) + } + if len(stopper.calls) != 2 { + t.Fatalf("stop calls = %v, want both run executions", stopper.calls) + } +} + +func TestPauseMarksMissingRuntimeRunSessionInterrupted(t *testing.T) { + repo := &fakeRepo{ + liveRunSessions: []models.RunSession{{ + ID: "run-session-missing", ExecutionID: "execution-missing", State: models.RunSessionStateRunning, + }}, + } + stopper := &fakeRunStopper{results: map[string]error{"execution-missing": runtimeapi.ErrNotFound}} + svc := pause.NewService(repo, &fakeCanceller{}, &fakeWorkspaces{known: map[string]bool{"ws-1": true}}, logger.Default()) + svc.SetRunExecutionStopper(stopper) + + result, err := svc.Pause(context.Background(), "ws-1", "incident", "user-1", "user") + if err != nil { + t.Fatalf("Pause: %v", err) + } + if result.Sweep.ExecutionsNotRunning != 1 || result.Sweep.Failures != 0 { + t.Fatalf("sweep = %+v, want one missing runtime and no failure", result.Sweep) + } + if got := repo.finishedRunSessions["run-session-missing"]; got != models.RunSessionStateInterrupted { + t.Fatalf("run session state = %q, want interrupted", got) + } +} + func (f *fakeCanceller) CancelTaskExecution(_ context.Context, taskID string, _ string, _ bool) error { f.calls = append(f.calls, taskID) if sequence := f.resultSequences[taskID]; len(sequence) > 0 { diff --git a/apps/backend/internal/office/pause/sweep.go b/apps/backend/internal/office/pause/sweep.go index c3bf915a76..cfe9478abe 100644 --- a/apps/backend/internal/office/pause/sweep.go +++ b/apps/backend/internal/office/pause/sweep.go @@ -6,6 +6,8 @@ import ( "go.uber.org/zap" + runtimeapi "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/office/models" orchestratorexecutor "github.com/kandev/kandev/internal/orchestrator/executor" ) @@ -34,6 +36,7 @@ type SweepResult struct { // into the single `failures` count, not just sub-step (d)'s. func (s *Service) runHaltSweep(ctx context.Context, workspaceID string) SweepResult { var result SweepResult + s.cancelRunSessions(ctx, workspaceID, &result) runs, err := s.repo.ListInflightRunsForWorkspace(ctx, workspaceID) if err != nil { @@ -96,6 +99,45 @@ func (s *Service) runHaltSweep(ctx context.Context, workspaceID string) SweepRes return result } +func (s *Service) cancelRunSessions(ctx context.Context, workspaceID string, result *SweepResult) { + sessions, err := s.repo.ListLiveRunSessionsForWorkspace(ctx, workspaceID) + if err != nil { + s.logger.Warn("halt sweep: list live run sessions failed", zap.String("workspace_id", workspaceID), zap.Error(err)) + result.Failures++ + return + } + for _, session := range sessions { + if _, err := s.repo.RequestRunSessionCancellation(ctx, session.ID); err != nil { + result.Failures++ + s.logger.Warn("halt sweep: mark run session cancelled failed", zap.String("run_session_id", session.ID), zap.Error(err)) + continue + } + if s.runStopper == nil { + result.Failures++ + s.logger.Warn("halt sweep: run execution stopper unavailable", zap.String("run_session_id", session.ID)) + continue + } + err := s.runStopper.Stop(ctx, session.ExecutionID, haltSweepCancelReason) + switch { + case err == nil: + result.ExecutionsCancelled++ + if _, finishErr := s.repo.FinishRunSession(ctx, session.ID, models.RunSessionStateCancelled, haltSweepCancelReason); finishErr != nil { + result.Failures++ + s.logger.Warn("halt sweep: finish run session cancellation failed", zap.String("run_session_id", session.ID), zap.Error(finishErr)) + } + case errors.Is(err, orchestratorexecutor.ErrExecutionNotFound), errors.Is(err, runtimeapi.ErrNotFound): + result.ExecutionsNotRunning++ + if _, finishErr := s.repo.FinishRunSession(ctx, session.ID, models.RunSessionStateInterrupted, "runtime execution not found"); finishErr != nil { + result.Failures++ + s.logger.Warn("halt sweep: mark missing run session interrupted failed", zap.String("run_session_id", session.ID), zap.Error(finishErr)) + } + default: + result.Failures++ + s.logger.Warn("halt sweep: stop run execution failed", zap.String("run_session_id", session.ID), zap.Error(err)) + } + } +} + // cancelTaskExecutions requests cancellation for the deduplicated task id // union, partitioning the outcome into three counts: an actual stop, a // task with nothing to stop (ErrExecutionNotFound — expected for an idle diff --git a/apps/backend/internal/office/repository/sqlite/base.go b/apps/backend/internal/office/repository/sqlite/base.go index 124709caad..f36e35ee0d 100644 --- a/apps/backend/internal/office/repository/sqlite/base.go +++ b/apps/backend/internal/office/repository/sqlite/base.go @@ -476,11 +476,37 @@ func (r *Repository) createRunTables() error { CREATE TABLE IF NOT EXISTS office_run_skills ( run_id TEXT NOT NULL, skill_id TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL DEFAULT '', + label_source TEXT NOT NULL DEFAULT '', version TEXT NOT NULL, content_hash TEXT NOT NULL, materialized_path TEXT NOT NULL, PRIMARY KEY (run_id, skill_id) ); + + CREATE TABLE IF NOT EXISTS office_run_sessions ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + agent_profile_id TEXT NOT NULL, + run_id TEXT NOT NULL, + attempt INTEGER NOT NULL, + state TEXT NOT NULL, + execution_id TEXT NOT NULL DEFAULT '', + execution_profile_id TEXT NOT NULL DEFAULT '', + adapter TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + acp_session_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + started_at TIMESTAMP, + finished_at TIMESTAMP, + cancel_requested_at TIMESTAMP, + error_message TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL DEFAULT 1, + UNIQUE (run_id, attempt) + ); + CREATE INDEX IF NOT EXISTS idx_office_run_sessions_run ON office_run_sessions(run_id, attempt); + CREATE INDEX IF NOT EXISTS idx_office_run_sessions_live ON office_run_sessions(workspace_id, state); `) if err != nil { return err diff --git a/apps/backend/internal/office/repository/sqlite/base_migrations.go b/apps/backend/internal/office/repository/sqlite/base_migrations.go index 4d92465b6e..beea1f4d3d 100644 --- a/apps/backend/internal/office/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/office/repository/sqlite/base_migrations.go @@ -56,6 +56,9 @@ func (r *Repository) runMigrations() error { return err } r.migrateRunOutcome() + if err := r.migrateRunSkillLabels(); err != nil { + return err + } if err := r.migrateParentWakeIndexes(); err != nil { return err } @@ -79,6 +82,29 @@ func (r *Repository) runMigrations() error { return nil } +// migrateRunSkillLabels adds the captured labels used by run history. The +// snapshot keeps its display identity even if the live skill is renamed or +// deleted later. + +func (r *Repository) migrateRunSkillLabels() error { + if err := r.migrate.Apply( + "office_run_skills.display_name", + `ALTER TABLE office_run_skills ADD COLUMN display_name TEXT NOT NULL DEFAULT ''`, + ); err != nil { + return err + } + if err := r.migrate.Apply( + "office_run_skills.slug", + `ALTER TABLE office_run_skills ADD COLUMN slug TEXT NOT NULL DEFAULT ''`, + ); err != nil { + return err + } + return r.migrate.Apply( + "office_run_skills.label_source", + `ALTER TABLE office_run_skills ADD COLUMN label_source TEXT NOT NULL DEFAULT ''`, + ) +} + // backfillRoutineTriggerTimezones sets an explicit "UTC" on cron triggers // created before the column default changed from empty to 'UTC', so the // empty string no longer means anything at read time. diff --git a/apps/backend/internal/office/repository/sqlite/costs.go b/apps/backend/internal/office/repository/sqlite/costs.go index 2ef448dda4..b54a6d374c 100644 --- a/apps/backend/internal/office/repository/sqlite/costs.go +++ b/apps/backend/internal/office/repository/sqlite/costs.go @@ -95,13 +95,14 @@ func (r *Repository) CreateCostEvent(ctx context.Context, event *models.CostEven return err } -// ListCostEvents returns cost events filtered by workspace (via task join), ordered by time. +// ListCostEvents returns cost events filtered by their task or run-session workspace, ordered by time. func (r *Repository) ListCostEvents(ctx context.Context, workspaceID string) ([]*models.CostEvent, error) { var events []*models.CostEvent err := r.ro.SelectContext(ctx, &events, r.ro.Rebind(` SELECT e.* FROM office_cost_events e - JOIN tasks t ON t.id = e.task_id - WHERE t.workspace_id = ? + LEFT JOIN tasks t ON t.id = e.task_id + LEFT JOIN office_run_sessions rs ON e.task_id = '' AND rs.id = e.session_id + WHERE COALESCE(t.workspace_id, rs.workspace_id) = ? ORDER BY e.occurred_at DESC `), workspaceID) if err != nil { @@ -124,9 +125,10 @@ func (r *Repository) GetCostsByAgent(ctx context.Context, workspaceID string) ([ SUM(e.cost_subcents) AS total_subcents, COUNT(*) AS count FROM office_cost_events e - JOIN tasks t ON t.id = e.task_id + LEFT JOIN tasks t ON t.id = e.task_id + LEFT JOIN office_run_sessions rs ON e.task_id = '' AND rs.id = e.session_id LEFT JOIN agent_profiles ap ON ap.id = e.agent_profile_id - WHERE t.workspace_id = ? + WHERE COALESCE(t.workspace_id, rs.workspace_id) = ? GROUP BY e.agent_profile_id `), workspaceID) if err != nil { @@ -156,9 +158,10 @@ func (r *Repository) GetCostsByProject(ctx context.Context, workspaceID string) SUM(e.cost_subcents) AS total_subcents, COUNT(*) AS count FROM office_cost_events e - JOIN tasks t ON t.id = e.task_id + LEFT JOIN tasks t ON t.id = e.task_id + LEFT JOIN office_run_sessions rs ON e.task_id = '' AND rs.id = e.session_id LEFT JOIN office_projects op ON op.id = t.project_id - WHERE t.workspace_id = ? + WHERE COALESCE(t.workspace_id, rs.workspace_id) = ? GROUP BY COALESCE(t.project_id, '') `), workspaceID) if err != nil { @@ -192,8 +195,9 @@ func (r *Repository) GetCostsByModel(ctx context.Context, workspaceID string) ([ SUM(e.cost_subcents) AS total_subcents, COUNT(*) AS count FROM office_cost_events e - JOIN tasks t ON t.id = e.task_id - WHERE t.workspace_id = ? + LEFT JOIN tasks t ON t.id = e.task_id + LEFT JOIN office_run_sessions rs ON e.task_id = '' AND rs.id = e.session_id + WHERE COALESCE(t.workspace_id, rs.workspace_id) = ? GROUP BY e.provider, e.model `), workspaceID) if err != nil { @@ -226,8 +230,9 @@ func (r *Repository) GetCostsByProvider(ctx context.Context, workspaceID string) SUM(e.cost_subcents) AS total_subcents, COUNT(*) AS count FROM office_cost_events e - JOIN tasks t ON t.id = e.task_id - WHERE t.workspace_id = ? + LEFT JOIN tasks t ON t.id = e.task_id + LEFT JOIN office_run_sessions rs ON e.task_id = '' AND rs.id = e.session_id + WHERE COALESCE(t.workspace_id, rs.workspace_id) = ? GROUP BY COALESCE(NULLIF(e.provider, ''), 'unknown') `), workspaceID) if err != nil { @@ -246,8 +251,9 @@ func (r *Repository) SumCosts(ctx context.Context, workspaceID string) (int64, e err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT COALESCE(SUM(e.cost_subcents), 0) FROM office_cost_events e - JOIN tasks t ON t.id = e.task_id - WHERE t.workspace_id = ? + LEFT JOIN tasks t ON t.id = e.task_id + LEFT JOIN office_run_sessions rs ON e.task_id = '' AND rs.id = e.session_id + WHERE COALESCE(t.workspace_id, rs.workspace_id) = ? `), workspaceID).Scan(&total) return total, err } @@ -263,8 +269,9 @@ func (r *Repository) SumCostsSince(ctx context.Context, workspaceID string, sinc err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT COALESCE(SUM(e.cost_subcents), 0) FROM office_cost_events e - JOIN tasks t ON t.id = e.task_id - WHERE t.workspace_id = ? AND e.occurred_at >= ? + LEFT JOIN tasks t ON t.id = e.task_id + LEFT JOIN office_run_sessions rs ON e.task_id = '' AND rs.id = e.session_id + WHERE COALESCE(t.workspace_id, rs.workspace_id) = ? AND e.occurred_at >= ? `), workspaceID, since.UTC()).Scan(&total) return total, err } diff --git a/apps/backend/internal/office/repository/sqlite/run_sessions.go b/apps/backend/internal/office/repository/sqlite/run_sessions.go new file mode 100644 index 0000000000..59640b0b4b --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/run_sessions.go @@ -0,0 +1,284 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/office/models" +) + +// ReserveRunSession atomically inserts a fresh run-owned session attempt and +// binds the run's first session ID while the run is claimed. A duplicate +// (run_id, attempt) is a lost reservation and leaves the predecessor row +// untouched. A later attempt may coexist with the predecessor, but never +// overwrites its durable identity. +func (r *Repository) ReserveRunSession(ctx context.Context, session *models.RunSession) (bool, error) { + if err := validateRunSessionReservation(session); err != nil { + return false, err + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return false, fmt.Errorf("reserve run session: begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := r.validateRunSessionWorkspace(ctx, tx, session); err != nil { + return false, err + } + + result, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO office_run_sessions ( + id, workspace_id, agent_profile_id, run_id, attempt, state, + execution_id, execution_profile_id, adapter, model, acp_session_id, + created_at, started_at, finished_at, cancel_requested_at, error_message, version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + `), session.ID, session.WorkspaceID, session.AgentProfileID, session.RunID, + session.Attempt, session.State, session.ExecutionID, session.ExecutionProfileID, + session.Adapter, session.Model, session.ACPSessionID, session.CreatedAt, + session.StartedAt, session.FinishedAt, session.CancelRequestedAt, + session.ErrorMessage, session.Version) + if err != nil { + return false, fmt.Errorf("reserve run session: insert: %w", err) + } + inserted, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("reserve run session: rows affected: %w", err) + } + if inserted == 0 { + return false, nil + } + + bound, err := r.bindReservedRunSession(ctx, tx, session) + if err != nil { + return false, err + } + if !bound { + return false, nil + } + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("reserve run session: commit: %w", err) + } + return true, nil +} + +func validateRunSessionReservation(session *models.RunSession) error { + if session == nil || session.ID == "" || session.RunID == "" || session.WorkspaceID == "" || + session.AgentProfileID == "" || session.Attempt <= 0 || session.State == "" { + return errors.New("reserve run session: incomplete session") + } + return nil +} + +func (r *Repository) validateRunSessionWorkspace( + ctx context.Context, + tx *sqlx.Tx, + session *models.RunSession, +) error { + var profileWorkspace string + if err := tx.GetContext(ctx, &profileWorkspace, r.db.Rebind(` + SELECT COALESCE(workspace_id, '') FROM agent_profiles WHERE id = ? + `), session.AgentProfileID); err != nil { + return fmt.Errorf("reserve run session: resolve agent workspace: %w", err) + } + if profileWorkspace != session.WorkspaceID { + return fmt.Errorf("reserve run session: agent workspace %q does not match %q", profileWorkspace, session.WorkspaceID) + } + return nil +} + +func (r *Repository) bindReservedRunSession( + ctx context.Context, + tx *sqlx.Tx, + session *models.RunSession, +) (bool, error) { + // Only the first attempt is projected onto runs.session_id. The dedicated + // session table remains authoritative for retries and preserves the + // predecessor identity. + boundResult, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE runs + SET session_id = ? + WHERE id = ? AND status = 'claimed' AND COALESCE(session_id, '') = '' + `), session.ID, session.RunID) + if err != nil { + return false, fmt.Errorf("reserve run session: bind run: %w", err) + } + bound, err := boundResult.RowsAffected() + if err != nil { + return false, fmt.Errorf("reserve run session: bind rows affected: %w", err) + } + if bound == 1 { + return true, nil + } + var runStatus string + if err := tx.GetContext(ctx, &runStatus, r.db.Rebind(`SELECT status FROM runs WHERE id = ?`), session.RunID); err != nil { + return false, fmt.Errorf("reserve run session: verify run claim: %w", err) + } + return runStatus == "claimed", nil +} + +// GetRunSession returns one run-owned session by durable identity. +func (r *Repository) GetRunSession(ctx context.Context, sessionID string) (*models.RunSession, error) { + var session models.RunSession + err := r.ro.GetContext(ctx, &session, r.ro.Rebind(`SELECT * FROM office_run_sessions WHERE id = ?`), sessionID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get run session: %w", err) + } + return &session, nil +} + +// ListRunSessions returns all attempts for a run in reservation order. +func (r *Repository) ListRunSessions(ctx context.Context, runID string) ([]models.RunSession, error) { + var sessions []models.RunSession + err := r.ro.SelectContext(ctx, &sessions, r.ro.Rebind(` + SELECT * FROM office_run_sessions WHERE run_id = ? ORDER BY attempt ASC + `), runID) + if err != nil { + return nil, fmt.Errorf("list run sessions: %w", err) + } + if sessions == nil { + sessions = []models.RunSession{} + } + return sessions, nil +} + +// ListLiveRunSessionsForWorkspace returns every run-owned attempt that still +// has a runtime execution. It deliberately does not join runs: a pause must +// also find executions whose run was cancelled before the process stopped. +func (r *Repository) ListLiveRunSessionsForWorkspace(ctx context.Context, workspaceID string) ([]models.RunSession, error) { + var sessions []models.RunSession + err := r.ro.SelectContext(ctx, &sessions, r.ro.Rebind(` + SELECT * FROM office_run_sessions + WHERE workspace_id = ? AND COALESCE(execution_id, '') <> '' AND state IN (?, ?) + ORDER BY created_at ASC + `), workspaceID, models.RunSessionStatePreparing, models.RunSessionStateRunning) + if err != nil { + return nil, fmt.Errorf("list live run sessions: %w", err) + } + if sessions == nil { + sessions = []models.RunSession{} + } + return sessions, nil +} + +// BindRunSessionExecution records the runtime identity while the attempt is +// still preparing. This makes a registered execution visible to pause and +// deletion sweeps before process startup can race those controls. +func (r *Repository) BindRunSessionExecution( + ctx context.Context, sessionID, executionID, executionProfileID, adapter, model, acpSessionID string, +) (bool, error) { + if executionID == "" { + return false, errors.New("bind run session execution: execution ID is required") + } + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE office_run_sessions + SET execution_id = ?, execution_profile_id = ?, adapter = ?, model = ?, + acp_session_id = ?, version = version + 1 + WHERE id = ? AND state = ? AND COALESCE(execution_id, '') = '' + AND cancel_requested_at IS NULL + `), executionID, executionProfileID, adapter, model, acpSessionID, sessionID, + models.RunSessionStatePreparing) + if err != nil { + return false, fmt.Errorf("bind run session execution: %w", err) + } + wrote, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("bind run session execution: rows affected: %w", err) + } + return wrote == 1, nil +} + +// ListUnfinishedRunSessions returns every non-terminal attempt for startup +// reconciliation, including reservations that never received an execution ID. +func (r *Repository) ListUnfinishedRunSessions(ctx context.Context) ([]models.RunSession, error) { + var sessions []models.RunSession + err := r.ro.SelectContext(ctx, &sessions, r.ro.Rebind(` + SELECT * FROM office_run_sessions + WHERE state IN (?, ?) + ORDER BY created_at ASC + `), models.RunSessionStatePreparing, models.RunSessionStateRunning) + if err != nil { + return nil, fmt.Errorf("list unfinished run sessions: %w", err) + } + if sessions == nil { + sessions = []models.RunSession{} + } + return sessions, nil +} + +// RequestRunSessionCancellation persists cancellation intent before a pause +// asks the runtime to stop the process. The registration admission gate +// rejects a preparing attempt after this CAS wins. +func (r *Repository) RequestRunSessionCancellation(ctx context.Context, sessionID string) (bool, error) { + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE office_run_sessions + SET cancel_requested_at = ?, version = version + 1 + WHERE id = ? AND state IN (?, ?) AND cancel_requested_at IS NULL + `), time.Now().UTC(), sessionID, models.RunSessionStatePreparing, models.RunSessionStateRunning) + if err != nil { + return false, fmt.Errorf("request run session cancellation: %w", err) + } + wrote, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("request run session cancellation: rows affected: %w", err) + } + return wrote == 1, nil +} + +// MarkRunSessionStarted records the runtime execution identity and moves a +// preparing attempt to running. The compare-and-set prevents a late starter +// from resurrecting a cancelled or already-terminal attempt. +func (r *Repository) MarkRunSessionStarted( + ctx context.Context, sessionID, executionID, executionProfileID, adapter, model, acpSessionID string, +) (bool, error) { + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE office_run_sessions + SET state = ?, execution_id = ?, execution_profile_id = ?, adapter = ?, model = ?, + acp_session_id = ?, started_at = ?, version = version + 1 + WHERE id = ? AND state = ? AND execution_id = ? AND cancel_requested_at IS NULL + `), models.RunSessionStateRunning, executionID, executionProfileID, adapter, model, + acpSessionID, now, sessionID, models.RunSessionStatePreparing, executionID) + if err != nil { + return false, fmt.Errorf("mark run session started: %w", err) + } + wrote, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("mark run session started: rows affected: %w", err) + } + return wrote == 1, nil +} + +// FinishRunSession applies a terminal state exactly once to an attempt. +func (r *Repository) FinishRunSession( + ctx context.Context, sessionID string, state models.RunSessionState, errorMessage string, +) (bool, error) { + if state != models.RunSessionStateFinished && state != models.RunSessionStateFailed && + state != models.RunSessionStateCancelled && state != models.RunSessionStateInterrupted { + return false, errors.New("finish run session: invalid terminal state") + } + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE office_run_sessions + SET state = ?, finished_at = ?, error_message = ?, version = version + 1 + WHERE id = ? AND state IN (?, ?) + AND (cancel_requested_at IS NULL OR ? <> ?) + `), state, now, errorMessage, sessionID, + models.RunSessionStatePreparing, models.RunSessionStateRunning, + state, models.RunSessionStateFinished) + if err != nil { + return false, fmt.Errorf("finish run session: %w", err) + } + wrote, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("finish run session: rows affected: %w", err) + } + return wrote == 1, nil +} diff --git a/apps/backend/internal/office/repository/sqlite/run_sessions_postgres_test.go b/apps/backend/internal/office/repository/sqlite/run_sessions_postgres_test.go new file mode 100644 index 0000000000..f1b175c708 --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/run_sessions_postgres_test.go @@ -0,0 +1,63 @@ +package sqlite_test + +import ( + "context" + "testing" + "time" + + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/office/models" + "github.com/kandev/kandev/internal/office/repository/sqlite" + taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/testutil" +) + +// AC-OFFICE-TASKLESS-001.1: the reservation CAS uses the same portable +// transaction on the supported PostgreSQL backend, not a SQLite-only insert. +func TestPostgresRunSessionReservationCAS(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + if _, err := taskrepo.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init task repo: %v", err) + } + if _, _, err := settingsstore.Provide(db, db, nil); err != nil { + t.Fatalf("init settings store: %v", err) + } + repo, err := sqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init office repo: %v", err) + } + ctx := context.Background() + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO agents (id, name, created_at, updated_at) + VALUES ('pg-run-session-agent-type', 'Agent', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed agent type: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, workspace_id, created_at, updated_at) + VALUES ('pg-run-session-agent', 'pg-run-session-agent-type', 'Agent', 'Agent', 'pg-run-session-workspace', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed agent profile: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at) + VALUES ('pg-run-session-run', 'pg-run-session-agent', 'routine_dispatch_cron', '{}', 'claimed', CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed run: %v", err) + } + session := &models.RunSession{ + ID: "pg-run-session-1", WorkspaceID: "pg-run-session-workspace", + AgentProfileID: "pg-run-session-agent", RunID: "pg-run-session-run", + Attempt: 1, State: models.RunSessionStatePreparing, CreatedAt: time.Now().UTC(), Version: 1, + } + reserved, err := repo.ReserveRunSession(ctx, session) + if err != nil || !reserved { + t.Fatalf("reservation = %v, %v; want true, nil", reserved, err) + } + duplicate := *session + duplicate.ID = "pg-run-session-duplicate" + reserved, err = repo.ReserveRunSession(ctx, &duplicate) + if err != nil || reserved { + t.Fatalf("duplicate reservation = %v, %v; want false, nil", reserved, err) + } +} diff --git a/apps/backend/internal/office/repository/sqlite/run_sessions_test.go b/apps/backend/internal/office/repository/sqlite/run_sessions_test.go new file mode 100644 index 0000000000..f72cff32b0 --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/run_sessions_test.go @@ -0,0 +1,123 @@ +package sqlite_test + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/office/models" +) + +// AC-OFFICE-TASKLESS-001.1: run-owned sessions have their own durable table +// instead of borrowing the task_sessions foreign key contract. +func TestRunSessionSchemaExists(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO agents (id, name, created_at, updated_at) + VALUES ('agent-schema-type', 'Agent', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed agent type: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, workspace_id, created_at, updated_at) + VALUES ('agent-schema', 'agent-schema-type', 'Agent', 'Agent', 'workspace-schema', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed agent profile: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO runs ( + id, agent_profile_id, reason, payload, status, requested_at + ) VALUES ('run-schema', 'agent-schema', 'routine_dispatch_cron', '{}', 'claimed', CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed run: %v", err) + } + _, err := repo.ExecRaw(ctx, ` + INSERT INTO office_run_sessions ( + id, workspace_id, agent_profile_id, run_id, attempt, state, created_at + ) VALUES ('session-schema', 'workspace-schema', 'agent-schema', 'run-schema', 1, ?, CURRENT_TIMESTAMP) + `, "preparing") + if err != nil { + t.Fatalf("insert run session: %v", err) + } +} + +// AC-OFFICE-TASKLESS-001.1/.2: a duplicate reservation loses without +// replacing the predecessor, while a new attempt gets a fresh identity. +func TestRunSessionReservationCAS(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO agents (id, name, created_at, updated_at) + VALUES ('agent-cas-type', 'Agent', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed agent type: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, workspace_id, created_at, updated_at) + VALUES ('agent-cas', 'agent-cas-type', 'Agent', 'Agent', 'workspace-cas', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed agent profile: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at) + VALUES ('run-cas', 'agent-cas', 'routine_dispatch_cron', '{}', 'claimed', CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed run: %v", err) + } + + first := &models.RunSession{ + ID: "session-cas-1", WorkspaceID: "workspace-cas", AgentProfileID: "agent-cas", + RunID: "run-cas", Attempt: 1, State: models.RunSessionStatePreparing, + CreatedAt: time.Now().UTC(), Version: 1, + } + reserved, err := repo.ReserveRunSession(ctx, first) + if err != nil || !reserved { + t.Fatalf("first reservation = %v, %v; want true, nil", reserved, err) + } + executionBound, err := repo.BindRunSessionExecution( + ctx, first.ID, "execution-cas-1", "profile-cas", "adapter", "model", "", + ) + if err != nil || !executionBound { + t.Fatalf("bind execution = %v, %v; want true, nil", executionBound, err) + } + requested, err := repo.RequestRunSessionCancellation(ctx, first.ID) + if err != nil || !requested { + t.Fatalf("request cancellation = %v, %v; want true, nil", requested, err) + } + finished, err := repo.FinishRunSession(ctx, first.ID, models.RunSessionStateFinished, "") + if err != nil || finished { + t.Fatalf("finish after cancellation = %v, %v; want false, nil", finished, err) + } + cancelled, err := repo.FinishRunSession(ctx, first.ID, models.RunSessionStateCancelled, "cancelled") + if err != nil || !cancelled { + t.Fatalf("cancel after cancellation = %v, %v; want true, nil", cancelled, err) + } + duplicate := *first + duplicate.ID = "session-cas-duplicate" + reserved, err = repo.ReserveRunSession(ctx, &duplicate) + if err != nil || reserved { + t.Fatalf("duplicate reservation = %v, %v; want false, nil", reserved, err) + } + second := *first + second.ID = "session-cas-2" + second.Attempt = 2 + reserved, err = repo.ReserveRunSession(ctx, &second) + if err != nil || !reserved { + t.Fatalf("second reservation = %v, %v; want true, nil", reserved, err) + } + sessions, err := repo.ListRunSessions(ctx, first.RunID) + if err != nil { + t.Fatalf("list sessions: %v", err) + } + if len(sessions) != 2 || sessions[0].ID != first.ID || sessions[1].ID != second.ID { + t.Fatalf("sessions = %#v, want both immutable attempts", sessions) + } + var bound string + if err := repo.ReaderDB().GetContext(ctx, &bound, `SELECT session_id FROM runs WHERE id = ?`, first.RunID); err != nil { + t.Fatalf("read bound run session: %v", err) + } + if bound != first.ID { + t.Fatalf("run.session_id = %q, want predecessor's first bound identity %q", bound, first.ID) + } +} diff --git a/apps/backend/internal/office/repository/sqlite/skills.go b/apps/backend/internal/office/repository/sqlite/skills.go index 38dcc98bec..b7118cd47a 100644 --- a/apps/backend/internal/office/repository/sqlite/skills.go +++ b/apps/backend/internal/office/repository/sqlite/skills.go @@ -428,9 +428,16 @@ func (r *Repository) CreateRunSkillSnapshots(ctx context.Context, snapshots []mo } defer func() { _ = tx.Rollback() }() stmt, err := tx.PreparexContext(ctx, r.db.Rebind(` - INSERT OR REPLACE INTO office_run_skills ( - run_id, skill_id, version, content_hash, materialized_path - ) VALUES (?, ?, ?, ?, ?) + INSERT INTO office_run_skills ( + run_id, skill_id, display_name, slug, label_source, version, content_hash, materialized_path + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (run_id, skill_id) DO UPDATE SET + display_name = excluded.display_name, + slug = excluded.slug, + label_source = excluded.label_source, + version = excluded.version, + content_hash = excluded.content_hash, + materialized_path = excluded.materialized_path `)) if err != nil { return err @@ -438,8 +445,8 @@ func (r *Repository) CreateRunSkillSnapshots(ctx context.Context, snapshots []mo defer func() { _ = stmt.Close() }() for _, snap := range snapshots { if _, err := stmt.ExecContext(ctx, - snap.RunID, snap.SkillID, snap.Version, - snap.ContentHash, snap.MaterializedPath, + snap.RunID, snap.SkillID, snap.DisplayName, snap.Slug, + snap.LabelSource, snap.Version, snap.ContentHash, snap.MaterializedPath, ); err != nil { return err } diff --git a/apps/backend/internal/office/repository/sqlite/skills_test.go b/apps/backend/internal/office/repository/sqlite/skills_test.go index 10f0d16930..6265e0e2e2 100644 --- a/apps/backend/internal/office/repository/sqlite/skills_test.go +++ b/apps/backend/internal/office/repository/sqlite/skills_test.go @@ -27,6 +27,9 @@ func TestRunSkillSnapshotsRemainStableAfterSkillUpdate(t *testing.T) { if err := repo.CreateRunSkillSnapshots(ctx, []models.RunSkillSnapshot{{ RunID: "run-1", SkillID: skill.ID, + DisplayName: skill.Name, + Slug: skill.Slug, + LabelSource: "captured", Version: skill.Version, ContentHash: skill.ContentHash, MaterializedPath: "/tmp/run-1/skills/review", @@ -52,4 +55,31 @@ func TestRunSkillSnapshotsRemainStableAfterSkillUpdate(t *testing.T) { if got.Version != "v1" || got.ContentHash != "hash-original" { t.Fatalf("snapshot changed after source update: %#v", got) } + if got.DisplayName != "Review" || got.Slug != "review" || got.LabelSource != "captured" { + t.Fatalf("snapshot label changed after source update: %#v", got) + } +} + +func TestRunSkillSnapshotLabelRetention(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + if err := repo.CreateRunSkillSnapshots(ctx, []models.RunSkillSnapshot{{ + RunID: "run-2", SkillID: "skill-2", DisplayName: "Legacy Review", Slug: "legacy-review", + LabelSource: "captured", Version: "v1", ContentHash: "hash", MaterializedPath: "/tmp/skills", + }}); err != nil { + t.Fatalf("CreateRunSkillSnapshots: %v", err) + } + if err := repo.CreateRunSkillSnapshots(ctx, []models.RunSkillSnapshot{{ + RunID: "run-2", SkillID: "skill-2", DisplayName: "Renamed Review", Slug: "renamed-review", + LabelSource: "captured", Version: "v2", ContentHash: "hash-2", MaterializedPath: "/tmp/skills-2", + }}); err != nil { + t.Fatalf("replace snapshot: %v", err) + } + snapshots, err := repo.ListRunSkillSnapshots(ctx, "run-2") + if err != nil { + t.Fatalf("ListRunSkillSnapshots: %v", err) + } + if len(snapshots) != 1 || snapshots[0].DisplayName != "Renamed Review" { + t.Fatalf("snapshots = %#v, want one updated snapshot", snapshots) + } } diff --git a/apps/backend/internal/office/repository/sqlite/workspace_deletion.go b/apps/backend/internal/office/repository/sqlite/workspace_deletion.go index bec54f7470..ae1af65737 100644 --- a/apps/backend/internal/office/repository/sqlite/workspace_deletion.go +++ b/apps/backend/internal/office/repository/sqlite/workspace_deletion.go @@ -60,6 +60,7 @@ func (r *Repository) deleteWorkspaceDataTx(ctx context.Context, tx *sqlx.Tx, wor `DELETE FROM office_run_skills WHERE run_id IN ( SELECT id FROM runs WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?) )`, + `DELETE FROM office_run_sessions WHERE workspace_id = ?`, `DELETE FROM runs WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`, `DELETE FROM agent_wakeup_requests WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`, `DELETE FROM agent_continuation_summaries WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`, diff --git a/apps/backend/internal/office/repository/sqlite/workspace_deletion_test.go b/apps/backend/internal/office/repository/sqlite/workspace_deletion_test.go index 28e7e7a57b..7031a2b0c9 100644 --- a/apps/backend/internal/office/repository/sqlite/workspace_deletion_test.go +++ b/apps/backend/internal/office/repository/sqlite/workspace_deletion_test.go @@ -35,6 +35,7 @@ func TestDeleteWorkspaceDataDeletesOwnedOfficeRows(t *testing.T) { "run_events", "office_run_route_attempts", "office_run_skills", + "office_run_sessions", "agent_wakeup_requests", "agent_continuation_summaries", "office_routine_triggers", @@ -133,6 +134,7 @@ func seedWorkspaceDeletionRows(t *testing.T, repo *sqlite.Repository, workspaceI execRaw(t, repo, `INSERT INTO run_events (run_id, seq, event_type, payload, created_at) VALUES (?, 1, 'queued', '{}', ?)`, runID, now) execRaw(t, repo, `INSERT INTO office_run_route_attempts (run_id, seq, provider_id, model, tier, outcome, started_at) VALUES (?, 1, 'codex', 'gpt', 'balanced', 'failed', ?)`, runID, now) execRaw(t, repo, `INSERT INTO office_run_skills (run_id, skill_id, version, content_hash, materialized_path) VALUES (?, ?, 'v1', 'hash', '/tmp/skill')`, runID, workspaceID+"-skill") + execRaw(t, repo, `INSERT INTO office_run_sessions (id, workspace_id, agent_profile_id, run_id, attempt, state, created_at) VALUES (?, ?, ?, ?, 1, 'finished', ?)`, workspaceID+"-run-session", workspaceID, agentID, runID, now) execRaw(t, repo, `INSERT INTO agent_wakeup_requests (id, agent_profile_id, source, status, requested_at) VALUES (?, ?, 'test', 'queued', ?)`, workspaceID+"-wakeup", agentID, now) execRaw(t, repo, `INSERT INTO agent_continuation_summaries (agent_profile_id, scope, content) VALUES (?, 'workspace', 'summary')`, agentID) execRaw(t, repo, `INSERT INTO office_agent_pause_recoveries (agent_id, task_id, failed_run_id) VALUES (?, ?, ?)`, agentID, taskID, runID) diff --git a/apps/backend/internal/office/scheduler/dispatch_routing.go b/apps/backend/internal/office/scheduler/dispatch_routing.go index e0ec2aec42..c56d49cabe 100644 --- a/apps/backend/internal/office/scheduler/dispatch_routing.go +++ b/apps/backend/internal/office/scheduler/dispatch_routing.go @@ -71,12 +71,15 @@ func (ss *SchedulerService) DispatchWithRouting( ctx context.Context, run *models.Run, agent *models.AgentInstance, launch LaunchContext, ) (bool, bool, error) { - if ss.resolver == nil || ss.taskStarter == nil { + if ss.resolver == nil || (ss.taskStarter == nil && ss.runSessionLauncher == nil) { return false, false, nil } if agent == nil || run == nil { return false, false, fmt.Errorf("dispatch: nil run or agent") } + if extractRunTaskID(run.Payload) != "" && ss.taskStarter == nil { + return false, false, nil + } prior, err := ss.repo.ListRouteAttempts(ctx, run.ID) if err != nil { return false, false, fmt.Errorf("dispatch: list prior: %w", err) @@ -262,7 +265,6 @@ func (ss *SchedulerService) tryCandidates( agent *models.AgentInstance, res *routing.Resolution, launch LaunchContext, prior []models.RouteAttempt, ) (bool, *routing.BlockReason, error) { - taskID := extractRunTaskID(run.Payload) var prev *routing.Candidate for i := range res.Candidates { candidate := res.Candidates[i] @@ -273,7 +275,7 @@ func (ss *SchedulerService) tryCandidates( if err != nil { return false, nil, err } - sessionID, launchErr := ss.launchCandidate(ctx, taskID, agent.ID, candidate, candidateLaunch) + sessionID, launchErr := ss.launchCandidate(ctx, run, agent, candidate, candidateLaunch) if errors.Is(launchErr, service.ErrLaunchDeferredByCapacity) { return ss.handleLaunchDeferred(ctx, run, agent.WorkspaceID, seq) } @@ -419,11 +421,29 @@ func (ss *SchedulerService) recordAttemptStart( // everything except provider/model selection. Without this, the routed // path would fall back to task.Description and lose role framing. func (ss *SchedulerService) launchCandidate( - ctx context.Context, taskID, agentID string, + ctx context.Context, run *models.Run, agent *models.AgentInstance, candidate routing.Candidate, launch LaunchContext, ) (string, error) { + taskID := extractRunTaskID(run.Payload) + agentID := agent.ID if taskID == "" { - return "", fmt.Errorf("dispatch: empty task id in run payload") + if ss.runSessionLauncher == nil { + return "", fmt.Errorf("dispatch: taskless run-session launcher is not configured") + } + route := &RouteOverride{ + ExecutionProfileID: candidate.ExecutionProfileID, + ProviderID: string(candidate.ProviderID), + Model: candidate.Model, + Tier: string(candidate.Tier), + Mode: candidate.Mode, + Flags: candidate.Flags, + Env: candidate.Env, + } + result, err := ss.runSessionLauncher.StartRunSession(ctx, run, agent, launch, route) + if err != nil { + return "", err + } + return result.SessionID, nil } if _, ok := routingerr.InjectedCode(string(candidate.ProviderID)); ok { // Synthesize a launch failure via Classify so injection is diff --git a/apps/backend/internal/office/scheduler/run.go b/apps/backend/internal/office/scheduler/run.go index a6b612dd98..8924611817 100644 --- a/apps/backend/internal/office/scheduler/run.go +++ b/apps/backend/internal/office/scheduler/run.go @@ -42,15 +42,7 @@ var ErrRoutingNotSupported = errors.New("routing not supported by task starter") // model is the base profile's CLIFlags + AutoApprove booleans, not a // preset-by-name. Per-provider permission overrides would require // re-modelling that surface and are deferred. -type RouteOverride struct { - ExecutionProfileID string - ProviderID string - Model string - Tier string - Mode string - Flags []string - Env map[string]string -} +type RouteOverride = service.RouteOverride // LaunchContext is an alias for service.LaunchContext so dispatch // callsites inside this package can spell the type without re-imports. @@ -195,6 +187,7 @@ type SchedulerService struct { logger *logger.Logger svc *service.Service taskStarter TaskStarter + runSessionLauncher service.RunSessionLauncher resolver *routing.Resolver eb bus.EventBus apiBaseURL string @@ -248,6 +241,11 @@ func (ss *SchedulerService) SetTaskStarter(ts TaskStarter) { ss.taskStarter = ts } +// SetRunSessionLauncher wires the Office-owned taskless launch seam. +func (ss *SchedulerService) SetRunSessionLauncher(launcher service.RunSessionLauncher) { + ss.runSessionLauncher = launcher +} + // SetResolver wires the routing resolver. When set, dispatch goes through // dispatchWithRouting; when nil, the legacy concrete-profile path runs. func (ss *SchedulerService) SetResolver(r *routing.Resolver) { diff --git a/apps/backend/internal/office/service/base_test.go b/apps/backend/internal/office/service/base_test.go index 9f10932b6f..f29e9d87e0 100644 --- a/apps/backend/internal/office/service/base_test.go +++ b/apps/backend/internal/office/service/base_test.go @@ -182,6 +182,9 @@ func applyServiceOverrides(opts *service.ServiceOptions, o service.ServiceOption if o.TaskCanceller != nil { opts.TaskCanceller = o.TaskCanceller } + if o.RunExecutionStopper != nil { + opts.RunExecutionStopper = o.RunExecutionStopper + } if o.TaskWorkspace != nil { opts.TaskWorkspace = o.TaskWorkspace } diff --git a/apps/backend/internal/office/service/event_subscribers.go b/apps/backend/internal/office/service/event_subscribers.go index ce10b32030..1e0bd1a5f1 100644 --- a/apps/backend/internal/office/service/event_subscribers.go +++ b/apps/backend/internal/office/service/event_subscribers.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" + runtimeapi "github.com/kandev/kandev/internal/agent/runtime" "github.com/kandev/kandev/internal/agent/runtime/routingerr" "github.com/kandev/kandev/internal/agentctl/types/streams" commoncosts "github.com/kandev/kandev/internal/common/costs" @@ -153,15 +154,19 @@ type ApprovalResolvedData struct { // same identity, so taskless fallback resolution and summary writes use this // field, not AgentID. type AgentLifecycleData struct { + AgentExecutionID string `json:"agent_execution_id"` TaskID string `json:"task_id"` RunID string `json:"run_id"` + RunSessionID string `json:"run_session_id"` + RunAttempt int `json:"run_attempt"` + WorkspaceID string `json:"workspace_id"` + OwnerKind string `json:"owner_kind"` AgentID string `json:"agent_id"` AgentProfileID string `json:"agent_profile_id"` SessionID string `json:"session_id"` TurnID string `json:"turn_id,omitempty"` ErrorMessage string `json:"error_message"` ProviderError *streams.ProviderError `json:"provider_error,omitempty"` - AgentExecutionID string `json:"agent_execution_id"` PromptGeneration uint64 `json:"prompt_generation,omitempty"` EvidenceKnown bool `json:"evidence_known,omitempty"` OutputObserved bool `json:"output_observed,omitempty"` @@ -189,6 +194,21 @@ func (s *Service) resolveLifecycleRun(ctx context.Context, data AgentLifecycleDa if data.SessionID != "" && run.SessionID != "" && data.SessionID != run.SessionID { return nil, sql.ErrNoRows } + if data.RunSessionID != "" { + session, sessionErr := s.repo.GetRunSession(ctx, data.RunSessionID) + if sessionErr != nil { + return nil, sessionErr + } + if session == nil || session.RunID != run.ID || session.Attempt != data.RunAttempt || + session.AgentProfileID != run.AgentProfileID || + (session.State != models.RunSessionStatePreparing && session.State != models.RunSessionStateRunning && + session.State != models.RunSessionStateFinished) { + return nil, sql.ErrNoRows + } + if data.AgentExecutionID != "" && session.ExecutionID != "" && session.ExecutionID != data.AgentExecutionID { + return nil, sql.ErrNoRows + } + } return run, nil } if data.TaskID != "" { @@ -202,17 +222,28 @@ func (s *Service) resolveLifecycleRun(ctx context.Context, data AgentLifecycleDa return s.repo.GetClaimedTasklessRunForAgent(ctx, agentProfileID) } +// exactRunSessionEvent reports whether the lifecycle event carries the +// immutable run-attempt identity. A missing claimed run for such an event is +// a stale predecessor signal and must not clear a successor's working state. +func exactRunSessionEvent(data *AgentLifecycleData) bool { + return data != nil && data.RunID != "" && data.RunSessionID != "" +} + type PromptUsageData struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id"` - AgentID string `json:"agent_id"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - AgentType string `json:"agent_type"` - Model string `json:"model"` - Provider string `json:"provider"` - Usage UsageTokens `json:"usage"` - TurnID string `json:"turn_id,omitempty"` - UsageEventID string `json:"usage_event_id,omitempty"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + RunSessionID string `json:"run_session_id,omitempty"` + RunAttempt int `json:"run_attempt,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + AgentID string `json:"agent_id"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + AgentType string `json:"agent_type"` + Model string `json:"model"` + Provider string `json:"provider"` + Usage UsageTokens `json:"usage"` + TurnID string `json:"turn_id,omitempty"` + UsageEventID string `json:"usage_event_id,omitempty"` } // UsageTokens mirrors streams.PromptUsage on the wire. All counts are int64 @@ -271,6 +302,7 @@ func (s *Service) RegisterEventSubscribers(eb bus.EventBus) error { {events.OfficeApprovalResolved, s.handleApprovalResolved}, {events.OfficeCommentCreated, s.handleCommentCreated}, {events.AgentCompleted, maybeAsync(s.handleAgentCompleted)}, + {events.AgentReady, maybeAsync(s.handleTasklessAgentReady)}, // AgentStopped fires when StopAgent is called (e.g. office // fire-and-forget turn-complete teardown). Same handler — both // signal "the agent is no longer running on this task and the @@ -281,6 +313,7 @@ func (s *Service) RegisterEventSubscribers(eb bus.EventBus) error { {events.AgentStopped, maybeAsync(s.handleAgentCompleted)}, {events.AgentFailed, maybeAsync(s.handleAgentFailed)}, {events.BuildSessionPromptUsageWildcardSubject(), maybeAsync(s.handlePromptUsage)}, + {events.BuildAgentStreamWildcardSubject(), maybeAsync(s.handleAgentStreamUsage)}, {events.AgentTurnMessageSaved, maybeAsync(s.handleAgentTurnMessageSaved)}, } for _, sub := range subs { @@ -425,6 +458,9 @@ func (s *Service) handleAgentCompleted(ctx context.Context, event *bus.Event) er run, err := s.resolveLifecycleRun(ctx, *data) if err != nil { if errors.Is(err, sql.ErrNoRows) { + if exactRunSessionEvent(data) { + return nil + } // No claimed run resolves for this event: it already finished // via another path, arrived late/duplicated, or a cancellation // marked the run terminal before this event landed. There is no @@ -565,6 +601,9 @@ func (s *Service) handleTasklessAgentCompleted( run, err := s.resolveLifecycleRun(ctx, *data) if err != nil { if errors.Is(err, sql.ErrNoRows) { + if exactRunSessionEvent(data) { + return nil + } // Same reasoning as handleAgentCompleted's and handleAgentFailed's // ErrNoRows exits: no claimed run resolves, so nothing reaches // this function's own clear below, but the agent may still be @@ -579,6 +618,9 @@ func (s *Service) handleTasklessAgentCompleted( } return err } + if err := s.finishRunSession(ctx, data, models.RunSessionStateFinished, ""); err != nil { + return err + } // run came from GetClaimedTasklessRunForAgent: it is the run that // actually launched. Taskless runs typically carry no task_id, so this // is a no-op in the common case, but call it for the same reason as @@ -611,6 +653,42 @@ func (s *Service) handleTasklessAgentCompleted( return nil } +func (s *Service) finishRunSession( + ctx context.Context, data *AgentLifecycleData, state models.RunSessionState, message string, +) error { + if data == nil || data.RunSessionID == "" { + return nil + } + session, err := s.repo.GetRunSession(ctx, data.RunSessionID) + if err != nil { + return err + } + if session == nil { + return sql.ErrNoRows + } + if session.State == state { + return nil + } + if session.State != models.RunSessionStatePreparing && session.State != models.RunSessionStateRunning { + return sql.ErrNoRows + } + wrote, err := s.repo.FinishRunSession(ctx, data.RunSessionID, state, message) + if err != nil { + return err + } + if wrote { + return nil + } + latest, err := s.repo.GetRunSession(ctx, data.RunSessionID) + if err != nil { + return err + } + if latest != nil && latest.State == state { + return nil + } + return sql.ErrNoRows +} + // refreshContinuationSummary rebuilds the continuation summary for the // given agent and upserts it under run.ContinuationScope — the scope key // models.ContinuationScopeForRun computed once, at run-creation time, and @@ -726,12 +804,18 @@ func truncateRunOutputSummary(content string) string { func (s *Service) handleAgentFailed(ctx context.Context, event *bus.Event) error { data, err := decodeEventData[AgentLifecycleData](event) - if err != nil || data.TaskID == "" { + if err != nil { return nil } + if data.TaskID == "" { + return s.handleTasklessAgentFailed(ctx, data) + } run, err := s.resolveLifecycleRun(ctx, *data) if err != nil { if errors.Is(err, sql.ErrNoRows) { + if exactRunSessionEvent(data) { + return nil + } // Same reasoning as handleAgentCompleted's ErrNoRows exit: no // claimed run resolves, so nothing reaches HandleAgentFailure's // clear, but the agent may still be "working" from the launch. @@ -784,6 +868,74 @@ func (s *Service) handleAgentFailed(ctx context.Context, event *bus.Event) error return nil } +func (s *Service) handleTasklessAgentFailed( + ctx context.Context, data *AgentLifecycleData, +) error { + if data == nil || (data.AgentID == "" && data.AgentProfileID == "" && data.RunID == "") { + return nil + } + run, err := s.resolveLifecycleRun(ctx, *data) + if err != nil { + return s.handleTasklessAgentLookupError(ctx, data, err) + } + s.AppendRunEvent(ctx, run.ID, "error", "error", map[string]interface{}{ + "session_id": data.SessionID, + "run_session_id": data.RunSessionID, + "error_message": data.ErrorMessage, + }) + s.clearAgentWorking(ctx, run.AgentProfileID, run.ID) + if err := s.finishRunSession(ctx, data, models.RunSessionStateFailed, data.ErrorMessage); err != nil { + return err + } + if s.tryPostStartFallback(ctx, run, data.ErrorMessage, data.ProviderError) { + return nil + } + wrote, err := s.HandleAgentFailure( + ctx, + run, + enrichModelFailureMessage(run, data.ErrorMessage), + data.AgentID, + data.ProviderError, + AgentFailureEvidence{ + RunID: data.RunID, + SessionID: data.SessionID, + AgentExecutionID: data.AgentExecutionID, + PromptGeneration: data.PromptGeneration, + EvidenceKnown: data.EvidenceKnown, + OutputObserved: data.OutputObserved, + EffectObserved: data.EffectObserved, + ProviderDiagnosticCandidate: data.ProviderDiagnosticCandidate, + ProviderDiagnosticText: data.ProviderDiagnosticText, + }, + ) + if err != nil { + return err + } + if wrote { + s.publishRunProcessedForWorkspace(ctx, run.ID, RunStatusFailed, run, data.WorkspaceID) + } + return nil +} + +func (s *Service) handleTasklessAgentLookupError( + ctx context.Context, + data *AgentLifecycleData, + err error, +) error { + if !errors.Is(err, sql.ErrNoRows) { + return err + } + if exactRunSessionEvent(data) { + return nil + } + agentProfileID := data.AgentProfileID + if agentProfileID == "" { + agentProfileID = data.AgentID + } + s.clearAgentWorking(ctx, agentProfileID, data.RunID) + return nil +} + // dispatchAgentErrorTrigger wakes the workspace CEO agent (WO-05) after a // terminal agent-session failure. This is Path A in the office failure // model; Path B (HandleRunFailure -> escalateFailure, pre-launch retry @@ -899,18 +1051,31 @@ func (s *Service) handlePromptUsage(ctx context.Context, event *bus.Event) error s.recordCostEventDropped(costDropReasonDecodeError, "") return nil } - if data.TaskID == "" || data.SessionID == "" { + var fields *sqlite.TaskExecutionFields + switch { + case data.TaskID == "" && data.RunSessionID != "": + fields, err = s.tasklessUsageFields(ctx, data) + if err != nil { + return err + } + if fields == nil { + s.recordCostEventDropped(costDropReasonMissingIDs, data.TaskID) + return nil + } + + case data.TaskID == "" || data.SessionID == "": s.recordCostEventDropped(costDropReasonMissingIDs, data.TaskID) return nil - } - fields, err := s.repo.GetTaskExecutionFields(ctx, data.TaskID) - if err != nil { - s.recordCostEventDropped(costDropReasonTaskFieldsError, data.TaskID) - return nil + default: + fields, err = s.repo.GetTaskExecutionFields(ctx, data.TaskID) + if err != nil { + s.recordCostEventDropped(costDropReasonTaskFieldsError, data.TaskID) + return nil + } } sessionAgentProfileID := data.AgentProfileID - if sessionAgentProfileID == "" { + if sessionAgentProfileID == "" && data.TaskID != "" { id, lookupErr := s.repo.GetSessionAgentProfileID(ctx, data.TaskID, data.SessionID) if lookupErr != nil { // Best-effort attribution fallback: log and continue with an @@ -954,6 +1119,43 @@ func (s *Service) handlePromptUsage(ctx context.Context, event *bus.Event) error return nil } +// handleAgentStreamUsage bridges direct shared-runtime usage into the Office +// cost ledger. Taskless executions do not have an orchestrator task session, +// so the owner fields on the stream payload provide exact attribution. +func (s *Service) handleAgentStreamUsage(ctx context.Context, event *bus.Event) error { + payload, err := decodeEventData[runtimeapi.AgentStreamEventPayload](event) + if err != nil || payload.OwnerKind != runtimeapi.ExecutionOwnerRun || payload.RunSessionID == "" || payload.Data == nil || payload.Data.Usage == nil { + return nil + } + usage := payload.Data.Usage + outputPresent := usage.OutputTokensPresent + return s.handlePromptUsage(ctx, bus.NewEvent(events.SessionPromptUsageUpdated, "office-service", PromptUsageData{ + AgentExecutionID: payload.ExecutionID, + SessionID: payload.RunSessionID, + RunSessionID: payload.RunSessionID, + RunAttempt: payload.RunAttempt, + WorkspaceID: payload.WorkspaceID, + AgentID: payload.AgentType, + AgentProfileID: payload.AgentProfileID, + AgentType: payload.AgentType, + Model: payload.Data.CurrentModelID, + TurnID: payload.Data.TurnID, + UsageEventID: fmt.Sprintf("run:%s:%d", payload.ExecutionID, payload.Data.PromptGeneration), + Usage: UsageTokens{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + OutputTokensPresent: &outputPresent, + CachedReadTokens: usage.CachedReadTokens, + CachedWriteTokens: usage.CachedWriteTokens, + ThoughtTokens: usage.ThoughtTokens, + TotalTokens: usage.TotalTokens, + ProviderReportedCostSubcents: usage.ProviderReportedCostSubcents, + ProviderReportedCostPresent: usage.ProviderReportedCostPresent, + Estimated: usage.Estimated, + }, + })) +} + // publishCostRecorded emits the durable cost write as an Office notification. // The database insert is authoritative; a publish failure is logged and does // not turn a successful cost write into a failed prompt-usage event. diff --git a/apps/backend/internal/office/service/scheduler_integration.go b/apps/backend/internal/office/service/scheduler_integration.go index 6a8eef0e37..051af3cfa4 100644 --- a/apps/backend/internal/office/service/scheduler_integration.go +++ b/apps/backend/internal/office/service/scheduler_integration.go @@ -58,8 +58,8 @@ func TickIntervalFromEnv() time.Duration { // SchedulerIntegration runs the run processing tick loop. // Each tick claims the next eligible run, validates guards, -// resolves executor config, builds the prompt, and marks the -// run finished. Agent launch is not yet wired. +// resolves executor config, builds the prompt, and either launches the agent +// or finishes the run with a terminal scheduler outcome. // TaskContextProvider supplies the office task-handoffs prompt context // (related tasks, available document keys, workspace group). Optional — // when nil the prompt builder omits the handoff section. The @@ -406,6 +406,7 @@ func (si *SchedulerIntegration) prepareAndLaunch( zap.Int("env_count", len(env))) launchCtx := LaunchContext{ + ExecutorID: execCfg.Type, Prompt: prompt, Env: env, ProfileID: profileID, @@ -512,9 +513,8 @@ func (si *SchedulerIntegration) assembleAgentPrompt( // wakeup/dispatcher.go's createFreshRun passes taskID=="" on every // lightweight-routine fire (the pre-installed coordinator heartbeat, // among others), so this branch runs on a real cadence today. The -// assembled prompt is still built for these runs even though -// launchAgent cannot currently launch one (WO-35) — see that function's -// doc comment. +// assembled prompt is still built for these runs before the run-owned runtime +// launch is admitted. // // The scope read here is run.ContinuationScope — the same key // models.ContinuationScopeForRun computed once, at run-creation time, and @@ -566,6 +566,9 @@ func (si *SchedulerIntegration) snapshotRunSkills(ctx context.Context, runID str snapshots = append(snapshots, models.RunSkillSnapshot{ RunID: runID, SkillID: skill.ID, + DisplayName: skill.DisplayName, + Slug: skill.Slug, + LabelSource: "captured", Version: skill.Version, ContentHash: skill.ContentHash, MaterializedPath: instructionsDir, @@ -643,11 +646,9 @@ func (si *SchedulerIntegration) isTaskTreeGated(ctx context.Context, runID, task return true } -// launchAgent starts the agent via the orchestrator. Returns false if the -// run could not be launched — either because it is structurally -// unlaunchable (no task_id, no task starter wired) or because the adapter -// invocation itself errored — and the caller should abort; the failure is -// already handled (run marked failed, checkout released, WO-35). +// launchAgent starts the agent through the task starter or the run-owned +// runtime launcher. Returns false if the run could not be launched; the +// failure is already handled by the selected launch path. func (si *SchedulerIntegration) launchAgent( ctx context.Context, run *models.Run, agent *models.AgentInstance, taskID, executorType string, @@ -656,15 +657,33 @@ func (si *SchedulerIntegration) launchAgent( runID := run.ID if taskID == "" { - si.logger.Error("cannot launch taskless run", - zap.String("run_id", runID), - zap.String("agent", agent.Name), - zap.String("reason", run.Reason), + if handled, launched := si.tryRoutingDispatch(ctx, run, agent, taskID, launch); handled { + return launched + } + if si.svc.runSessionLauncher == nil { + si.logger.Error("cannot launch taskless run: no run-session launcher configured", + zap.String("run_id", runID), zap.String("agent", agent.Name)) + si.failTasklessRun(ctx, run, agent, + "scheduler cannot launch a taskless run: no run-session launcher is configured") + return false + } + si.logger.Info("launching taskless agent for run", + zap.String("run_id", runID), zap.String("agent", agent.Name), zap.String("executor_type", executorType), - ) - si.failTasklessRun(ctx, run, agent, - "scheduler cannot launch a taskless run: run payload carries no task_id") - return false + zap.Int("prompt_len", len(launch.Prompt))) + var route *RouteOverride + result, err := si.svc.runSessionLauncher.StartRunSession(ctx, run, agent, launch, route) + if err != nil { + si.logger.Error("taskless agent launch failed", zap.String("run_id", runID), zap.Error(err)) + si.svc.AppendRunEvent(ctx, runID, "error", "error", map[string]interface{}{ + "phase": "adapter.invoke", "error_message": err.Error(), + }) + si.failTasklessRun(ctx, run, agent, err.Error()) + return false + } + IncLoopLaunch(agent.WorkspaceID) + si.persistLaunchedSession(ctx, runID, agent.WorkspaceID, result.SessionID) + return true } if si.svc.taskStarter == nil { si.logger.Error("cannot launch run: no task starter configured", @@ -789,7 +808,8 @@ func (si *SchedulerIntegration) persistLaunchedSession( } // failTasklessRun terminally fails a run that launchAgent determined has -// no task_id. This is a scheduler capability gap, not an agent failure, so +// no task_id and no run-session launcher. This is a scheduler wiring failure, +// not an agent failure, so // it must NOT go through HandleAgentFailure's consecutive-failure/ // auto-pause accounting. The pre-installed "Coordinator heartbeat" routine // is taskless by design and fires every 5 minutes, so counting these toward @@ -798,15 +818,9 @@ func (si *SchedulerIntegration) persistLaunchedSession( // event-driven ones that work today — was silently finished with no // launch (WO-35 Review round 1, Finding 1). // -// SCOPE-1 decision: docs/specs/office/requirements/scheduler.md and -// docs/specs/office/system-design/scheduler-01.md say a -// lightweight (taskless) routine fire is meant to produce a real agent -// run. This card does not implement that launch: doing so requires a -// taskless session seam (task_sessions.task_id is currently NOT NULL), -// which is a schema/contract change — New Feature Dev work, not a small -// contained fix. What this card ships instead is the honest interim -// state: fail loud and visible rather than silently reporting success -// with no agent ever launched (the card's original SYMPTOM). +// The production composition wires the run-session launcher. This fallback +// remains for incomplete compositions and tests so a taskless run still fails +// loud and visible rather than silently reporting success with no agent. // // A bare MarkRunFailed still leaves a visible failed row (surfaces via // ListFailedRunsForInbox, since the agent is never auto-paused here), so diff --git a/apps/backend/internal/office/service/scheduler_taskless_launch_test.go b/apps/backend/internal/office/service/scheduler_taskless_launch_test.go index 0e40a18e98..9a52af231d 100644 --- a/apps/backend/internal/office/service/scheduler_taskless_launch_test.go +++ b/apps/backend/internal/office/service/scheduler_taskless_launch_test.go @@ -22,10 +22,9 @@ import ( // as a success. That is exactly the shape of the card's "323 consecutive // successful runs, zero agent sessions" measurement. // -// The scheduler cannot currently launch a taskless run at all (that -// requires a taskless session seam — task_sessions.task_id is NOT NULL — -// which is a follow-up feature, not part of this card). So the correct -// terminal state today is a loud, immediate failure, not silent success. +// This isolated service test intentionally leaves the run-session launcher +// unwired, so the scheduler must retain its fail-closed behavior rather than +// reporting a successful launch that never happened. func TestSchedulerTick_TasklessRunFailsInsteadOfFinishing(t *testing.T) { mock := &mockTaskStarter{} svc := newTestService(t, service.ServiceOptions{TaskStarter: mock}) @@ -276,9 +275,8 @@ func TestSchedulerTick_TaskBoundRunStillLaunches(t *testing.T) { } // TestSchedulerTick_TasklessRunsDoNotAutoPauseAgent is the WO-35 Review -// round 1 regression test. A taskless run is a scheduler capability gap -// (the taskless-launch seam does not exist yet — see failTasklessRun's -// SCOPE-1 decision comment in scheduler_integration.go), not an agent +// round 1 regression test. A taskless run without a configured launcher is a +// wiring failure, not an agent // failure, so failing it must not touch the agent's consecutive-failure // counter. The pre-installed "Coordinator heartbeat" routine is taskless // by design and fires every 5 minutes diff --git a/apps/backend/internal/office/service/service.go b/apps/backend/internal/office/service/service.go index 250b95bfe5..e769e163b4 100644 --- a/apps/backend/internal/office/service/service.go +++ b/apps/backend/internal/office/service/service.go @@ -77,6 +77,39 @@ type TaskStarterWithLaunchContextSession interface { launch LaunchContext) (sessionID string, err error) } +// RunSessionLaunch is the durable identity returned by a run-owned runtime +// launch. SessionID is the Office run-session ID, not a task_sessions row. +type RunSessionLaunch struct { + SessionID string + ExecutionID string + ExecutionProfileID string + Adapter string + Model string + ACPSessionID string +} + +// RunSessionLauncher starts a run-owned execution without creating a task or +// task session. A route is optional; when present it is the concrete provider +// selection chosen by the routing dispatcher. +type RunSessionLauncher interface { + StartRunSession(ctx context.Context, run *models.Run, agent *models.AgentInstance, + launch LaunchContext, route *RouteOverride) (RunSessionLaunch, error) +} + +type RunSessionReconciler interface { + ReconcileRunSessions(ctx context.Context) error +} + +// ReconcileRunSessions delegates startup recovery to the backend runtime +// composition when taskless sessions are enabled. +func (s *Service) ReconcileRunSessions(ctx context.Context) error { + reconciler, ok := s.runSessionLauncher.(RunSessionReconciler) + if !ok { + return nil + } + return reconciler.ReconcileRunSessions(ctx) +} + // LaunchContext mirrors scheduler.LaunchContext so the office.service // package can carry the Office-built launch context (prompt, env, // workflow step, attachments, plan-mode, profile) into the routing @@ -97,6 +130,18 @@ type LaunchContext struct { AdditionalSkillSlugs []string } +// RouteOverride carries the provider-specific execution selection into either +// the task starter or the run-session launcher. +type RouteOverride struct { + ExecutionProfileID string + ProviderID string + Model string + Tier string + Mode string + Flags []string + Env map[string]string +} + // RoutingDispatcher is the seam the office scheduler integration uses to // hand a run off to the provider-routing dispatcher. Implemented by the // scheduler.SchedulerService when routing is wired. A nil dispatcher @@ -131,6 +176,13 @@ type TaskCanceller interface { CancelTaskExecution(ctx context.Context, taskID string, reason string, force bool) error } +// RunExecutionStopper stops a run-owned shared-runtime execution by its +// durable execution identity. Workspace deletion uses this exact identity +// before removing the Office rows that make the process attributable. +type RunExecutionStopper interface { + Stop(ctx context.Context, executionID string, reason string) error +} + // TaskWorkspaceService owns workspace/task rows outside the office schema. type TaskWorkspaceService interface { GetWorkspace(ctx context.Context, id string) (*taskmodels.Workspace, error) @@ -304,6 +356,7 @@ type ServiceOptions struct { EventBus bus.EventBus TaskStarter TaskStarter TaskCanceller TaskCanceller + RunExecutionStopper RunExecutionStopper TaskWorkspace TaskWorkspaceService TaskTreeDeleter TaskTreeDeleter WorkspaceGroupCleaner WorkspaceGroupCleaner @@ -328,6 +381,8 @@ type Service struct { agentTypeResolver AgentTypeResolver projectSkillDirResolver ProjectSkillDirResolver taskStarter TaskStarter + runSessionLauncher RunSessionLauncher + runStopper RunExecutionStopper routingDispatcher RoutingDispatcher taskCanceller TaskCanceller taskWorkspace TaskWorkspaceService @@ -435,6 +490,19 @@ func (s *Service) SetAgentTokenMinter(minter AgentTokenMinter) { s.agentTokenMinter = minter } +// SetRunSessionLauncher wires the shared-runtime adapter for taskless Office +// runs. Keeping this optional preserves isolated service tests and keeps the +// scheduler fail-closed when startup composition is incomplete. +func (s *Service) SetRunSessionLauncher(launcher RunSessionLauncher) { + s.runSessionLauncher = launcher +} + +// RunSessionLauncherHandle returns the shared-runtime taskless launch seam so +// the provider-routing scheduler can use the same implementation. +func (s *Service) RunSessionLauncherHandle() RunSessionLauncher { + return s.runSessionLauncher +} + // SetRoutingDispatcher wires the provider-routing dispatcher (the office // scheduler.SchedulerService). When nil, the legacy concrete-profile // launch path runs unchanged. @@ -481,8 +549,8 @@ func NewService(opts ServiceOptions) *Service { log := opts.Logger.WithFields(zap.String("component", "office-service")) if opts.TaskStarter == nil { log.Warn("office service constructed without a TaskStarter; " + - "every run the scheduler claims will fail immediately " + - "instead of launching an agent (WO-35)") + "task-bound runs the scheduler claims will fail immediately " + + "instead of launching an agent") } svc := &Service{ repo: opts.Repo, @@ -493,6 +561,7 @@ func NewService(opts ServiceOptions) *Service { eb: opts.EventBus, taskStarter: opts.TaskStarter, taskCanceller: opts.TaskCanceller, + runStopper: opts.RunExecutionStopper, taskWorkspace: opts.TaskWorkspace, taskTreeDeleter: opts.TaskTreeDeleter, workspaceGroupCleaner: opts.WorkspaceGroupCleaner, @@ -508,6 +577,12 @@ func NewService(opts ServiceOptions) *Service { return svc } +// SetRunExecutionStopper wires the shared runtime stop seam used by +// workspace deletion for taskless Office sessions. +func (s *Service) SetRunExecutionStopper(stopper RunExecutionStopper) { + s.runStopper = stopper +} + // SetWorkspaceGroupCleaner wires the handoff cleanup service after startup // constructs the shared HandoffService instance. func (s *Service) SetWorkspaceGroupCleaner(cleaner WorkspaceGroupCleaner) { diff --git a/apps/backend/internal/office/service/skill_manifest.go b/apps/backend/internal/office/service/skill_manifest.go index 4d877fa2e5..e14363c8d9 100644 --- a/apps/backend/internal/office/service/skill_manifest.go +++ b/apps/backend/internal/office/service/skill_manifest.go @@ -26,6 +26,7 @@ type SkillManifest struct { // ManifestSkill represents a single skill's content. type ManifestSkill struct { ID string + DisplayName string Slug string Content string // SKILL.md content Version string @@ -97,6 +98,7 @@ func (si *SchedulerIntegration) appendConfiguredSkill( } manifest.Skills = append(manifest.Skills, ManifestSkill{ ID: skill.ID, + DisplayName: skill.Name, Slug: skill.Slug, Content: skill.Content, Version: skill.Version, diff --git a/apps/backend/internal/office/service/taskless_completion.go b/apps/backend/internal/office/service/taskless_completion.go new file mode 100644 index 0000000000..9846ae0464 --- /dev/null +++ b/apps/backend/internal/office/service/taskless_completion.go @@ -0,0 +1,36 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + runtimeapi "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/events/bus" +) + +// Taskless turns have no task orchestrator to stop the execution after AgentReady. +// Keep the run claimed until its exact execution has stopped successfully. +func (s *Service) handleTasklessAgentReady(ctx context.Context, event *bus.Event) error { + data, err := decodeEventData[AgentLifecycleData](event) + if err != nil { + return err + } + if data.OwnerKind != string(runtimeapi.ExecutionOwnerRun) || data.TaskID != "" || + !exactRunSessionEvent(data) || data.AgentExecutionID == "" { + return nil + } + if _, err := s.resolveLifecycleRun(ctx, *data); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + if s.runStopper == nil { + return errors.New("taskless completion requires a runtime stopper") + } + if err := s.runStopper.Stop(ctx, data.AgentExecutionID, "office_turn_complete"); err != nil && !runtimeapi.IsNotFound(err) { + return fmt.Errorf("stop completed taskless execution: %w", err) + } + return s.handleTasklessAgentCompleted(ctx, data) +} diff --git a/apps/backend/internal/office/service/taskless_lifecycle_test.go b/apps/backend/internal/office/service/taskless_lifecycle_test.go new file mode 100644 index 0000000000..f9784fd9e2 --- /dev/null +++ b/apps/backend/internal/office/service/taskless_lifecycle_test.go @@ -0,0 +1,328 @@ +package service_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/office/models" + "github.com/kandev/kandev/internal/office/service" +) + +type tasklessTestLauncher struct { + svc *service.Service + calls []service.LaunchContext + next int +} + +func (l *tasklessTestLauncher) StartRunSession( + ctx context.Context, run *models.Run, agent *models.AgentInstance, + launch service.LaunchContext, _ *service.RouteOverride, +) (service.RunSessionLaunch, error) { + l.calls = append(l.calls, launch) + l.next++ + id := fmt.Sprintf("run-session-%d", l.next) + now := time.Now().UTC() + session := &models.RunSession{ + ID: id, WorkspaceID: agent.WorkspaceID, AgentProfileID: agent.ID, + RunID: run.ID, Attempt: l.next, State: models.RunSessionStatePreparing, + CreatedAt: now, Version: 1, + } + reserved, err := l.svc.RepoForTest().ReserveRunSession(ctx, session) + if err != nil || !reserved { + return service.RunSessionLaunch{}, fmt.Errorf("reserve test run session: reserved=%v err=%w", reserved, err) + } + if _, err := l.svc.RepoForTest().BindRunSessionExecution( + ctx, id, "execution-"+id, launch.ProfileID, "test-adapter", "test-model", "acp-"+id, + ); err != nil { + return service.RunSessionLaunch{}, err + } + if _, err := l.svc.RepoForTest().MarkRunSessionStarted( + ctx, id, "execution-"+id, launch.ProfileID, "test-adapter", "test-model", "acp-"+id, + ); err != nil { + return service.RunSessionLaunch{}, err + } + return service.RunSessionLaunch{ + SessionID: id, ExecutionID: "execution-" + id, + ExecutionProfileID: launch.ProfileID, Adapter: "test-adapter", Model: "test-model", + ACPSessionID: "acp-" + id, + }, nil +} + +func TestTasklessRoutineRunLaunchesRunSessionAndCompletes(t *testing.T) { + svc, eb := newTestServiceWithBus(t) + ctx := context.Background() + launcher := &tasklessTestLauncher{svc: svc} + svc.SetRunSessionLauncher(launcher) + agent := &models.AgentInstance{ + ID: "taskless-agent", WorkspaceID: "ws-1", Name: "taskless-agent", + Role: models.AgentRoleCEO, Status: models.AgentStatusIdle, + ExecutorPreference: `{"type":"local_pc"}`, + } + if err := svc.CreateAgentInstance(ctx, agent); err != nil { + t.Fatalf("create agent: %v", err) + } + if _, err := svc.QueueRun(ctx, agent.ID, service.RunReasonRoutineTrigger, `{}`, "taskless-test"); err != nil { + t.Fatalf("queue: %v", err) + } + runs, err := svc.ListRuns(ctx, agent.WorkspaceID) + if err != nil || len(runs) != 1 { + t.Fatalf("list queued run: runs=%#v err=%v", runs, err) + } + run := runs[0] + service.RunSchedulerTick(svc, ctx) + if len(launcher.calls) != 1 || launcher.calls[0].Prompt == "" { + t.Fatalf("taskless launch = %#v, want one real prompt", launcher.calls) + } + session, err := svc.RepoForTest().GetRunSession(ctx, "run-session-1") + if err != nil || session == nil || session.State != models.RunSessionStateRunning { + t.Fatalf("run session after launch = %#v, err=%v", session, err) + } + if runs, err := svc.ListRuns(ctx, agent.WorkspaceID); err != nil || len(runs) != 1 || runs[0].SessionID != "run-session-1" { + t.Fatalf("run session projection = runs=%#v err=%v, want run-session-1", runs, err) + } + if err := eb.Publish(ctx, events.AgentCompleted, bus.NewEvent(events.AgentCompleted, "test", lifecycle.AgentEventPayload{ + AgentExecutionID: "execution-run-session-1", AgentID: "test-adapter", AgentProfileID: agent.ID, + RunID: run.ID, RunSessionID: "run-session-1", RunAttempt: 1, OwnerKind: lifecycle.ExecutionOwnerRun, + WorkspaceID: agent.WorkspaceID, Status: "COMPLETED", + })); err != nil { + t.Fatalf("publish completion: %v", err) + } + finished, err := svc.RepoForTest().GetRunSession(ctx, "run-session-1") + if err != nil || finished == nil || finished.State != models.RunSessionStateFinished { + t.Fatalf("run session after completion = %#v, err=%v", finished, err) + } + runs, err = svc.ListRuns(ctx, agent.WorkspaceID) + if err != nil || len(runs) != 1 || runs[0].Status != service.RunStatusFinished { + t.Fatalf("runs after completion = %#v, err=%v", runs, err) + } + var tasks int + if err := svc.RepoForTest().ReaderDB().Get(&tasks, `SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`, agent.WorkspaceID); err != nil { + t.Fatalf("count task rows: %v", err) + } + if tasks != 0 { + t.Fatalf("taskless run created %d task rows", tasks) + } +} + +func TestTasklessLateCompletionDoesNotFinishSuccessor(t *testing.T) { + svc, eb := newTestServiceWithBus(t) + ctx := context.Background() + launcher := &tasklessTestLauncher{svc: svc} + svc.SetRunSessionLauncher(launcher) + agent := &models.AgentInstance{ + ID: "taskless-successor-agent", WorkspaceID: "ws-1", Name: "taskless-successor-agent", + Role: models.AgentRoleCEO, Status: models.AgentStatusIdle, + ExecutorPreference: `{"type":"local_pc"}`, + } + if err := svc.CreateAgentInstance(ctx, agent); err != nil { + t.Fatalf("create agent: %v", err) + } + if _, err := svc.QueueRun(ctx, agent.ID, service.RunReasonRoutineTrigger, `{}`, "first"); err != nil { + t.Fatalf("queue first: %v", err) + } + runs, err := svc.ListRuns(ctx, agent.WorkspaceID) + if err != nil || len(runs) != 1 { + t.Fatalf("list first run: runs=%#v err=%v", runs, err) + } + first := runs[0] + service.RunSchedulerTick(svc, ctx) + if _, err := svc.FinishRun(ctx, first.ID, service.RunOutcomeProcessed); err != nil { + t.Fatalf("finish first: %v", err) + } + if _, err := svc.QueueRun(ctx, agent.ID, service.RunReasonRoutineTrigger, `{}`, "second"); err != nil { + t.Fatalf("queue second: %v", err) + } + runs, err = svc.ListRuns(ctx, agent.WorkspaceID) + if err != nil || len(runs) != 2 { + t.Fatalf("list successor run: runs=%#v err=%v", runs, err) + } + second := runs[0] + if second.ID == first.ID { + second = runs[1] + } + if _, err := svc.RepoForTest().MarkAgentWorking(ctx, agent.ID, second.ID); err != nil { + t.Fatalf("mark successor working: %v", err) + } + if err := eb.Publish(ctx, events.AgentCompleted, bus.NewEvent(events.AgentCompleted, "test", lifecycle.AgentEventPayload{ + AgentExecutionID: "execution-run-session-1", AgentID: "test-adapter", AgentProfileID: agent.ID, + RunID: first.ID, RunSessionID: "run-session-1", RunAttempt: 1, OwnerKind: lifecycle.ExecutionOwnerRun, + WorkspaceID: agent.WorkspaceID, Status: "COMPLETED", + })); err != nil { + t.Fatalf("publish stale completion: %v", err) + } + var workingRunID string + if err := svc.RepoForTest().ReaderDB().Get(&workingRunID, `SELECT working_run_id FROM agent_profiles WHERE id = ?`, agent.ID); err != nil { + t.Fatalf("read working run: %v", err) + } + if workingRunID != second.ID { + t.Fatalf("successor working run = %q, want %q", workingRunID, second.ID) + } +} + +func TestTasklessUsageDuplicate(t *testing.T) { + svc, eb := newTestServiceWithBus(t) + ctx := context.Background() + launcher := &tasklessTestLauncher{svc: svc} + svc.SetRunSessionLauncher(launcher) + agent := &models.AgentInstance{ + ID: "taskless-usage-agent", WorkspaceID: "ws-1", Name: "taskless-usage-agent", + Role: models.AgentRoleCEO, Status: models.AgentStatusIdle, + ExecutorPreference: `{"type":"local_pc"}`, + } + if err := svc.CreateAgentInstance(ctx, agent); err != nil { + t.Fatalf("create agent: %v", err) + } + if _, err := svc.QueueRun(ctx, agent.ID, service.RunReasonRoutineTrigger, `{}`, "usage"); err != nil { + t.Fatalf("queue: %v", err) + } + service.RunSchedulerTick(svc, ctx) + payload := lifecycle.AgentStreamEventPayload{ + ExecutionID: "execution-run-session-1", OwnerKind: lifecycle.ExecutionOwnerRun, + WorkspaceID: agent.WorkspaceID, RunID: "", RunSessionID: "run-session-1", RunAttempt: 1, + AgentProfileID: agent.ID, AgentType: "claude-acp", + Data: &lifecycle.AgentStreamEventData{PromptGeneration: 1, TurnID: "turn-1", CurrentModelID: "test-model", Usage: &streams.PromptUsage{InputTokens: 7, OutputTokens: 3, OutputTokensPresent: true, TotalTokens: 10, ProviderReportedCostSubcents: 123, ProviderReportedCostPresent: true}}, + } + stale := payload + stale.RunAttempt++ + if err := eb.Publish(ctx, events.BuildAgentStreamSubject("run-session-1"), bus.NewEvent(events.AgentStream, "test", stale)); err != nil { + t.Fatal(err) + } + var staleCosts int + if err := svc.RepoForTest().ReaderDB().Get(&staleCosts, `SELECT COUNT(*) FROM office_cost_events`); err != nil { + t.Fatal(err) + } + if staleCosts != 0 { + t.Fatalf("mismatched attempt recorded %d cost events", staleCosts) + } + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + var wirePayload map[string]interface{} + if err := json.Unmarshal(encoded, &wirePayload); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := eb.Publish(ctx, events.BuildAgentStreamSubject("run-session-1"), bus.NewEvent(events.AgentStream, "test", wirePayload)); err != nil { + t.Fatalf("publish usage %d: %v", i, err) + } + } + var costCount int + if err := svc.RepoForTest().ReaderDB().Get(&costCount, + `SELECT COUNT(*) FROM office_cost_events WHERE session_id = ?`, "run-session-1"); err != nil { + t.Fatalf("count costs: %v", err) + } + if costCount != 1 { + t.Fatalf("cost events = %d, want one deduplicated row", costCount) + } + costEvents, err := svc.RepoForTest().ListCostEvents(ctx, agent.WorkspaceID) + if err != nil || len(costEvents) != 1 { + t.Fatalf("workspace costs = %v, err=%v", costEvents, err) + } + for name, read := range map[string]func(context.Context, string) ([]*models.CostBreakdown, error){ + "agent": svc.RepoForTest().GetCostsByAgent, "model": svc.RepoForTest().GetCostsByModel, + "provider": svc.RepoForTest().GetCostsByProvider, "project": svc.RepoForTest().GetCostsByProject, + } { + got, err := read(ctx, agent.WorkspaceID) + if err != nil || len(got) != 1 || got[0].Count != 1 { + t.Errorf("%s costs = %#v, err=%v", name, got, err) + } + } + total, err := svc.RepoForTest().SumCosts(ctx, agent.WorkspaceID) + if err != nil || total != 123 { + t.Errorf("total = %d, err=%v", total, err) + } + total, err = svc.RepoForTest().SumCostsSince(ctx, agent.WorkspaceID, time.Now().Add(-time.Hour)) + if err != nil || total != 123 { + t.Errorf("period total = %d, err=%v", total, err) + } + runs, err := svc.ListRuns(ctx, agent.WorkspaceID) + if err != nil { + t.Fatal(err) + } + _, rollup, err := svc.RepoForTest().GetRunWithCosts(ctx, runs[0].ID) + if err != nil || rollup.CostSubcents != 123 || rollup.InputTokens != 7 { + t.Errorf("rollup = %#v, err=%v", rollup, err) + } + +} + +func TestTasklessRoutineReadyCompletesRun(t *testing.T) { + svc, eb := newTestServiceWithBus(t) + ctx := context.Background() + stopper := &fakeRunExecutionStopper{} + svc.SetRunExecutionStopper(stopper) + launcher := &tasklessTestLauncher{svc: svc} + svc.SetRunSessionLauncher(launcher) + agent := &models.AgentInstance{ + ID: "taskless-agent", WorkspaceID: "ws-1", Name: "taskless-agent", + Role: models.AgentRoleCEO, Status: models.AgentStatusIdle, + ExecutorPreference: `{"type":"local_pc"}`, + } + if err := svc.CreateAgentInstance(ctx, agent); err != nil { + t.Fatalf("create agent: %v", err) + } + if _, err := svc.QueueRun(ctx, agent.ID, service.RunReasonRoutineTrigger, `{}`, "taskless-test"); err != nil { + t.Fatalf("queue: %v", err) + } + runs, err := svc.ListRuns(ctx, agent.WorkspaceID) + if err != nil || len(runs) != 1 { + t.Fatalf("list queued run: runs=%#v err=%v", runs, err) + } + run := runs[0] + service.RunSchedulerTick(svc, ctx) + if len(launcher.calls) != 1 || launcher.calls[0].Prompt == "" { + t.Fatalf("taskless launch = %#v, want one real prompt", launcher.calls) + } + session, err := svc.RepoForTest().GetRunSession(ctx, "run-session-1") + if err != nil || session == nil || session.State != models.RunSessionStateRunning { + t.Fatalf("run session after launch = %#v, err=%v", session, err) + } + if runs, err := svc.ListRuns(ctx, agent.WorkspaceID); err != nil || len(runs) != 1 || runs[0].SessionID != "run-session-1" { + t.Fatalf("run session projection = runs=%#v err=%v, want run-session-1", runs, err) + } + stopper.err = errors.New("stop failed") + _ = eb.Publish(ctx, events.AgentReady, bus.NewEvent(events.AgentReady, "test", lifecycle.AgentEventPayload{ + AgentExecutionID: "execution-run-session-1", AgentProfileID: agent.ID, + RunID: run.ID, RunSessionID: "run-session-1", RunAttempt: 1, OwnerKind: lifecycle.ExecutionOwnerRun, WorkspaceID: agent.WorkspaceID, + })) + stillRunning, err := svc.RepoForTest().GetRunSession(ctx, "run-session-1") + if err != nil || stillRunning.State != models.RunSessionStateRunning { + t.Fatalf("failed stop settled session: %#v, %v", stillRunning, err) + } + stopper.err = nil + stopper.executionIDs = nil + if err := eb.Publish(ctx, events.AgentReady, bus.NewEvent(events.AgentReady, "test", lifecycle.AgentEventPayload{ + AgentExecutionID: "execution-run-session-1", AgentID: "test-adapter", AgentProfileID: agent.ID, + RunID: run.ID, RunSessionID: "run-session-1", RunAttempt: 1, OwnerKind: lifecycle.ExecutionOwnerRun, + WorkspaceID: agent.WorkspaceID, Status: "READY", + })); err != nil { + t.Fatalf("publish completion: %v", err) + } + finished, err := svc.RepoForTest().GetRunSession(ctx, "run-session-1") + if err != nil || finished == nil || finished.State != models.RunSessionStateFinished { + t.Fatalf("run session after completion = %#v, err=%v", finished, err) + } + runs, err = svc.ListRuns(ctx, agent.WorkspaceID) + if err != nil || len(runs) != 1 || runs[0].Status != service.RunStatusFinished { + t.Fatalf("runs after completion = %#v, err=%v", runs, err) + } + var tasks int + if err := svc.RepoForTest().ReaderDB().Get(&tasks, `SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`, agent.WorkspaceID); err != nil { + t.Fatalf("count task rows: %v", err) + } + if tasks != 0 { + t.Fatalf("taskless run created %d task rows", tasks) + } + if len(stopper.executionIDs) != 1 || stopper.executionIDs[0] != "execution-run-session-1" { + t.Fatalf("stopped executions = %v", stopper.executionIDs) + } +} diff --git a/apps/backend/internal/office/service/taskless_usage.go b/apps/backend/internal/office/service/taskless_usage.go new file mode 100644 index 0000000000..fa68a9fa48 --- /dev/null +++ b/apps/backend/internal/office/service/taskless_usage.go @@ -0,0 +1,22 @@ +package service + +import ( + "context" + "github.com/kandev/kandev/internal/office/repository/sqlite" +) + +// Usage can arrive after completion, but must still identify the exact durable +// attempt. Read attribution from its owner rather than mutable agent state. +func (s *Service) tasklessUsageFields(ctx context.Context, data *PromptUsageData) (*sqlite.TaskExecutionFields, error) { + session, err := s.repo.GetRunSession(ctx, data.RunSessionID) + if err != nil { + return nil, err + } + if session == nil || session.ExecutionID == "" || session.ExecutionID != data.AgentExecutionID || + session.Attempt != data.RunAttempt || session.AgentProfileID != data.AgentProfileID || + session.WorkspaceID != data.WorkspaceID { + return nil, nil + } + data.SessionID = session.ID + return &sqlite.TaskExecutionFields{WorkspaceID: session.WorkspaceID, AssigneeAgentProfileID: session.AgentProfileID}, nil +} diff --git a/apps/backend/internal/office/service/workspace_deletion.go b/apps/backend/internal/office/service/workspace_deletion.go index 38a5676d23..fe605a643b 100644 --- a/apps/backend/internal/office/service/workspace_deletion.go +++ b/apps/backend/internal/office/service/workspace_deletion.go @@ -2,10 +2,13 @@ package service import ( "context" + "errors" "fmt" "path/filepath" "time" + runtimeapi "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/office/models" taskmodels "github.com/kandev/kandev/internal/task/models" "go.uber.org/zap" ) @@ -69,6 +72,13 @@ func (s *Service) DeleteWorkspace(ctx context.Context, workspaceID string) error s.cancelWorkspaceTasks(taskCancelCtx, tasks) cancelTaskCancel() + runCleanupCtx, cancelRunCleanup := workspaceDeletionPhaseContext(cleanupBaseCtx) + if err := s.stopWorkspaceRunSessions(runCleanupCtx, workspaceID); err != nil { + cancelRunCleanup() + return err + } + cancelRunCleanup() + if s.configSyncCleaner != nil { configSyncCleanupCtx, cancelConfigSyncCleanup := workspaceDeletionPhaseContext(cleanupBaseCtx) unlock, err := s.configSyncCleaner.PurgeForWorkspaceDeletion(configSyncCleanupCtx, workspaceID) @@ -113,6 +123,47 @@ func (s *Service) DeleteWorkspace(ctx context.Context, workspaceID string) error return nil } +// stopWorkspaceRunSessions terminates every run-owned process while its +// durable session identity is still available. Any stop or persistence error +// aborts deletion so a live process cannot outlive the rows that identify it. +func (s *Service) stopWorkspaceRunSessions(ctx context.Context, workspaceID string) error { + sessions, err := s.repo.ListLiveRunSessionsForWorkspace(ctx, workspaceID) + if err != nil { + return fmt.Errorf("list live run sessions for deletion: %w", err) + } + if len(sessions) == 0 { + return nil + } + if s.runStopper == nil { + return errors.New("stop run sessions for deletion: run execution stopper not configured") + } + var firstErr error + for _, session := range sessions { + if _, requestErr := s.repo.RequestRunSessionCancellation(ctx, session.ID); requestErr != nil { + if firstErr == nil { + firstErr = fmt.Errorf("request cancellation for run session %s: %w", session.ID, requestErr) + } + continue + } + stopErr := s.runStopper.Stop(ctx, session.ExecutionID, "workspace deleted") + switch { + case stopErr == nil: + if _, finishErr := s.repo.FinishRunSession(ctx, session.ID, models.RunSessionStateCancelled, "workspace deleted"); finishErr != nil && firstErr == nil { + firstErr = fmt.Errorf("finish cancelled run session %s: %w", session.ID, finishErr) + } + case errors.Is(stopErr, runtimeapi.ErrNotFound): + if _, finishErr := s.repo.FinishRunSession(ctx, session.ID, models.RunSessionStateInterrupted, "runtime execution not found"); finishErr != nil && firstErr == nil { + firstErr = fmt.Errorf("finish missing run session %s: %w", session.ID, finishErr) + } + default: + if firstErr == nil { + firstErr = fmt.Errorf("stop run session %s: %w", session.ID, stopErr) + } + } + } + return firstErr +} + func (s *Service) deleteWorkspaceTasks(ctx context.Context, tasks []*taskmodels.Task) error { for _, task := range tasks { if task == nil || task.ID == "" { diff --git a/apps/backend/internal/office/service/workspace_deletion_test.go b/apps/backend/internal/office/service/workspace_deletion_test.go index 71ff7e4ed5..6b06f08494 100644 --- a/apps/backend/internal/office/service/workspace_deletion_test.go +++ b/apps/backend/internal/office/service/workspace_deletion_test.go @@ -74,6 +74,75 @@ func TestDeleteWorkspaceStopsTasksDeletesDataAndConfig(t *testing.T) { } } +func TestDeleteWorkspaceStopsRunOwnedExecutionsBeforeDeletingData(t *testing.T) { + ctx := context.Background() + taskSvc := &fakeWorkspaceTaskService{ + workspace: &taskmodels.Workspace{ID: "ws-delete", Name: "default"}, + } + stopper := &fakeRunExecutionStopper{} + svc := newTestService(t, service.ServiceOptions{ + TaskWorkspace: taskSvc, + RunExecutionStopper: stopper, + }) + createTestAgent(t, svc, "ws-delete", "agent-run-delete") + now := time.Now().UTC() + svc.ExecSQL(t, `INSERT INTO runs ( + id, agent_profile_id, reason, payload, status, requested_at + ) VALUES (?, ?, 'routine', '{}', 'claimed', ?)`, "run-delete", "agent-run-delete", now) + svc.ExecSQL(t, `INSERT INTO office_run_sessions ( + id, workspace_id, agent_profile_id, run_id, attempt, state, + execution_id, created_at + ) VALUES (?, ?, ?, ?, 1, 'running', ?, ?)`, + "session-delete", "ws-delete", "agent-run-delete", "run-delete", "execution-delete", now) + + if err := svc.DeleteWorkspace(ctx, "ws-delete"); err != nil { + t.Fatalf("DeleteWorkspace: %v", err) + } + if len(stopper.executionIDs) != 1 || stopper.executionIDs[0] != "execution-delete" { + t.Fatalf("stopped executions = %#v, want execution-delete", stopper.executionIDs) + } + if taskSvc.deletedWorkspace != "ws-delete" { + t.Fatalf("deleted workspace = %q, want ws-delete", taskSvc.deletedWorkspace) + } +} + +func TestDeleteWorkspaceAbortsWhenRunExecutionStopFails(t *testing.T) { + ctx := context.Background() + taskSvc := &fakeWorkspaceTaskService{ + workspace: &taskmodels.Workspace{ID: "ws-delete", Name: "default"}, + } + stopper := &fakeRunExecutionStopper{err: errors.New("stop failed")} + svc := newTestService(t, service.ServiceOptions{ + TaskWorkspace: taskSvc, + RunExecutionStopper: stopper, + }) + createTestAgent(t, svc, "ws-delete", "agent-run-delete") + now := time.Now().UTC() + svc.ExecSQL(t, `INSERT INTO runs ( + id, agent_profile_id, reason, payload, status, requested_at + ) VALUES (?, ?, 'routine', '{}', 'claimed', ?)`, "run-delete", "agent-run-delete", now) + svc.ExecSQL(t, `INSERT INTO office_run_sessions ( + id, workspace_id, agent_profile_id, run_id, attempt, state, + execution_id, created_at + ) VALUES (?, ?, ?, ?, 1, 'running', ?, ?)`, + "session-delete", "ws-delete", "agent-run-delete", "run-delete", "execution-delete", now) + + if err := svc.DeleteWorkspace(ctx, "ws-delete"); err == nil { + t.Fatal("DeleteWorkspace returned nil after runtime stop failure") + } + if taskSvc.deletedWorkspace != "" { + t.Fatalf("workspace deleted after runtime stop failure: %q", taskSvc.deletedWorkspace) + } + var sessions int + if err := svc.RepoForTest().ReaderDB().Get(&sessions, + `SELECT COUNT(*) FROM office_run_sessions WHERE id = ?`, "session-delete"); err != nil { + t.Fatalf("count run sessions: %v", err) + } + if sessions != 1 { + t.Fatalf("run session count = %d, want 1 after aborted deletion", sessions) + } +} + func TestDeleteWorkspaceUsesTaskLifecycleDeleter(t *testing.T) { ctx := context.Background() taskSvc := &fakeWorkspaceTaskService{ @@ -384,6 +453,16 @@ type fakeTaskCanceller struct { taskIDs []string } +type fakeRunExecutionStopper struct { + executionIDs []string + err error +} + +func (f *fakeRunExecutionStopper) Stop(_ context.Context, executionID, _ string) error { + f.executionIDs = append(f.executionIDs, executionID) + return f.err +} + func (f *fakeTaskCanceller) CancelTaskExecution(_ context.Context, taskID string, _ string, _ bool) error { f.taskIDs = append(f.taskIDs, taskID) return nil diff --git a/apps/backend/internal/runs/repository/sqlite/runs.go b/apps/backend/internal/runs/repository/sqlite/runs.go index dd68498788..8109a45dea 100644 --- a/apps/backend/internal/runs/repository/sqlite/runs.go +++ b/apps/backend/internal/runs/repository/sqlite/runs.go @@ -833,9 +833,9 @@ func (r *Repository) GetRunWithCosts( COALESCE(SUM(tokens_cached_in), 0) AS cached_tokens, COALESCE(SUM(cost_subcents), 0) AS cost_subcents FROM office_cost_events - WHERE task_id != '' - AND task_id = COALESCE(json_extract(?, '$.task_id'), '') - `), run.Payload).StructScan(&rollup) + WHERE (task_id != '' AND task_id = COALESCE(json_extract(?, '$.task_id'), '')) + OR (task_id = '' AND session_id IN (SELECT id FROM office_run_sessions WHERE run_id = ?)) + `), run.Payload, run.ID).StructScan(&rollup) if err != nil { return &run, &RunCostRollup{}, nil } diff --git a/apps/web/app/office/agents/[id]/runs/components/run-header.test.tsx b/apps/web/app/office/agents/[id]/runs/components/run-header.test.tsx index a3923a1d3e..f131c97159 100644 --- a/apps/web/app/office/agents/[id]/runs/components/run-header.test.tsx +++ b/apps/web/app/office/agents/[id]/runs/components/run-header.test.tsx @@ -55,6 +55,12 @@ describe("RunHeader", () => { ); }); + it("renders the captured agent name independently from invocation details", () => { + render(); + expect(screen.getByTestId("run-agent-name").textContent).toBe("CEO"); + expect(screen.getByTestId("run-adapter").textContent).toContain("claude_local"); + }); + it("formats the duration in human-readable units", () => { render(); expect(screen.getByTestId("run-duration").textContent).toContain("30s"); diff --git a/apps/web/app/office/agents/[id]/runs/components/run-header.tsx b/apps/web/app/office/agents/[id]/runs/components/run-header.tsx index 35e9c23a77..150a171f3b 100644 --- a/apps/web/app/office/agents/[id]/runs/components/run-header.tsx +++ b/apps/web/app/office/agents/[id]/runs/components/run-header.tsx @@ -146,6 +146,11 @@ function TopRow({ run }: { run: RunDetail }) { {run.status} + {run.agent_name && ( + + {run.agent_name} + + )} {run.invocation.adapter && ( {run.invocation.adapter} diff --git a/apps/web/app/office/agents/[id]/runs/components/runtime-panel.test.tsx b/apps/web/app/office/agents/[id]/runs/components/runtime-panel.test.tsx index b97ba2fe55..0fd3cf78de 100644 --- a/apps/web/app/office/agents/[id]/runs/components/runtime-panel.test.tsx +++ b/apps/web/app/office/agents/[id]/runs/components/runtime-panel.test.tsx @@ -32,4 +32,36 @@ describe("RuntimePanel", () => { expect(screen.getByTestId("runtime-skills").textContent).toContain("skill-1"); expect(screen.getByTestId("runtime-skills").textContent).toContain("hash abc"); }); + + it("prefers captured skill labels and shows an explicit fallback when absent", () => { + render( + , + ); + + const skills = screen.getByTestId("runtime-skills"); + expect(skills.textContent).toContain("Planning"); + expect(skills.textContent).toContain("skill-renamed"); + expect(skills.textContent).toMatch(/skill unavailable/i); + }); }); diff --git a/apps/web/app/office/agents/[id]/runs/components/runtime-panel.tsx b/apps/web/app/office/agents/[id]/runs/components/runtime-panel.tsx index ed6f829de0..10f3d78e04 100644 --- a/apps/web/app/office/agents/[id]/runs/components/runtime-panel.tsx +++ b/apps/web/app/office/agents/[id]/runs/components/runtime-panel.tsx @@ -48,7 +48,8 @@ export function RuntimePanel({ runtime }: Props) {
{runtime.skills.map((skill) => (
- {skill.skill_id}{" "} + {skill.display_name || skill.slug || t("office:skillUnavailable")}{" "} + ({skill.skill_id}){" "} {skill.version && v{skill.version}} {skill.content_hash && ( diff --git a/apps/web/app/office/components/office-shell.tsx b/apps/web/app/office/components/office-shell.tsx index c8aa1bb8cf..431fcd91ad 100644 --- a/apps/web/app/office/components/office-shell.tsx +++ b/apps/web/app/office/components/office-shell.tsx @@ -12,7 +12,13 @@ import { useOfficeTopbarChrome, } from "@/app/office/components/office-topbar-context"; import { usePathname } from "@/lib/routing/client-router"; -import { WorkspacePauseBanner } from "@/app/office/components/workspace-pause-banner"; +import { useAppStore } from "@/components/state-provider"; +import { useWorkspacePause } from "@/hooks/domains/office/use-workspace-pause"; +import { + WorkspacePauseState, + WorkspacePauseTopbarActions, + type WorkspacePauseViewProps, +} from "@/app/office/components/workspace-pause-banner"; // Route -> catalog key, not route -> title. The map is module scope, so a `t()` // here would resolve once at import and freeze at the boot locale; the keys are // resolved at render below. The route paths are URLs, not copy. @@ -114,6 +120,12 @@ function OfficeShellChrome({ children, routePath }: OfficeShellProps) { const { t } = useTranslation(); const pathname = usePathname(); const chrome = useOfficeTopbarChrome(); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const pauseState = useWorkspacePause(activeWorkspaceId); + const pauseView: WorkspacePauseViewProps = { + activeWorkspaceId, + ...pauseState, + }; const titleKey = resolveTitleKey(pathname); const title = chrome?.title ?? (titleKey ? t(titleKey) : ""); @@ -124,7 +136,12 @@ function OfficeShellChrome({ children, routePath }: OfficeShellProps) { icon={chrome?.icon} parents={chrome?.parents} leftActions={chrome?.leftActions} - actions={chrome?.actions} + actions={ + <> + + {chrome?.actions} + + } topbarTestId="office-topbar" className="gap-2 bg-background px-4" pageNav={(onClose) => } @@ -139,7 +156,7 @@ function OfficeShellChrome({ children, routePath }: OfficeShellProps) { Office route, so mounting here (rather than per-page) is what keeps the indicator and pause control present regardless of which page the operator is on. */} - + {/* `data-office-route` stamps the RESOLVED route onto the outlet, and is the render anchor the pseudo-coverage oracle waits on for every `office — …` screen (e2e/tests/i18n/pseudo-coverage.spec.ts). diff --git a/apps/web/app/office/components/workspace-pause-banner.test.tsx b/apps/web/app/office/components/workspace-pause-banner.test.tsx new file mode 100644 index 0000000000..c1a558078c --- /dev/null +++ b/apps/web/app/office/components/workspace-pause-banner.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, fireEvent, render, screen, within, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WorkspacePauseViewProps } from "./workspace-pause-banner"; +import { WorkspacePauseState, WorkspacePauseTopbarActions } from "./workspace-pause-banner"; + +afterEach(() => { + cleanup(); +}); + +function view(overrides: Partial = {}): WorkspacePauseViewProps { + const action = vi.fn().mockResolvedValue({ ok: true }); + return { + activeWorkspaceId: "ws-1", + record: null, + status: "known", + refresh: action, + pause: action, + retryPause: action, + resume: action, + sweep: null, + isMutating: false, + ...overrides, + }; +} + +describe("WorkspacePauseTopbarActions", () => { + it("renders desktop actions and a mobile workspace-actions entry point", () => { + render(); + + expect(screen.getByTestId("office-workspace-topbar-actions")).toBeTruthy(); + expect(screen.getByTestId("office-workspace-actions-trigger")).toBeTruthy(); + }); +}); + +describe("WorkspacePauseState", () => { + it("keeps the paused banner informational and leaves controls in the topbar", () => { + render( + , + ); + + expect(screen.getByTestId("office-workspace-paused-banner")).toBeTruthy(); + expect(screen.queryByTestId("office-pause-workspace-button")).toBeNull(); + expect(screen.queryByTestId("office-resume-workspace-button")).toBeNull(); + }); +}); + +it.each([false, true])( + "keeps mobile confirmation mounted after drawer close (paused=%s)", + async (paused) => { + const props = view( + paused + ? { + record: { + id: "p", + reason: "maintenance", + createdBy: "user", + createdByKind: "user", + createdAt: "2026-09-17T00:00:00Z", + }, + } + : {}, + ); + render(); + fireEvent.click(screen.getByTestId("office-workspace-actions-trigger")); + const drawer = screen.getByTestId("office-workspace-actions-drawer"); + const action = paused ? "resume" : "pause"; + fireEvent.click(within(drawer).getByTestId(`office-${action}-workspace-button`)); + fireEvent.animationEnd(drawer); + expect(screen.queryByTestId(`office-${action}-workspace-dialog`)).not.toBeNull(); + if (!paused) + fireEvent.change(screen.getByTestId("office-pause-reason-input"), { + target: { value: "maintenance" }, + }); + fireEvent.click(screen.getByTestId(`office-${action}-confirm-button`)); + await waitFor(() => expect(paused ? props.resume : props.pause).toHaveBeenCalled()); + }, +); diff --git a/apps/web/app/office/components/workspace-pause-banner.tsx b/apps/web/app/office/components/workspace-pause-banner.tsx index 9aa3d39bc4..3b3786dbce 100644 --- a/apps/web/app/office/components/workspace-pause-banner.tsx +++ b/apps/web/app/office/components/workspace-pause-banner.tsx @@ -1,9 +1,17 @@ "use client"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react"; +import { + IconAdjustmentsHorizontal, + IconAlertTriangle, + IconRefresh, + IconPlayerPause, + IconPlayerPlay, +} from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from "@kandev/ui/drawer"; import { useAppStore } from "@/components/state-provider"; import { useWorkspacePause, @@ -39,19 +47,27 @@ function RefreshPauseStateButton({ // time, plus a resume control. Rendered whenever a record is present, even // while `status` is `unknown` (marked stale) — a pause already read stays on // screen per the design's "Frontend state" GET-failure rule. +export type WorkspacePauseViewProps = { + activeWorkspaceId: string | null; + record: WorkspacePauseRecord | null; + status: ReturnType["status"]; + refresh: () => Promise; + pause: UseWorkspacePauseResult["pause"]; + retryPause: UseWorkspacePauseResult["retryPause"]; + resume: UseWorkspacePauseResult["resume"]; + sweep: UseWorkspacePauseResult["sweep"]; + isMutating: boolean; +}; + function PausedBanner({ record, stale, - onRefresh, - onResume, sweep, onRetryPause, isMutating, }: { record: WorkspacePauseRecord; stale: boolean; - onRefresh: () => Promise; - onResume: UseWorkspacePauseResult["resume"]; sweep: UseWorkspacePauseResult["sweep"]; onRetryPause: UseWorkspacePauseResult["retryPause"]; isMutating: boolean; @@ -94,10 +110,6 @@ function PausedBanner({
)}
-
- - -
); } @@ -105,13 +117,7 @@ function PausedBanner({ // AC-OFFICE-KILL-SWITCH-006.4's "Frontend state" unknown-with-no-record case: // the absence of a banner must never be read as "running", so an explicit // affordance is shown instead. The pause control stays reachable here too. -function PauseStateUnavailableBar({ - onRefresh, - onPause, -}: { - onRefresh: () => Promise; - onPause: UseWorkspacePauseResult["pause"]; -}) { +function PauseStateUnavailableBar() { const { t } = useTranslation(); return (
{t("office:pauseStateUnavailable")} -
- - -
); } -// Not paused, and pause state is known: only the always-reachable pause -// control plus a refresh, per AC-OFFICE-KILL-SWITCH-006.12/-006.13. No full -// banner — a persistent indicator is only required while paused. -function RunningControlBar({ - onRefresh, - onPause, -}: { - onRefresh: () => Promise; - onPause: UseWorkspacePauseResult["pause"]; -}) { - return ( -
- - -
+function WorkspacePauseActions({ view }: { view: WorkspacePauseViewProps }) { + const { t } = useTranslation(); + const [drawerOpen, setDrawerOpen] = useState(false); + if (!view.activeWorkspaceId) return null; + const renderActions = (onOpen: () => void) => { + const controls = ( +
+ + +
+ ); + return ( + <> +
+ {controls} +
+
+ + + + + + + {t("office:workspaceActions")} + +
{controls}
+
+
+
+ + ); + }; + return view.record ? ( + + ) : ( + ); } +export function WorkspacePauseTopbarActions({ view }: { view: WorkspacePauseViewProps }) { + return ; +} + +export function WorkspacePauseState({ view }: { view: WorkspacePauseViewProps }) { + if (!view.activeWorkspaceId) return null; + if (view.record) { + return ( + + ); + } + if (view.status === "unknown") return ; + return null; +} + /** * Mounted once by `OfficeShell` so it survives every `/office/**` navigation * (AC-OFFICE-KILL-SWITCH-006.4, -006.12). Renders nothing until a workspace @@ -156,26 +229,19 @@ export function WorkspacePauseBanner() { const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); const { record, status, refresh, pause, retryPause, resume, sweep, isMutating } = useWorkspacePause(activeWorkspaceId); - - if (!activeWorkspaceId) return null; - - if (record) { - return ( - - ); - } - - if (status === "unknown") { - return ; - } - - return ; + return ( + + ); } diff --git a/apps/web/app/office/components/workspace-pause-controls.tsx b/apps/web/app/office/components/workspace-pause-controls.tsx index fa9822926a..d5a50c722d 100644 --- a/apps/web/app/office/components/workspace-pause-controls.tsx +++ b/apps/web/app/office/components/workspace-pause-controls.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type MouseEvent } from "react"; +import { useState, type MouseEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"; import { @@ -46,9 +46,11 @@ export function codePointLength(value: string): number { export function PauseWorkspaceButton({ onPause, disabled, + renderTrigger, }: { onPause: (reason: string) => Promise; disabled?: boolean; + renderTrigger?: (onOpen: () => void) => ReactNode; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -82,17 +84,21 @@ export function PauseWorkspaceButton({ return ( <> - + {renderTrigger ? ( + renderTrigger(() => setOpen(true)) + ) : ( + + )} @@ -144,9 +150,11 @@ export function PauseWorkspaceButton({ export function ResumeWorkspaceButton({ onResume, disabled, + renderTrigger, }: { onResume: () => Promise; disabled?: boolean; + renderTrigger?: (onOpen: () => void) => ReactNode; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -176,16 +184,20 @@ export function ResumeWorkspaceButton({ return ( <> - + {renderTrigger ? ( + renderTrigger(() => setOpen(true)) + ) : ( + + )} diff --git a/apps/web/app/office/workspace/activity/activity-row.test.tsx b/apps/web/app/office/workspace/activity/activity-row.test.tsx index 741884ba2b..d84902a8fd 100644 --- a/apps/web/app/office/workspace/activity/activity-row.test.tsx +++ b/apps/web/app/office/workspace/activity/activity-row.test.tsx @@ -54,4 +54,21 @@ describe("ActivityRow", () => { const link = screen.getByRole("link", { name: "Run" }); expect(link.getAttribute("href")).toBe("/office/agents/agent-7/runs/run-1"); }); + + it("renders server-resolved actor and task labels while retaining task identifiers", () => { + render( + , + ); + + expect(screen.getByText("CEO")).toBeTruthy(); + expect(screen.getByText(/KAN-14 Build report/)).toBeTruthy(); + }); }); diff --git a/apps/web/app/office/workspace/activity/activity-row.tsx b/apps/web/app/office/workspace/activity/activity-row.tsx index 9510d0e56a..ce4318985b 100644 --- a/apps/web/app/office/workspace/activity/activity-row.tsx +++ b/apps/web/app/office/workspace/activity/activity-row.tsx @@ -23,10 +23,10 @@ const CANCEL_REASON_LABEL_KEYS: Record = { const MAX_DESCRIPTION_LENGTH = 80; -function actorInitial(actorType: string, actorId: string): string { +function actorInitial(actorType: string, actorId: string, actorName?: string): string { if (actorType === "system") return "SY"; if (actorType === "agent") { - const trimmed = actorId.trim(); + const trimmed = (actorName || actorId).trim(); return trimmed.slice(0, 2).toUpperCase() || "AG"; } return "U"; @@ -34,8 +34,8 @@ function actorInitial(actorType: string, actorId: string): string { function actorLabel(entry: ActivityEntry): string { if (entry.actorType === "system") return t("office:system"); - // An agent id or the raw actor type — identifiers, never copy. - return entry.actorId || entry.actorType; + // Historical rows may have no resolved label, so retain the identifier. + return entry.actorName || entry.actorId || entry.actorType; } function taskIdentifier(details: Record | undefined): string | null { @@ -44,11 +44,18 @@ function taskIdentifier(details: Record | undefined): string | return null; } -function taskRefNode(details: Record | undefined): React.ReactNode { - const id = details?.task_id; - const identifier = taskIdentifier(details); +function taskRefNode( + entry: ActivityEntry, + details: Record | undefined, +): React.ReactNode { + const id = details?.task_id ?? entry.targetId; + const identifier = entry.targetIdentifier ?? taskIdentifier(details); if (!id && !identifier) return null; - const label = identifier ?? (typeof id === "string" ? id : ""); + const fallback = identifier || (typeof id === "string" ? id : ""); + let label = fallback; + if (entry.targetName) { + label = identifier ? `${identifier} ${entry.targetName}` : entry.targetName; + } return {label}; } @@ -71,72 +78,84 @@ function taskStatusLabel(raw: string): string { return key ? t(key) : raw.replace(/_/g, " "); } -function renderAction(entry: ActivityEntry): React.ReactNode { +function renderStaleCancellation(entry: ActivityEntry): React.ReactNode { const d = entry.details; + const label = cancelReasonLabel(typeof d?.reason === "string" ? d.reason : ""); + return ( + <> + {t("office:staleRunCancelled")} + {taskRefNode(entry, d)} + {label && - {truncate(label)}} + + ); +} - if (entry.action === "run_stale_cancelled") { - const label = cancelReasonLabel(typeof d?.reason === "string" ? d.reason : ""); - return ( - <> - {t("office:staleRunCancelled")} - {taskRefNode(d)} - {label && - {truncate(label)}} - - ); - } +function renderReassignmentCancellation(entry: ActivityEntry): React.ReactNode { + return ( + <> + {t("office:retryCancelledReassigned")} + {taskRefNode(entry, entry.details)} + + ); +} - if (entry.action === "run_retry_cancelled") { - return ( - <> - {t("office:retryCancelledReassigned")} - {taskRefNode(d)} - - ); - } +function renderRecoveryDispatch(entry: ActivityEntry): React.ReactNode { + return ( + <> + {t("office:unstartedTaskRecovered")} + {taskRefNode(entry, entry.details)} + + ); +} - if (entry.action === "recovery_dispatch") { - return ( - <> - {t("office:unstartedTaskRecovered")} - {taskRefNode(d)} - - ); - } +function renderTaskStatusChange(entry: ActivityEntry): React.ReactNode { + const d = entry.details; + // One key for the whole clause. The raw status resolves through the shared + // label map without freezing the English word order. + const status = taskStatusLabel(typeof d?.new_status === "string" ? d.new_status : ""); + return ( + <> + + {" "} + {status ? t("office:activityStatusChangedTo", { status }) : t("office:statusChanged")} + + {taskRefNode(entry, d)} + + ); +} - if (entry.action === "task_status_changed") { - // One key for the whole clause. "status changed" + " to {status}" as two - // fragments freezes the English order, and the status arrived as the raw - // wire value; it now resolves through the shared status label map. - const status = taskStatusLabel(typeof d?.new_status === "string" ? d.new_status : ""); - return ( - <> - - {" "} - {status ? t("office:activityStatusChangedTo", { status }) : t("office:statusChanged")} - - {taskRefNode(d)} - - ); - } +const SPECIAL_ACTION_RENDERERS: Record React.ReactNode> = { + run_stale_cancelled: renderStaleCancellation, + run_retry_cancelled: renderReassignmentCancellation, + recovery_dispatch: renderRecoveryDispatch, + task_status_changed: renderTaskStatusChange, +}; - // NOT localized, deliberately: `entry.action` is an open-ended backend - // activity identifier with no closed union on the wire, so a key map would - // silently fall through for any action the backend adds. `targetType` below is - // a wire value for the same reason. +function renderFallbackAction(entry: ActivityEntry): React.ReactNode { + // NOT localized deliberately: these are open-ended backend identifiers. const formatted = truncate(entry.action.replace(/[._]/g, " ")); return ( <> {formatted} {entry.targetType && ( - {entry.targetType} - {entry.targetId ? ` ${entry.targetId}` : ""} + {entry.targetName || entry.targetType} + {entry.targetIdentifier || entry.targetId + ? ` ${entry.targetIdentifier || entry.targetId}` + : ""} )} ); } +function renderAction(entry: ActivityEntry): React.ReactNode { + const renderer = SPECIAL_ACTION_RENDERERS[entry.action]; + if (renderer) return renderer(entry); + + return renderFallbackAction(entry); +} + function runHref(entry: ActivityEntry): string | null { if (!entry.runId) return null; const agentID = resolveAgentId(entry); @@ -160,7 +179,7 @@ export function ActivityRow({ entry }: Props) { return (
- {actorInitial(entry.actorType, entry.actorId)} + {actorInitial(entry.actorType, entry.actorId, entry.actorName)}
{actorLabel(entry)} diff --git a/apps/web/app/office/workspace/settings/export/export-preview.tsx b/apps/web/app/office/workspace/settings/export/export-preview.tsx index cd12dd27e2..5786688568 100644 --- a/apps/web/app/office/workspace/settings/export/export-preview.tsx +++ b/apps/web/app/office/workspace/settings/export/export-preview.tsx @@ -1,43 +1,191 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { IconDownload } from "@tabler/icons-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { IconArrowLeft, IconDownload } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { useAppStore } from "@/components/state-provider"; +import { ApiError } from "@/lib/api/client"; import * as officeApi from "@/lib/api/domains/office-api"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { ExportFileTree } from "./export-file-tree"; import { ExportFilePreview } from "./export-file-preview"; -import { buildFileTree, bundleToExportFiles, countSelectedFiles } from "./export-utils"; +import { buildFileTree, countSelectedFiles } from "./export-utils"; import type { ExportFile } from "./export-types"; import { useTranslation } from "react-i18next"; -export function ExportPreview() { - const { t } = useTranslation(); - const activeWorkspaceId = useAppStore((s) => s.workspaces?.activeId ?? ""); - const workspaces = useAppStore((s) => s.workspaces); - const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId); - // Fallback LABEL for the bundle header, shown only before the workspace loads. - const workspaceName = activeWorkspace?.name || t("common:workspace"); +type ExportToolbarProps = { + workspaceName: string; + selectedCount: number; + total: number; + onExport: () => void; +}; +function ExportToolbar({ workspaceName, selectedCount, total, onExport }: ExportToolbarProps) { + const { t } = useTranslation(); + return ( +
+ + {t("office:exportSelectionSummary", { + workspace: workspaceName, + selected: selectedCount, + total, + })} + + +
+ ); +} + +type ExportErrorProps = { + errorKey: string; + onRetry: () => void; +}; + +function ExportDownloadError({ errorKey, onRetry }: ExportErrorProps) { + const { t } = useTranslation(); + return ( +
+ {t(errorKey)} + {errorKey === "office:exportConfigurationChanged" && ( + + )} +
+ ); +} + +type ExportContentProps = { + usesTouchDrawer: boolean; + previewFile: ExportFile | null; + tree: ReturnType; + selectedPaths: Set; + onSelectedPathsChange: (paths: Set) => void; + previewPath: string | null; + onPreviewPathChange: (path: string | null) => void; +}; + +function ExportContent({ + usesTouchDrawer, + previewFile, + tree, + selectedPaths, + onSelectedPathsChange, + previewPath, + onPreviewPathChange, +}: ExportContentProps) { + const { t } = useTranslation(); + if (usesTouchDrawer && previewFile) { + return ( +
+ + +
+ ); + } + + return ( + <> + onPreviewPathChange(path)} + /> + {!usesTouchDrawer && } + + ); +} + +type ExportStatusProps = { + activeWorkspaceId: string; + loading: boolean; + errorKey: string | null; + onRetry: () => void; +}; + +function ExportStatus({ + activeWorkspaceId, + loading, + errorKey, + onRetry, +}: ExportStatusProps): React.ReactNode { + const { t } = useTranslation(); + if (!activeWorkspaceId) { + return ( +
+ {t("office:selectAWorkspaceToExport")} +
+ ); + } + if (loading) { + return ( +
+ {t("office:loadingExportBundle")} +
+ ); + } + if (errorKey) { + return ( +
+ {t(errorKey)} + +
+ ); + } + return null; +} + +function useExportManifest( + activeWorkspaceId: string, + usesTouchDrawer: boolean, + reloadToken: number, +) { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); - // A catalog KEY, not a message: the load effect below must not take `t` in - // its dep array, or a locale switch would re-issue the export request. const [errorKey, setErrorKey] = useState(null); const [selectedPaths, setSelectedPaths] = useState>(new Set()); const [previewPath, setPreviewPath] = useState(null); + const [revision, setRevision] = useState(""); useEffect(() => { - if (!activeWorkspaceId) return; + setErrorKey(null); + setFiles([]); + setSelectedPaths(new Set()); + setPreviewPath(null); + setRevision(""); + if (!activeWorkspaceId) { + setLoading(false); + return; + } + setLoading(true); let cancelled = false; officeApi - .exportConfig(activeWorkspaceId) - .then((res) => { + .exportConfigManifest(activeWorkspaceId) + .then((manifest) => { if (cancelled) return; - const exported = bundleToExportFiles(res.bundle); - setFiles(exported); - setSelectedPaths(new Set(exported.map((f) => f.path))); - if (exported.length > 0) setPreviewPath(exported[0].path); + setFiles(manifest.files); + setRevision(manifest.revision); + setSelectedPaths(new Set(manifest.files.map((file) => file.path))); + setPreviewPath(usesTouchDrawer ? null : (manifest.files[0]?.path ?? null)); }) .catch(() => { if (!cancelled) setErrorKey("office:failedToLoadExportBundle"); @@ -48,7 +196,57 @@ export function ExportPreview() { return () => { cancelled = true; }; - }, [activeWorkspaceId]); + }, [activeWorkspaceId, reloadToken, usesTouchDrawer]); + + return { + files, + loading, + errorKey, + selectedPaths, + previewPath, + revision, + setSelectedPaths, + setPreviewPath, + }; +} + +function useWorkspaceDownloadGeneration(activeWorkspaceId: string) { + const workspaceGeneration = useRef(0); + const lastWorkspaceId = useRef(activeWorkspaceId); + if (lastWorkspaceId.current !== activeWorkspaceId) { + lastWorkspaceId.current = activeWorkspaceId; + workspaceGeneration.current += 1; + } + return { workspaceGeneration, lastWorkspaceId }; +} + +export function ExportPreview() { + const { t } = useTranslation(); + const activeWorkspaceId = useAppStore((s) => s.workspaces?.activeId ?? ""); + const workspaces = useAppStore((s) => s.workspaces); + const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId); + // Fallback LABEL for the bundle header, shown only before the workspace loads. + const workspaceName = activeWorkspace?.name || t("common:workspace"); + + const [reloadToken, setReloadToken] = useState(0); + const [downloadErrorKey, setDownloadErrorKey] = useState(null); + const { workspaceGeneration, lastWorkspaceId } = + useWorkspaceDownloadGeneration(activeWorkspaceId); + const usesTouchDrawer = useTouchDrawer(); + const { + files, + loading, + errorKey, + selectedPaths, + previewPath, + revision, + setSelectedPaths, + setPreviewPath, + } = useExportManifest(activeWorkspaceId, usesTouchDrawer, reloadToken); + + useEffect(() => { + setDownloadErrorKey(null); + }, [activeWorkspaceId, reloadToken]); const tree = useMemo(() => buildFileTree(files), [files]); const selectedCount = useMemo( @@ -56,59 +254,75 @@ export function ExportPreview() { [selectedPaths, files], ); - const handleExport = useCallback(() => { - if (!activeWorkspaceId) return; - // This same-origin endpoint is a file download, not an external navigation. - window.open(officeApi.exportConfigZipUrl(activeWorkspaceId), "_blank"); - }, [activeWorkspaceId]); + const handleExport = useCallback(async () => { + if (!activeWorkspaceId || !revision || selectedCount === 0) return; + const requestWorkspaceId = activeWorkspaceId; + const requestGeneration = workspaceGeneration.current; + setDownloadErrorKey(null); + try { + const blob = await officeApi.exportSelectedConfigZip(requestWorkspaceId, { + revision, + paths: [...selectedPaths], + }); + if ( + workspaceGeneration.current !== requestGeneration || + lastWorkspaceId.current !== requestWorkspaceId + ) { + return; + } + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "kandev-config-selected.zip"; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + } catch (error) { + setDownloadErrorKey( + error instanceof ApiError && error.status === 409 + ? "office:exportConfigurationChanged" + : "office:failedToDownloadExport", + ); + } + }, [activeWorkspaceId, revision, selectedCount, selectedPaths]); + + const handleRetry = useCallback(() => { + setReloadToken((value) => value + 1); + }, []); const previewFile = files.find((f) => f.path === previewPath) ?? null; - if (loading) { + if (!activeWorkspaceId || loading || errorKey) { return ( -
- {t("office:loadingExportBundle")} -
- ); - } - - if (errorKey) { - return ( -
- {t(errorKey)} -
+ ); } return (
-
- - {t("office:exportSelectionSummary", { - workspace: workspaceName, - selected: selectedCount, - total: files.length, - })} - - -
-
- void handleExport()} + /> + {downloadErrorKey && ( + + )} +
+ -
); diff --git a/apps/web/e2e/helpers/office-api-client.ts b/apps/web/e2e/helpers/office-api-client.ts index 3a9d680808..1e835e74d6 100644 --- a/apps/web/e2e/helpers/office-api-client.ts +++ b/apps/web/e2e/helpers/office-api-client.ts @@ -589,6 +589,10 @@ export class OfficeApiClient { return res.routine ?? (res as unknown as Record); } + async listRoutineRuns(routineId: string): Promise> { + return this.request("GET", `/routines/${routineId}/runs`); + } + /** Manual fire (AC-OFFICE-KILL-SWITCH-002.4). Raw: a paused workspace answers 409. */ async runRoutine(routineId: string): Promise { return this.rawRequest("POST", `/routines/${routineId}/run`); diff --git a/apps/web/e2e/tests/office/config-export.spec.ts b/apps/web/e2e/tests/office/config-export.spec.ts new file mode 100644 index 0000000000..2efa17cab1 --- /dev/null +++ b/apps/web/e2e/tests/office/config-export.spec.ts @@ -0,0 +1,55 @@ +import { readFileSync } from "node:fs"; +import { inflateRawSync } from "node:zlib"; +import { expect, test } from "../../fixtures/office-fixture"; + +function readZipEntries(path: string): Map { + const archive = readFileSync(path); + const entries = new Map(); + const endOfCentralDirectory = 0x06054b50; + const centralDirectoryEntry = 0x02014b50; + const localFileEntry = 0x04034b50; + let end = archive.length - 22; + while (end >= 0 && archive.readUInt32LE(end) !== endOfCentralDirectory) end -= 1; + if (end < 0) throw new Error("ZIP end-of-central-directory record not found"); + const count = archive.readUInt16LE(end + 10); + let offset = archive.readUInt32LE(end + 16); + for (let index = 0; index < count; index += 1) { + if (archive.readUInt32LE(offset) !== centralDirectoryEntry) + throw new Error("invalid ZIP entry"); + const method = archive.readUInt16LE(offset + 10); + const compressedSize = archive.readUInt32LE(offset + 20); + const nameLength = archive.readUInt16LE(offset + 28); + const extraLength = archive.readUInt16LE(offset + 30); + const commentLength = archive.readUInt16LE(offset + 32); + const localOffset = archive.readUInt32LE(offset + 42); + const name = archive.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); + if (archive.readUInt32LE(localOffset) !== localFileEntry) + throw new Error("invalid local ZIP entry"); + const localNameLength = archive.readUInt16LE(localOffset + 26); + const localExtraLength = archive.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + localNameLength + localExtraLength; + const compressed = archive.subarray(dataStart, dataStart + compressedSize); + entries.set(name, method === 8 ? inflateRawSync(compressed) : compressed); + offset += 46 + nameLength + extraLength + commentLength; + } + return entries; +} + +test("configuration export downloads the selected server manifest", async ({ + testPage, + officeSeed: _, +}) => { + await testPage.goto("/office/workspace/settings/export"); + await expect(testPage.getByText(/E2E Workspace export/)).toBeVisible({ timeout: 10_000 }); + await expect(testPage.getByText("kandev.yml", { exact: true })).toBeVisible(); + + const downloadPromise = testPage.waitForEvent("download"); + await testPage.getByRole("button", { name: /Export \d+ files?/ }).click(); + const download = await downloadPromise; + const archivePath = await download.path(); + expect(archivePath).toBeTruthy(); + + const entries = readZipEntries(archivePath!); + expect(entries.has(".kandev/kandev.yml")).toBe(true); + expect(entries.get(".kandev/kandev.yml")?.toString("utf8")).toContain("name:"); +}); diff --git a/apps/web/e2e/tests/office/mobile-config-export.spec.ts b/apps/web/e2e/tests/office/mobile-config-export.spec.ts new file mode 100644 index 0000000000..ce61b0cf55 --- /dev/null +++ b/apps/web/e2e/tests/office/mobile-config-export.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "../../fixtures/office-fixture"; + +test("mobile configuration export supports file preview and back", async ({ + testPage, + officeSeed: _, +}) => { + await testPage.goto("/office/workspace/settings/export"); + await expect(testPage.getByText("kandev.yml", { exact: true })).toBeVisible({ timeout: 10_000 }); + + await testPage.getByText("kandev.yml", { exact: true }).click(); + await expect(testPage.getByRole("button", { name: "Back to export files" })).toBeVisible(); + await expect(testPage.locator("pre")).toContainText("name:"); + + await testPage.getByRole("button", { name: "Back to export files" }).click(); + await expect(testPage.getByText("kandev.yml", { exact: true })).toBeVisible(); +}); diff --git a/apps/web/e2e/tests/office/mobile-routine-catch-up-policy-ui.spec.ts b/apps/web/e2e/tests/office/mobile-routine-catch-up-policy-ui.spec.ts index 48a0b6bbff..c619718ab3 100644 --- a/apps/web/e2e/tests/office/mobile-routine-catch-up-policy-ui.spec.ts +++ b/apps/web/e2e/tests/office/mobile-routine-catch-up-policy-ui.spec.ts @@ -28,11 +28,11 @@ async function assertMobileCatchUpPolicyToggles(page: Page) { await expect(catchUpMax).toHaveValue("42"); await catchUpPolicyCombobox(page).tap(); - await page.getByRole("option", { name: "Skip missed" }).tap(); + await page.getByRole("option", { name: "Skip missed", exact: true }).tap(); await expect(page.getByText("Catch-up max", { exact: true })).toHaveCount(0); await catchUpPolicyCombobox(page).tap(); - await page.getByRole("option", { name: SUMMARIZE_MISSED_LABEL }).tap(); + await page.getByRole("option", { name: SUMMARIZE_MISSED_LABEL, exact: true }).tap(); await expect(catchUpMaxInput(page)).toHaveValue("42"); await expect(page.getByText(RETIRED_LABEL)).toHaveCount(0); } @@ -47,7 +47,7 @@ test.describe("Mobile routine catch-up policy control", () => { await testPage.getByLabel("Name").fill("E2E Mobile Catch-up Dialog"); await testPage.getByText("Assignee", { exact: true }).locator("..").getByRole("combobox").tap(); - await testPage.getByRole("option", { name: "CEO" }).tap(); + await testPage.getByRole("option", { name: "CEO", exact: true }).tap(); await testPage.getByRole("button", { name: "Next" }).tap(); await testPage.getByRole("button", { name: "Next" }).tap(); @@ -66,13 +66,14 @@ test.describe("Mobile routine catch-up policy control", () => { testPage, prCapture, }) => { + const routineName = "E2E Mobile Catch-up Detail Toggle"; const routine = (await officeApi.createRoutine(officeSeed.workspaceId, { - name: "E2E Mobile Catch-up Detail Toggle", + name: routineName, })) as { id: string }; expect(routine.id).toBeTruthy(); await testPage.goto(`/office/routines/${routine.id}`); - await expect(testPage.getByText("E2E Mobile Catch-up Detail Toggle")).toBeVisible({ + await expect(testPage.locator("main input").first()).toHaveValue(routineName, { timeout: 10_000, }); diff --git a/apps/web/e2e/tests/office/mobile-run-observation.spec.ts b/apps/web/e2e/tests/office/mobile-run-observation.spec.ts new file mode 100644 index 0000000000..0bf043f1b9 --- /dev/null +++ b/apps/web/e2e/tests/office/mobile-run-observation.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "../../fixtures/office-fixture"; + +test("mobile run observation keeps identity visible without document overflow", async ({ + testPage, + apiClient, + officeSeed, +}) => { + const run = await apiClient.seedRun({ + agentProfileId: officeSeed.agentId, + status: "finished", + reason: "routine_dispatch_manual", + }); + + await testPage.goto(`/office/agents/${officeSeed.agentId}/runs/${run.run_id}`); + await expect(testPage.getByTestId("run-header")).toBeVisible(); + await expect(testPage.getByTestId("run-agent-name")).toHaveText("CEO"); + expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + await testPage.evaluate(() => document.documentElement.clientWidth), + ); +}); diff --git a/apps/web/e2e/tests/office/mobile-workspace-kill-switch.spec.ts b/apps/web/e2e/tests/office/mobile-workspace-kill-switch.spec.ts index 10a9073bb2..4f4fe40abd 100644 --- a/apps/web/e2e/tests/office/mobile-workspace-kill-switch.spec.ts +++ b/apps/web/e2e/tests/office/mobile-workspace-kill-switch.spec.ts @@ -27,21 +27,25 @@ test.describe("mobile: Office workspace kill switch", () => { })) as { id: string }; await testPage.goto(`/office?workspaceId=${officeSeed.workspaceId}`); - await expect(testPage.getByTestId("office-workspace-running-bar")).toBeVisible({ - timeout: 10_000, - }); + const actionsTrigger = testPage.getByTestId("office-workspace-actions-trigger"); + await expect(actionsTrigger).toBeVisible({ timeout: 10_000 }); await assertNoDocumentHorizontalOverflow(testPage); - // Pause control meets the 44px coarse-pointer touch-target minimum - // (apps/web/AGENTS.md's mobile-parity convention). - const pauseButtonBox = await testPage - .getByTestId("office-pause-workspace-button") - .boundingBox(); - expect(pauseButtonBox?.height).toBeGreaterThanOrEqual(44); + // The actions trigger and drawer controls meet the 44px coarse-pointer + // touch-target minimum (apps/web/AGENTS.md's mobile-parity convention). + const actionsTriggerBox = await actionsTrigger.boundingBox(); + expect(actionsTriggerBox?.height).toBeGreaterThanOrEqual(44); // Pause control is reachable and operable on a phone viewport (AC-006.12, -006.8). - await testPage.getByTestId("office-pause-workspace-button").tap(); + await actionsTrigger.tap(); + const actionsDrawer = testPage.getByTestId("office-workspace-actions-drawer"); + await expect(actionsDrawer).toBeVisible(); + const pauseButton = actionsDrawer.getByTestId("office-pause-workspace-button"); + const pauseButtonBox = await pauseButton.boundingBox(); + expect(pauseButtonBox?.height).toBeGreaterThanOrEqual(44); + await pauseButton.tap(); const pauseDialog = testPage.getByTestId("office-pause-workspace-dialog"); + await expect(actionsDrawer).toHaveCount(0); await expect(pauseDialog).toBeVisible(); await pauseDialog.getByTestId("office-pause-reason-input").fill("Mobile E2E kill switch drill"); @@ -66,8 +70,12 @@ test.describe("mobile: Office workspace kill switch", () => { expect(blockedFire.status).toBe(409); // Resume requires explicit confirmation and is operable via touch (AC-006.5, -006.8). - await testPage.getByTestId("office-resume-workspace-button").tap(); + await testPage.getByTestId("office-workspace-actions-trigger").tap(); + const resumeDrawer = testPage.getByTestId("office-workspace-actions-drawer"); + await expect(resumeDrawer).toBeVisible(); + await resumeDrawer.getByTestId("office-resume-workspace-button").tap(); const resumeDialog = testPage.getByTestId("office-resume-workspace-dialog"); + await expect(resumeDrawer).toHaveCount(0); await expect(resumeDialog).toBeVisible(); const resumePosted = waitForHttp(testPage, "POST", /\/office\/workspaces\/[^/]+\/resume$/); @@ -75,7 +83,7 @@ test.describe("mobile: Office workspace kill switch", () => { await resumePosted; await expect(testPage.getByTestId("office-workspace-paused-banner")).toHaveCount(0); - await expect(testPage.getByTestId("office-workspace-running-bar")).toBeVisible(); + await expect(testPage.getByTestId("office-workspace-actions-trigger")).toBeVisible(); await assertNoDocumentHorizontalOverflow(testPage); const resumedFire = await officeApi.runRoutine(routine.id); diff --git a/apps/web/e2e/tests/office/routine-catch-up-policy-ui.spec.ts b/apps/web/e2e/tests/office/routine-catch-up-policy-ui.spec.ts index ccaa95843a..ceae40df46 100644 --- a/apps/web/e2e/tests/office/routine-catch-up-policy-ui.spec.ts +++ b/apps/web/e2e/tests/office/routine-catch-up-policy-ui.spec.ts @@ -37,11 +37,11 @@ async function assertCatchUpPolicyToggles(page: Page, catchUpMaxInput: () => Pro await catchUpMaxInput(); await catchUpPolicyCombobox(page).click(); - await page.getByRole("option", { name: "Skip missed" }).click(); + await page.getByRole("option", { name: "Skip missed", exact: true }).click(); await expect(page.getByText("Catch-up max", { exact: true })).toHaveCount(0); await catchUpPolicyCombobox(page).click(); - await page.getByRole("option", { name: SUMMARIZE_MISSED_LABEL }).click(); + await page.getByRole("option", { name: SUMMARIZE_MISSED_LABEL, exact: true }).click(); await catchUpMaxInput(); await expect(page.getByText(RETIRED_LABEL)).toHaveCount(0); @@ -61,7 +61,7 @@ test.describe("Routine catch-up policy UI", () => { .locator("..") .getByRole("combobox") .click(); - await testPage.getByRole("option", { name: "CEO" }).click(); + await testPage.getByRole("option", { name: "CEO", exact: true }).click(); await testPage.getByRole("button", { name: "Next" }).click(); await testPage.getByRole("button", { name: "Next" }).click(); @@ -86,7 +86,7 @@ test.describe("Routine catch-up policy UI", () => { expect(routine.id).toBeTruthy(); await testPage.goto(`/office/routines/${routine.id}`); - await expect(testPage.getByText("E2E Catch-up Detail Toggle")).toBeVisible({ + await expect(testPage.getByText("E2E Catch-up Detail Toggle").last()).toBeVisible({ timeout: 10_000, }); diff --git a/apps/web/e2e/tests/office/run-observation.spec.ts b/apps/web/e2e/tests/office/run-observation.spec.ts new file mode 100644 index 0000000000..4bec178ba2 --- /dev/null +++ b/apps/web/e2e/tests/office/run-observation.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "../../fixtures/office-fixture"; + +test.describe("Office run observation", () => { + test("direct run entry keeps the named agent and activity label", async ({ + testPage, + apiClient, + officeApi, + officeSeed, + }) => { + const run = await apiClient.seedRun({ + agentProfileId: officeSeed.agentId, + status: "finished", + reason: "routine_dispatch_manual", + inputSnapshot: JSON.stringify({ adapter: "mock", model: "mock-fast" }), + }); + await apiClient.seedActivity({ + workspaceId: officeSeed.workspaceId, + actorType: "agent", + actorId: officeSeed.agentId, + action: "task_status_changed", + targetType: "task", + targetId: "missing-task-id", + details: JSON.stringify({ task_identifier: "KAN-14" }), + runId: run.run_id, + }); + + await testPage.goto(`/office/agents/${officeSeed.agentId}/runs/${run.run_id}`); + await expect(testPage.getByTestId("run-header")).toBeVisible(); + await expect(testPage.getByTestId("run-agent-name")).toHaveText("CEO"); + + await testPage.goto("/office/workspace/activity"); + await expect(testPage.getByText("CEO", { exact: true })).toBeVisible(); + await expect(testPage.getByText(/KAN-14/)).toBeVisible(); + + const activity = await officeApi.listActivity(officeSeed.workspaceId); + expect(activity).toBeDefined(); + }); +}); diff --git a/apps/web/e2e/tests/office/taskless-routine-session.spec.ts b/apps/web/e2e/tests/office/taskless-routine-session.spec.ts new file mode 100644 index 0000000000..f9887675c5 --- /dev/null +++ b/apps/web/e2e/tests/office/taskless-routine-session.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from "../../fixtures/office-fixture"; + +type RoutineRun = { + id: string; + linked_task_id?: string; + status: string; +}; + +async function routineRuns( + officeApi: { listRoutineRuns(id: string): Promise> }, + id: string, +) { + const result = await officeApi.listRoutineRuns(id); + return (Array.isArray(result.runs) ? result.runs : []) as RoutineRun[]; +} + +test.describe("Office taskless routine sessions", () => { + test("fires a real taskless routine twice without creating task rows", async ({ + officeApi, + apiClient, + officeSeed, + }) => { + test.setTimeout(120_000); + const before = await apiClient.listTasks(officeSeed.workspaceId); + const routine = await officeApi.createRoutine(officeSeed.workspaceId, { + name: `Taskless E2E ${Date.now()}`, + description: "Taskless routine session smoke test", + assignee_agent_profile_id: officeSeed.agentId, + }); + const routineId = routine.id as string; + + const existing = await officeApi.listRuns(officeSeed.workspaceId); + const seen = new Set(((existing.runs ?? []) as { id: string }[]).map((run) => run.id)); + const sessions: string[] = []; + for (let attempt = 1; attempt <= 2; attempt += 1) { + const response = await officeApi.runRoutine(routineId); + expect(response.status).toBe(200); + await expect + .poll(() => routineRuns(officeApi, routineId), { timeout: 20_000 }) + .toHaveLength(attempt); + let runId = ""; + await expect + .poll( + async () => { + const result = await officeApi.listRuns(officeSeed.workspaceId); + const run = ((result.runs ?? []) as { id: string; reason: string }[]).find( + (candidate) => !seen.has(candidate.id) && candidate.reason.startsWith("routine_"), + ); + runId = run?.id ?? ""; + return runId; + }, + { timeout: 30_000 }, + ) + .not.toBe(""); + seen.add(runId); + const detailPath = `/agents/${officeSeed.agentId}/runs/${runId}`; + await expect + .poll( + async () => { + const result = await officeApi.rawRequest("GET", detailPath); + expect(result.ok).toBe(true); + const detail = await result.json(); + return detail.status; + }, + { timeout: 60_000 }, + ) + .toMatch(/^(finished|failed|cancelled)$/); + const detail = await (await officeApi.rawRequest("GET", detailPath)).json(); + expect({ status: detail.status, error: detail.error_message ?? "" }).toEqual({ + status: "finished", + error: "", + }); + expect(detail.task_id ?? "").toBe(""); + expect(detail.session.session_id).toBeTruthy(); + expect(detail.assembled_prompt).toBeTruthy(); + sessions.push(detail.session.session_id); + } + + const runs = await routineRuns(officeApi, routineId); + expect(runs.every((run) => !run.linked_task_id)).toBe(true); + expect(new Set(runs.map((run) => run.id)).size).toBe(2); + + expect(new Set(sessions).size).toBe(2); + const after = await apiClient.listTasks(officeSeed.workspaceId); + expect(after.tasks.map((task) => task.id)).toEqual(before.tasks.map((task) => task.id)); + }); +}); diff --git a/apps/web/e2e/tests/office/workspace-kill-switch.spec.ts b/apps/web/e2e/tests/office/workspace-kill-switch.spec.ts index 617591768c..209c06c673 100644 --- a/apps/web/e2e/tests/office/workspace-kill-switch.spec.ts +++ b/apps/web/e2e/tests/office/workspace-kill-switch.spec.ts @@ -29,7 +29,7 @@ test.describe("Office workspace kill switch", () => { expect(baselineFire.status).toBe(200); await testPage.goto(`/office?workspaceId=${officeSeed.workspaceId}`); - await expect(testPage.getByTestId("office-workspace-running-bar")).toBeVisible({ + await expect(testPage.getByTestId("office-pause-workspace-button")).toBeVisible({ timeout: 10_000, }); await expect(testPage.getByTestId("office-workspace-paused-banner")).toHaveCount(0); @@ -78,7 +78,7 @@ test.describe("Office workspace kill switch", () => { await resumePosted; await expect(testPage.getByTestId("office-workspace-paused-banner")).toHaveCount(0); - await expect(testPage.getByTestId("office-workspace-running-bar")).toBeVisible(); + await expect(testPage.getByTestId("office-pause-workspace-button")).toBeVisible(); // Resume is effective (AC-005.2/-005.3): the same routine can fire again. const resumedFire = await officeApi.runRoutine(routine.id); @@ -91,7 +91,7 @@ test.describe("Office workspace kill switch", () => { officeSeed, }) => { await testPage.goto(`/office?workspaceId=${officeSeed.workspaceId}`); - await expect(testPage.getByTestId("office-workspace-running-bar")).toBeVisible({ + await expect(testPage.getByTestId("office-pause-workspace-button")).toBeVisible({ timeout: 10_000, }); @@ -101,7 +101,7 @@ test.describe("Office workspace kill switch", () => { // AC-006.13: refresh re-reads and displays what the server holds. const refreshRead = waitForHttp(testPage, "GET", /\/office\/workspaces\/[^/]+\/pause$/); - await testPage.getByTestId("office-pause-refresh-running").click(); + await testPage.getByTestId("office-pause-refresh-topbar").click(); await refreshRead; const banner = testPage.getByTestId("office-workspace-paused-banner"); diff --git a/apps/web/lib/api/domains/office-activity-normalize.ts b/apps/web/lib/api/domains/office-activity-normalize.ts index c9ff92a521..172682a357 100644 --- a/apps/web/lib/api/domains/office-activity-normalize.ts +++ b/apps/web/lib/api/domains/office-activity-normalize.ts @@ -4,8 +4,11 @@ export type RawActivityEntry = ActivityEntry & { workspace_id?: string; actor_type?: ActivityEntry["actorType"]; actor_id?: string; + actor_name?: string; target_type?: string; target_id?: string; + target_name?: string; + target_identifier?: string; run_id?: string; session_id?: string; created_at?: string; @@ -25,18 +28,32 @@ function parseDetails(details: unknown): Record | undefined { } } +function pick(fallback: T, ...values: Array): T { + for (const value of values) { + if (value !== undefined) return value; + } + return fallback; +} + export function normalizeActivityEntry(raw: RawActivityEntry): ActivityEntry { return { - id: raw.id ?? "", - workspaceId: raw.workspaceId ?? raw.workspace_id ?? "", - actorType: raw.actorType ?? raw.actor_type ?? "system", - actorId: raw.actorId ?? raw.actor_id ?? "", - action: raw.action ?? "", - targetType: raw.targetType ?? raw.target_type, - targetId: raw.targetId ?? raw.target_id, + id: pick("", raw.id), + workspaceId: pick("", raw.workspaceId, raw.workspace_id), + actorType: pick("system", raw.actorType, raw.actor_type), + actorId: pick("", raw.actorId, raw.actor_id), + actorName: pick(undefined, raw.actorName, raw.actor_name), + action: pick("", raw.action), + targetType: pick(undefined, raw.targetType, raw.target_type), + targetId: pick(undefined, raw.targetId, raw.target_id), + targetName: pick(undefined, raw.targetName, raw.target_name), + targetIdentifier: pick( + undefined, + raw.targetIdentifier, + raw.target_identifier, + ), details: parseDetails(raw.details), - runId: raw.runId ?? raw.run_id, - sessionId: raw.sessionId ?? raw.session_id, - createdAt: raw.createdAt ?? raw.created_at ?? "", + runId: pick(undefined, raw.runId, raw.run_id), + sessionId: pick(undefined, raw.sessionId, raw.session_id), + createdAt: pick("", raw.createdAt, raw.created_at), }; } diff --git a/apps/web/lib/api/domains/office-api.ts b/apps/web/lib/api/domains/office-api.ts index bb267dc604..0358710547 100644 --- a/apps/web/lib/api/domains/office-api.ts +++ b/apps/web/lib/api/domains/office-api.ts @@ -20,6 +20,8 @@ export { setupChannel, deleteChannel, exportConfig, + exportConfigManifest, + exportSelectedConfigZip, exportConfigZipUrl, previewImport, applyImport, diff --git a/apps/web/lib/api/domains/office-config-export.test.ts b/apps/web/lib/api/domains/office-config-export.test.ts new file mode 100644 index 0000000000..07f68de6b2 --- /dev/null +++ b/apps/web/lib/api/domains/office-config-export.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { exportConfigManifest, exportSelectedConfigZip } from "./office-extended-api"; + +const WORKSPACE_ID = "ws-1"; +const BASE_URL = ""; +const MANIFEST_PATH = `/api/v1/office/workspaces/${WORKSPACE_ID}/config/export/manifest`; +const ZIP_PATH = `/api/v1/office/workspaces/${WORKSPACE_ID}/config/export/zip`; +const AGENT_PATH = ".kandev/agents/agent.yml"; + +const originalFetch = global.fetch; + +describe("office-config-export-api", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + global.fetch = fetchSpy as unknown as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("loads the workspace-scoped export manifest", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + revision: "rev-1", + files: [{ path: AGENT_PATH, content: "name: Agent" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const manifest = await exportConfigManifest(WORKSPACE_ID, { baseUrl: BASE_URL }); + + expect(String(fetchSpy.mock.calls[0]![0])).toBe(MANIFEST_PATH); + expect(manifest.revision).toBe("rev-1"); + expect(manifest.files[0]?.path).toBe(AGENT_PATH); + }); + + it("posts the selected manifest revision and paths for the ZIP", async () => { + fetchSpy.mockResolvedValueOnce(new Response("zip-bytes", { status: 200 })); + + const blob = await exportSelectedConfigZip( + WORKSPACE_ID, + { revision: "rev-1", paths: [AGENT_PATH] }, + { baseUrl: BASE_URL }, + ); + + const init = fetchSpy.mock.calls[0]![1] as RequestInit; + expect(String(fetchSpy.mock.calls[0]![0])).toBe(ZIP_PATH); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + revision: "rev-1", + paths: [AGENT_PATH], + }); + expect(await blob.text()).toBe("zip-bytes"); + }); + + it("preserves a stale-revision response as an ApiError", async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ error: "export changed" }), { + status: 409, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect( + exportSelectedConfigZip( + WORKSPACE_ID, + { revision: "old-revision", paths: [AGENT_PATH] }, + { baseUrl: BASE_URL }, + ), + ).rejects.toMatchObject({ status: 409, message: "export changed" }); + }); +}); diff --git a/apps/web/lib/api/domains/office-config-export.ts b/apps/web/lib/api/domains/office-config-export.ts new file mode 100644 index 0000000000..6031348a78 --- /dev/null +++ b/apps/web/lib/api/domains/office-config-export.ts @@ -0,0 +1,38 @@ +import { fetchBlob, fetchJson, type ApiRequestOptions } from "../client"; + +const BASE = "/api/v1/office"; + +export type ConfigExportFile = { + path: string; + content: string; +}; + +export type ConfigExportManifest = { + revision: string; + files: ConfigExportFile[]; +}; + +export function exportConfigManifest(workspaceId: string, options?: ApiRequestOptions) { + return fetchJson( + `${BASE}/workspaces/${workspaceId}/config/export/manifest`, + options, + ); +} + +export const exportConfigZipUrl = (workspaceId: string) => + `${BASE}/workspaces/${workspaceId}/config/export/zip`; + +export function exportSelectedConfigZip( + workspaceId: string, + request: { revision: string; paths: string[] }, + options?: ApiRequestOptions, +) { + return fetchBlob(`${BASE}/workspaces/${workspaceId}/config/export/zip`, { + ...options, + init: { + method: "POST", + body: JSON.stringify(request), + ...options?.init, + }, + }); +} diff --git a/apps/web/lib/api/domains/office-extended-api.ts b/apps/web/lib/api/domains/office-extended-api.ts index 80ecbd27e3..481fe8cfb2 100644 --- a/apps/web/lib/api/domains/office-extended-api.ts +++ b/apps/web/lib/api/domains/office-extended-api.ts @@ -56,8 +56,7 @@ export function exportConfig(workspaceId: string, options?: ApiRequestOptions) { ); } -export const exportConfigZipUrl = (workspaceId: string) => - `${BASE}/workspaces/${workspaceId}/config/export/zip`; +export * from "./office-config-export"; export function previewImport( workspaceId: string, diff --git a/apps/web/lib/api/domains/office-runs-api.ts b/apps/web/lib/api/domains/office-runs-api.ts index a288120503..2fe46cd5c3 100644 --- a/apps/web/lib/api/domains/office-runs-api.ts +++ b/apps/web/lib/api/domains/office-runs-api.ts @@ -58,6 +58,9 @@ export type RunRuntimeDetail = { session_id?: string; skills: Array<{ skill_id: string; + display_name?: string; + slug?: string; + label_source?: string; version: string; content_hash: string; materialized_path: string; @@ -89,6 +92,7 @@ export type RunDetail = { id: string; id_short: string; agent_id: string; + agent_name?: string; reason: string; status: "queued" | "claimed" | "finished" | "failed" | "cancelled"; cancel_reason?: string; diff --git a/apps/web/lib/desktop/external-links-usage.test.ts b/apps/web/lib/desktop/external-links-usage.test.ts index 81ded95765..26695343be 100644 --- a/apps/web/lib/desktop/external-links-usage.test.ts +++ b/apps/web/lib/desktop/external-links-usage.test.ts @@ -17,10 +17,14 @@ describe("programmatic external links", () => { expect(contents).not.toContain("window.open("); }); - it.each([ - ["../../hooks/use-open-session-in-editor.ts", "editor custom schemes"], - ["../../app/office/workspace/settings/export/export-preview.tsx", "downloads"], - ])("preserves WebView-owned window.open for %s", (path) => { - expect(source(path)).toContain("window.open("); + it("preserves WebView-owned window.open for editor custom schemes", () => { + expect(source("../../hooks/use-open-session-in-editor.ts")).toContain("window.open("); + }); + + it("uses the browser-owned download flow for selected Office exports", () => { + const contents = source("../../app/office/workspace/settings/export/export-preview.tsx"); + + expect(contents).toContain("anchor.download"); + expect(contents).toContain("anchor.click()"); }); }); diff --git a/apps/web/lib/state/slices/office/types.ts b/apps/web/lib/state/slices/office/types.ts index 9dc44f6734..31e72709ee 100644 --- a/apps/web/lib/state/slices/office/types.ts +++ b/apps/web/lib/state/slices/office/types.ts @@ -112,9 +112,12 @@ export type ActivityEntry = { workspaceId: string; actorType: "user" | "agent" | "system"; actorId: string; + actorName?: string; action: string; targetType?: string; targetId?: string; + targetName?: string; + targetIdentifier?: string; details?: Record; runId?: string; sessionId?: string; diff --git a/apps/web/src/locales/en/office.json b/apps/web/src/locales/en/office.json index 828ca50a88..5d305b16b4 100644 --- a/apps/web/src/locales/en/office.json +++ b/apps/web/src/locales/en/office.json @@ -313,6 +313,7 @@ "exportFilesCount_one": "Export {{count}} file", "exportFilesCount_other": "Export {{count}} files", "exportSelectionSummary": "{{workspace}} export · {{selected}} / {{total}} files selected", + "exportConfigurationChanged": "Configuration changed. Reload the preview before downloading.", "exportSvg": "Export SVG", "exportToFilesystem": "Export to filesystem", "failed": "Failed", @@ -348,6 +349,7 @@ "failedToLoadDeletionSummary": "Failed to load deletion summary", "failedToLoadDiffs": "Failed to load diffs", "failedToLoadExportBundle": "Failed to load export bundle", + "failedToDownloadExport": "Failed to download the selected export", "failedToLoadInstructions": "Failed to load instructions", "failedToLoadMemory": "Failed to load memory", "failedToLoadMoreTasks": "Failed to load more tasks", @@ -438,6 +440,7 @@ "live": "Live", "liveNow": "Live now", "loadingExportBundle": "Loading export bundle...", + "backToExportFiles": "Back to export files", "loadingInstructions": "Loading instructions...", "loadingProject": "Loading project...", "loadingRuns": "Loading runs...", @@ -806,6 +809,7 @@ "selectApprover": "Select approver", "selectAProjectToCreateATask": "Select a project to create a task", "selectASkillToView": "Select a skill to view", + "selectAWorkspaceToExport": "Select a workspace to export.", "selectAWorkspaceToViewActivity": "Select a workspace to view activity.", "selectAWorkspaceToViewCosts": "Select a workspace to view costs.", "selectExecutionProfile": "Select execution profile", @@ -831,6 +835,7 @@ "skillMdContent": "SKILL.md Content", "skillNameInstructionsForTheAgent": "# Skill Name\n\nInstructions for the agent...", "skills": "Skills", + "skillUnavailable": "Skill unavailable", "skillSourceGithub": "GitHub", "skillSourceGitRepository": "Git Repository", "skillSourceInline": "Inline", @@ -1007,6 +1012,7 @@ "workspaceName": "Workspace name", "workspacePausedDetail": "Paused by {{actor}}: {{reason}} ({{time}})", "workspacePausedTitle": "Workspace paused", + "workspaceActions": "Workspace actions", "writeTheDatabaseStateToOn": "Write the database state to on-disk YAML files.", "yearly": "Yearly", "yourModel": "your model", diff --git a/apps/web/src/locales/pseudo/office.json b/apps/web/src/locales/pseudo/office.json index f35fe94dd9..c9f4aee6d3 100644 --- a/apps/web/src/locales/pseudo/office.json +++ b/apps/web/src/locales/pseudo/office.json @@ -313,6 +313,7 @@ "exportFilesCount_one": "Ēxƥōŕţ {{count}} ƒĩĺē", "exportFilesCount_other": "Ēxƥōŕţ {{count}} ƒĩĺēś", "exportSelectionSummary": "{{workspace}} ēxƥōŕţ · {{selected}} / {{total}} ƒĩĺēś śēĺēćţēď", + "exportConfigurationChanged": "Ćōńƒĩĝũŕàţĩōń ćĥàńĝēď. Řēĺōàď ţĥē ƥŕēṽĩēẅ ƀēƒōŕē ďōẅńĺōàďĩńĝ.", "exportSvg": "Ēxƥōŕţ ŚVĜ", "exportToFilesystem": "Ēxƥōŕţ ţō ƒĩĺēśŷśţēḿ", "failed": "Ƒàĩĺēď", @@ -348,6 +349,7 @@ "failedToLoadDeletionSummary": "Ƒàĩĺēď ţō ĺōàď ďēĺēţĩōń śũḿḿàŕŷ", "failedToLoadDiffs": "Ƒàĩĺēď ţō ĺōàď ďĩƒƒś", "failedToLoadExportBundle": "Ƒàĩĺēď ţō ĺōàď ēxƥōŕţ ƀũńďĺē", + "failedToDownloadExport": "Ƒàĩĺēď ţō ďōẅńĺōàď ţĥē śēĺēćţēď ēxƥōŕţ", "failedToLoadInstructions": "Ƒàĩĺēď ţō ĺōàď ĩńśţŕũćţĩōńś", "failedToLoadMemory": "Ƒàĩĺēď ţō ĺōàď ḿēḿōŕŷ", "failedToLoadMoreTasks": "Ƒàĩĺēď ţō ĺōàď ḿōŕē ţàśķś", @@ -438,6 +440,7 @@ "live": "Ĺĩvē", "liveNow": "Ĺĩvē ńōŵ", "loadingExportBundle": "Ĺōàďĩńĝ ēxƥōŕţ ƀũńďĺē...", + "backToExportFiles": "Ɓàćƙ ţō ēxƥōŕţ ƒĩĺēś", "loadingInstructions": "Ĺōàďĩńĝ ĩńśţŕũćţĩōńś...", "loadingProject": "Ĺōàďĩńĝ ƥŕōĵēćţ...", "loadingRuns": "Ĺōàďĩńĝ ŕũńś...", @@ -806,6 +809,7 @@ "selectApprover": "Śēĺēćţ àƥƥŕōvēŕ", "selectAProjectToCreateATask": "Śēĺēćţ à ƥŕōĵēćţ ţō ćŕēàţē à ţàśķ", "selectASkillToView": "Śēĺēćţ à śķĩĺĺ ţō vĩēŵ", + "selectAWorkspaceToExport": "Śēĺēćţ à ŵōŕķśƥàćē ţō ēxƥōŕţ.", "selectAWorkspaceToViewActivity": "Śēĺēćţ à ŵōŕķśƥàćē ţō vĩēŵ àćţĩvĩţŷ.", "selectAWorkspaceToViewCosts": "Śēĺēćţ à ŵōŕķśƥàćē ţō vĩēŵ ćōśţś.", "selectExecutionProfile": "Śēĺēćţ ēxēćũţĩōń ƥŕōƒĩĺē", @@ -831,6 +835,7 @@ "skillMdContent": "ŚĶĨĹĹ.ḿď Ćōńţēńţ", "skillNameInstructionsForTheAgent": "# Śķĩĺĺ Ńàḿē\n\nĨńśţŕũćţĩōńś ƒōŕ ţĥē àĝēńţ...", "skills": "Śķĩĺĺś", + "skillUnavailable": "Śķĩĺĺ ũńàṽàĩĺàƀĺē", "skillSourceGithub": "ĜĩţĤũƀ", "skillSourceGitRepository": "Ĝĩţ Ŕēƥōśĩţōŕŷ", "skillSourceInline": "Ĩńĺĩńē", @@ -1007,6 +1012,7 @@ "workspaceName": "Ŵōŕķśƥàćē ńàḿē", "workspacePausedDetail": "Ƥàũśēď ƀŷ {{actor}}: {{reason}} ({{time}})", "workspacePausedTitle": "Ŵōŕķśƥàćē ƥàũśēď", + "workspaceActions": "Ŵōŕķśƥàćē àćţĩōńś", "writeTheDatabaseStateToOn": "Ŵŕĩţē ţĥē ďàţàƀàśē śţàţē ţō ōń-ďĩśķ ŶÀḾĹ ƒĩĺēś.", "yearly": "Ŷēàŕĺŷ", "yourModel": "ŷōũŕ ḿōďēĺ", diff --git a/apps/web/src/locales/pt-pt/office.json b/apps/web/src/locales/pt-pt/office.json index ef584c1139..090b96790f 100644 --- a/apps/web/src/locales/pt-pt/office.json +++ b/apps/web/src/locales/pt-pt/office.json @@ -313,6 +313,7 @@ "exportFilesCount_one": "Exportar {{count}} ficheiro", "exportFilesCount_other": "Exportar {{count}} ficheiros", "exportSelectionSummary": "Exportação de {{workspace}} · {{selected}} / {{total}} ficheiros selecionados", + "exportConfigurationChanged": "A configuração mudou. Volte a carregar a pré-visualização antes de descarregar.", "exportSvg": "Exportar SVG", "exportToFilesystem": "Exportar para o sistema de ficheiros", "failed": "Falhadas", @@ -348,6 +349,7 @@ "failedToLoadDeletionSummary": "Não foi possível carregar o resumo da eliminação", "failedToLoadDiffs": "Não foi possível carregar os diffs", "failedToLoadExportBundle": "Não foi possível carregar o pacote de exportação", + "failedToDownloadExport": "Não foi possível descarregar a exportação selecionada", "failedToLoadInstructions": "Não foi possível carregar as instruções", "failedToLoadMemory": "Não foi possível carregar a memória", "failedToLoadMoreTasks": "Não foi possível carregar mais tarefas", @@ -438,6 +440,7 @@ "live": "Ativa", "liveNow": "Ativa agora", "loadingExportBundle": "A carregar o pacote de exportação...", + "backToExportFiles": "Voltar aos ficheiros de exportação", "loadingInstructions": "A carregar as instruções...", "loadingProject": "A carregar o projeto...", "loadingRuns": "A carregar as execuções...", @@ -806,6 +809,7 @@ "selectApprover": "Selecionar aprovador", "selectAProjectToCreateATask": "Selecione um projeto para criar uma tarefa", "selectASkillToView": "Selecione uma competência para ver", + "selectAWorkspaceToExport": "Selecione uma área de trabalho para exportar.", "selectAWorkspaceToViewActivity": "Selecione uma área de trabalho para ver a atividade.", "selectAWorkspaceToViewCosts": "Selecione uma área de trabalho para ver os custos.", "selectExecutionProfile": "Selecionar perfil de execução", @@ -831,6 +835,7 @@ "skillMdContent": "Conteúdo do SKILL.md", "skillNameInstructionsForTheAgent": "# Nome da competência\n\nInstruções para o agente...", "skills": "Competências", + "skillUnavailable": "Competência indisponível", "skillSourceGithub": "GitHub", "skillSourceGitRepository": "Repositório Git", "skillSourceInline": "Em linha", @@ -1007,6 +1012,7 @@ "workspaceName": "Nome da área de trabalho", "workspacePausedDetail": "Pausado por {{actor}}: {{reason}} ({{time}})", "workspacePausedTitle": "Área de trabalho pausada", + "workspaceActions": "Ações da área de trabalho", "writeTheDatabaseStateToOn": "Escrever o estado da base de dados em ficheiros YAML no disco.", "yearly": "Anual", "yourModel": "o seu modelo", diff --git a/apps/web/src/locales/zh-cn/office.json b/apps/web/src/locales/zh-cn/office.json index 07334ffad5..1741a94010 100644 --- a/apps/web/src/locales/zh-cn/office.json +++ b/apps/web/src/locales/zh-cn/office.json @@ -313,6 +313,7 @@ "exportFilesCount_one": "导出 {{count}} 个文件", "exportFilesCount_other": "导出 {{count}} 个文件", "exportSelectionSummary": "{{workspace}} 导出 · 已选 {{selected}} / {{total}} 个文件", + "exportConfigurationChanged": "配置已更改。请重新加载预览后再下载。", "exportSvg": "导出 SVG", "exportToFilesystem": "导出到文件系统", "failed": "失败", @@ -348,6 +349,7 @@ "failedToLoadDeletionSummary": "加载删除摘要失败", "failedToLoadDiffs": "加载差异失败", "failedToLoadExportBundle": "加载导出包失败", + "failedToDownloadExport": "下载所选导出失败", "failedToLoadInstructions": "加载说明失败", "failedToLoadMemory": "加载记忆失败", "failedToLoadMoreTasks": "加载更多任务失败", @@ -438,6 +440,7 @@ "live": "实时", "liveNow": "当前实时", "loadingExportBundle": "正在加载导出包…", + "backToExportFiles": "返回导出文件", "loadingInstructions": "正在加载说明…", "loadingProject": "正在加载项目…", "loadingRuns": "正在加载运行…", @@ -806,6 +809,7 @@ "selectApprover": "选择审批人", "selectAProjectToCreateATask": "选择项目以创建任务", "selectASkillToView": "选择要查看的技能", + "selectAWorkspaceToExport": "选择一个工作区以导出。", "selectAWorkspaceToViewActivity": "选择工作区以查看活动。", "selectAWorkspaceToViewCosts": "选择工作区以查看费用。", "selectExecutionProfile": "选择执行配置", @@ -831,6 +835,7 @@ "skillMdContent": "SKILL.md 内容", "skillNameInstructionsForTheAgent": "# 技能名称\n\n给智能体的说明…", "skills": "技能", + "skillUnavailable": "技能不可用", "skillSourceGithub": "GitHub", "skillSourceGitRepository": "Git 仓库", "skillSourceInline": "内联", @@ -1007,6 +1012,7 @@ "workspaceName": "工作区名称", "workspacePausedDetail": "由 {{actor}} 暂停:{{reason}}({{time}})", "workspacePausedTitle": "工作区已暂停", + "workspaceActions": "工作区操作", "writeTheDatabaseStateToOn": "将数据库状态写入磁盘上的 YAML 文件。", "yearly": "每年", "yourModel": "你的模型", diff --git a/apps/web/src/locales/zh-hk/office.json b/apps/web/src/locales/zh-hk/office.json index b8dfec3547..512af53435 100644 --- a/apps/web/src/locales/zh-hk/office.json +++ b/apps/web/src/locales/zh-hk/office.json @@ -313,6 +313,7 @@ "exportFilesCount_one": "匯出 {{count}} 個檔案", "exportFilesCount_other": "匯出 {{count}} 個檔案", "exportSelectionSummary": "{{workspace}} 匯出 · 已選 {{selected}} / {{total}} 個檔案", + "exportConfigurationChanged": "設定已變更。請重新載入預覽後再下載。", "exportSvg": "匯出 SVG", "exportToFilesystem": "匯出到檔案系統", "failed": "失敗", @@ -348,6 +349,7 @@ "failedToLoadDeletionSummary": "載入刪除摘要失敗", "failedToLoadDiffs": "載入差異失敗", "failedToLoadExportBundle": "載入匯出包失敗", + "failedToDownloadExport": "下載所選匯出失敗", "failedToLoadInstructions": "載入說明失敗", "failedToLoadMemory": "載入記憶失敗", "failedToLoadMoreTasks": "載入更多任務失敗", @@ -438,6 +440,7 @@ "live": "實時", "liveNow": "目前實時", "loadingExportBundle": "正在載入匯出包…", + "backToExportFiles": "返回匯出檔案", "loadingInstructions": "正在載入說明…", "loadingProject": "正在載入項目…", "loadingRuns": "正在載入執行…", @@ -806,6 +809,7 @@ "selectApprover": "選擇審批人", "selectAProjectToCreateATask": "選擇項目以建立任務", "selectASkillToView": "選擇要查看的技能", + "selectAWorkspaceToExport": "選擇工作區以匯出。", "selectAWorkspaceToViewActivity": "選擇工作區以查看活動。", "selectAWorkspaceToViewCosts": "選擇工作區以查看費用。", "selectExecutionProfile": "選擇執行設定", @@ -831,6 +835,7 @@ "skillMdContent": "SKILL.md 內容", "skillNameInstructionsForTheAgent": "# 技能名稱\n\n給代理程式的說明…", "skills": "技能", + "skillUnavailable": "技能不可用", "skillSourceGithub": "GitHub", "skillSourceGitRepository": "Git 儲存庫", "skillSourceInline": "內聯", @@ -1007,6 +1012,7 @@ "workspaceName": "工作區名稱", "workspacePausedDetail": "由 {{actor}} 暫停:{{reason}}({{time}})", "workspacePausedTitle": "工作區已暫停", + "workspaceActions": "工作區操作", "writeTheDatabaseStateToOn": "將數據庫狀態寫入磁碟上的 YAML 檔案。", "yearly": "每年", "yourModel": "你的模型", diff --git a/apps/web/src/locales/zh-tw/office.json b/apps/web/src/locales/zh-tw/office.json index ac216f500b..ba18a492da 100644 --- a/apps/web/src/locales/zh-tw/office.json +++ b/apps/web/src/locales/zh-tw/office.json @@ -313,6 +313,7 @@ "exportFilesCount_one": "匯出 {{count}} 個檔案", "exportFilesCount_other": "匯出 {{count}} 個檔案", "exportSelectionSummary": "{{workspace}} 匯出 · 已選 {{selected}} / {{total}} 個檔案", + "exportConfigurationChanged": "設定已變更。請重新載入預覽後再下載。", "exportSvg": "匯出 SVG", "exportToFilesystem": "匯出到檔案系統", "failed": "失敗", @@ -348,6 +349,7 @@ "failedToLoadDeletionSummary": "載入刪除摘要失敗", "failedToLoadDiffs": "載入差異失敗", "failedToLoadExportBundle": "載入匯出包失敗", + "failedToDownloadExport": "下載所選匯出失敗", "failedToLoadInstructions": "載入說明失敗", "failedToLoadMemory": "載入記憶失敗", "failedToLoadMoreTasks": "載入更多工失敗", @@ -438,6 +440,7 @@ "live": "即時", "liveNow": "目前即時", "loadingExportBundle": "正在載入匯出包…", + "backToExportFiles": "返回匯出檔案", "loadingInstructions": "正在載入說明…", "loadingProject": "正在載入專案…", "loadingRuns": "正在載入執行…", @@ -806,6 +809,7 @@ "selectApprover": "選擇審批人", "selectAProjectToCreateATask": "選擇專案以建立任務", "selectASkillToView": "選擇要檢視的技能", + "selectAWorkspaceToExport": "選擇工作區以匯出。", "selectAWorkspaceToViewActivity": "選擇工作區以檢視活動。", "selectAWorkspaceToViewCosts": "選擇工作區以檢視費用。", "selectExecutionProfile": "選擇執行設定", @@ -831,6 +835,7 @@ "skillMdContent": "SKILL.md 內容", "skillNameInstructionsForTheAgent": "# 技能名稱\n\n給代理程式的說明…", "skills": "技能", + "skillUnavailable": "技能不可用", "skillSourceGithub": "GitHub", "skillSourceGitRepository": "Git 儲存庫", "skillSourceInline": "內聯", @@ -1007,6 +1012,7 @@ "workspaceName": "工作區名稱", "workspacePausedDetail": "由 {{actor}} 暫停:{{reason}}({{time}})", "workspacePausedTitle": "工作區已暫停", + "workspaceActions": "工作區操作", "writeTheDatabaseStateToOn": "將資料庫狀態寫入磁碟上的 YAML 檔案。", "yearly": "每年", "yourModel": "你的模型", diff --git a/apps/web/src/office-routes.tsx b/apps/web/src/office-routes.tsx index 6ba07412b9..d83dedbcd1 100644 --- a/apps/web/src/office-routes.tsx +++ b/apps/web/src/office-routes.tsx @@ -18,6 +18,7 @@ import type { SetupWizardRouteProps } from "@/app/office/setup/setup-route-data" import ProviderRoutingPage from "@/app/office/workspace/routing/page"; import { RoutinesPageClient } from "@/app/office/routines/routines-page-client"; import SettingsPage from "@/app/office/workspace/settings/page"; +import ExportPage from "@/app/office/workspace/settings/export/page"; import SyncPage from "@/app/office/workspace/settings/sync/page"; import OrgPage from "@/app/office/workspace/org/page"; import IssueDetailPage from "@/app/office/tasks/[id]/page"; @@ -68,6 +69,7 @@ const OFFICE_ROUTES: Record = { ), + "/office/workspace/settings/export": () => , "/office/workspace/settings/sync": () => , "/office/workspace/org": () => , }; diff --git a/docs/decisions/2026-09-17-office-taskless-run-sessions.md b/docs/decisions/2026-09-17-office-taskless-run-sessions.md new file mode 100644 index 0000000000..8359f017e7 --- /dev/null +++ b/docs/decisions/2026-09-17-office-taskless-run-sessions.md @@ -0,0 +1,58 @@ +# ADR-2026-09-17-office-taskless-run-sessions: Run-owned Office sessions + +**Status:** accepted +**Date:** 2026-09-17 +**Area:** backend + +## Context + +Lightweight routines intentionally enqueue runs without task IDs. The scheduler +specification describes fresh sessions for those runs, but both concrete and +provider-routed launch paths require a task. The September 17 investigation +confirmed repeated coordinator failures before any model invocation. The user +explicitly chose genuine taskless coordinator sessions over creating visible tasks. + +Task sessions have task ownership constraints. Runtime admission, event consumers, +resource recovery, and Office pause currently assume task sessions in several +places. Removing the scheduler guard alone cannot deliver a working lifecycle. + +## Decision + +Office owns durable run sessions, separately from task sessions. A taskless launch +creates neither a visible nor a hidden task. Each execution attempt has a fresh +session identity bound to the exact workspace, Office agent, run and attempt. +The shared agent runtime remains the only process/executor owner. + +Extend runtime admission with an explicit, trusted execution-owner discriminator +and an owner-specific admission provider. Existing task owners retain their +current checks. Run-owned admission verifies the durable Office session, claimed +run, workspace pause state and cancellation intent. It must not fall back to task +admission or bypass admission after a failed lookup. + +Office lifecycle projection accepts exact run/session/attempt identity; it must +not infer a current taskless run from whichever run an agent happens to hold. +Task-specific consumers do not mutate task state for run-owned events. Runtime +process startup, prompt delivery, skills, permissions, executor handling and +provider classification continue through the existing runtime implementation. + +## Consequences + +A separate Office session table and lifecycle adapter are required, including +pause, cleanup, restart reconciliation and usage attribution. Runtime interfaces +must express owner identity without importing Office packages. The Office design +owns the vertical behavior; shared runtime code implements the admission seam. +Existing task tables, task-session foreign keys and task-only APIs stay strict. + +A successful taskless turn ends that session. The next fire or retry starts a new +session and consumes only the existing bounded continuation summary. Persisted +failed runs from the old unsupported-launch path remain history; they are not +silently replayed on upgrade. + +## Alternatives Considered + +- Visible tasks per fire: rejected by the user and inconsistent with lightweight routines. +- Hidden synthetic tasks: would move the same invented work into storage and retain task lifecycle coupling. +- Nullable task ownership in all task sessions: would broaden task API, cleanup and workflow invariants beyond this capability. +- Host utility inference: intended for sessionless utility completions, not a managed Office tool-using run with its own lifecycle. + +See [taskless sessions](../specs/office/system-design/taskless-run-sessions.md). diff --git a/docs/plans/office-mode-repairs/plan.md b/docs/plans/office-mode-repairs/plan.md new file mode 100644 index 0000000000..18e0143f1d --- /dev/null +++ b/docs/plans/office-mode-repairs/plan.md @@ -0,0 +1,255 @@ +--- +created: 2026-09-17 +status: complete +requirements: + - REQ-OFFICE-TASKLESS-001 + - REQ-OFFICE-RUN-OBSERVATION-001 + - REQ-OFFICE-DASHBOARD-001 + - REQ-OFFICE-KILL-SWITCH-006 + - REQ-OFFICE-CONFIG-EXPORT-001 +system_design: + - ../../specs/office/system-design/taskless-run-sessions.md + - ../../specs/office/system-design/run-observation.md + - ../../specs/office/system-design/workspace-topbar-actions.md + - ../../specs/office/system-design/workspace-kill-switch-02.md + - ../../specs/office/system-design/config-export.md +legacy_specs: [] +--- + +# Implementation Plan: Office Mode Repairs + +## Overview + +Deliver genuine taskless coordinator execution and repair Office run/activity +identity, workspace action placement and configuration export. Implemented in the +primary session, sequentially, with the user-authorized plan package. + +Office owns all four vertical contracts. Shared runtime, task ownership and +workspace authorization are dependencies, not alternate specification owners. +[ADR](../../decisions/2026-09-17-office-taskless-run-sessions.md) records why taskless +sessions are run-owned instead of synthetic tasks or nullable task sessions. + +## Confirmed evidence + +The read-only diagnostic bundle from port 38429 reported ready, no warnings, +version v0.94.0-201-g6d8e54ffd. Run `a99a415f-aacf-4eaa-a2ea-0bfd92c058db` +was dispatched at 14:40:26 UTC and rejected at 14:40:31 UTC on September 17: +`cannot launch taskless run`, agent CEO, reason routine_dispatch_cron, executor +local_pc. Another rejection occurred at 14:45:31 UTC. No model launch occurred +for the rejected run. The checkout at investigation was e8e8bcff6, distinct from +the live binary; source inspection independently confirmed both rejection gates. +The diagnostic temporary directory was removed; no live settings were changed. + +Other confirmed causes: + +- `dashboard/run_detail.go:buildInvocation` assigns agent.ID as adapter. +- RuntimePanel prints skill_id; ActivityRow prints actorId and targetId. +- OfficeShell mounts WorkspacePauseState under PageShell; paused, stale and + unavailable branches own the conditional second row. Refresh reads pause + state only. +- OFFICE_ROUTES omits the existing export page. Both export endpoints returned + 200 and a 34-entry ZIP passed integrity checks. Selection is ignored by download; + frontend preview independently serializes YAML and omits the ZIP path prefix. + +## Scope + +In scope: taskless run lifecycle and exact attribution; readable run/activity +labels; topbar workspace actions; truthful selected-file export; desktop/mobile +coverage and relevant public documentation. + +Out of scope: changing routine cadence or idle defaults, interactive taskless +chat, replaying historical failures, broad activity wording redesign, import/sync +mutation redesign, unrelated placeholder routes, and live-instance changes. PR +publication is handled as the delivery step for this implementation. + +## Technical approach + +1. Add Office run-session persistence and a typed shared-runtime owner/start seam. + Task lifecycle invariants remain intact. No launch path is enabled until its + admission, cancellation and reconciliation are wired. +2. Integrate concrete/routed scheduler launch, first prompt, exact lifecycle + events, usage, continuation, stop and restart recovery as one functioning slice. +3. Add additive name/snapshot projections and update run/activity rendering. +4. Share one pause controller between topbar controls and conditional banners. +5. Register export route and use one server manifest for preview/selected ZIP. + +New API/type/file names in the work orders are proposed outputs, not claims that +they already exist. Existing integration points were inspected in source. + +## ASCII UI preview + +The control order, identity hierarchy, phone composition and failure affordances +below are structural requirements. Copy and spacing are illustrative; use existing +localized strings and UI primitives where possible. Phone views use a single +content scroll owner and safe-area-aware controls. No document horizontal overflow. + +```text +UI-01: Agent Runs / Activity, populated and missing identity +Desktop +[Agents > CEO Refresh pause Pause workspace] +[Run status | CEO | duration | cost] +[Adapter: Codex Model: ] +[Skills: Planning v... hash...] +[Activity: CEO recorded a decision on KAN-14 Build report] + +Phone +[< CEO Runs Workspace actions] +[Status / duration / cost] +[Adapter: Codex] +[Skills: Planning / version / hash] +[CEO recorded a decision] +[KAN-14 Build report] + +Missing: [Skill unavailable (04957907)] +Before invocation: [Adapter: Not started] +``` + +```text +UI-02: Office shell, running / paused / unknown +Before (desktop, from source and screenshot) +[Breadcrumb ] +[ Refresh Pause workspace] +[Page content] + +After desktop +[Breadcrumb Page actions | Refresh pause | Pause workspace] +[Paused / stale / unavailable banner, only when relevant] +[Page content, scrolling] + +After phone +[Menu Page title Workspace actions] +[Paused / stale / unavailable banner when relevant] +[Page content, scrolling] + +Workspace actions opens inset bottom drawer: +[Workspace name] +[Refresh pause status] +[Pause workspace / Resume workspace] +[Close] +Pause then opens the existing reason + confirmation flow. +Partial stop: banner retains [Retry stop]. +``` + +```text +UI-03: Preferences > Export +Desktop +[Preferences > Export Workspace actions] +[Workspace / selected count Download selected] +[File list + checkboxes | Selected file preview] + +Phone: file list +[< Preferences Export Workspace actions] +[Workspace / selected count] +[[x] .kandev/kandev.yml View] +[[x] .kandev/agents/ceo.yml View] +[Download selected] + +Phone: selected file +[< Back to files ceo.yml] +[Full-width content, contained code scroll] + +Loading: [Loading export...] +No workspace: [Select a workspace] +Failed load: [Could not load export] [Retry] +Empty/none selected: [No files selected] [Download disabled] +Stale download: [Configuration changed] [Reload preview] +``` + + +UI-01 maps to AC-OFFICE-RUN-OBSERVATION-001.1-.5; UI-02 to +AC-OFFICE-KILL-SWITCH-006.4-.14; UI-03 to AC-OFFICE-CONFIG-EXPORT-001.1-.4. + +## Tests + +Each work order lists exact commands and proposed regression test names. First +run the new regression red, then implement. Include existing neighboring tests +when they protect affected lifecycle contracts. The runtime task is intentionally +an internal compatibility expansion; the following task activates it only after +end-to-end lifecycle checks pass. Do not weaken tests by accepting a skipped +Postgres check as database compatibility evidence. + +## E2E tests + +- `office/taskless-routine-session.spec.ts`: real mock-agent initial prompt and + completion, no task created, actual invocation/usage, second fresh session, + pause during execution (taskless .1-.5, .8). +- `office/run-observation.spec.ts` and `office/mobile-run-observation.spec.ts`: + direct run/activity entry, names, legacy/missing/long labels (observation .1-.5). +- Existing `office/workspace-kill-switch.spec.ts` and + `office/mobile-workspace-kill-switch.spec.ts`: topbar and all pause states (.4-.14). +- `office/config-export.spec.ts` and `office/mobile-config-export.spec.ts`: + navigation, exact selected ZIP contents, preview/back, retry and workspace + switching (export .1-.4). + +Use chromium for desktop and mobile-chrome for mobile. Build fresh backend/web +assets before each affected E2E run and use guarded repository runners with their +normal worker limits. Do not run overlapping full suites or touch the live instance. + +## Work orders + +- [x] [01: Run-owned runtime foundation](task-01-run-session-foundation.md) +- [x] [02: Taskless scheduler lifecycle](task-02-taskless-lifecycle.md) +- [x] [03: Run and activity names](task-03-run-observation.md) +- [x] [04: Workspace topbar actions](task-04-workspace-topbar.md) +- [x] [05: Configuration export](task-05-config-export.md) + +## Verification results + +Design and implementation checks passed on September 17, 2026: + +- `python3 scripts/list-docs.py validate`: 286 decisions and 994 specifications. +- `python3 scripts/lint-spec-files.py --all`: all specifications passed. +- `python3 scripts/lint-spec-files.test.py`: 36 tests passed. +- Work-order reference/link check: all five orders resolve their requirement, + acceptance and design references. +- `git diff --check -- docs/specs docs/decisions docs/plans/office-mode-repairs`: passed. +- `git status --short -- docs/plans/office-mode-repairs`: package present with + all five completed work orders and recorded results. + +Implementation evidence: + +- `GOCACHE=/tmp/kandev-go-cache go test ./internal/office/dashboard ./internal/office/config ./internal/office/repository/sqlite ./internal/office/service ./internal/office/pause ./internal/office/scheduler ./internal/backendapp -count=1`: passed. +- `GOCACHE=/tmp/kandev-go-cache go test ./internal/office/service ./internal/office/pause -run 'Taskless|RunSession|Pause' -race -count=1`: passed. +- Focused frontend regressions: 5 files and 25 tests passed for activity labels, runtime labels, run headers, pause controls and export API. +- `pnpm run typecheck && pnpm run i18n:check`: passed; all translated catalogs remain complete and the non-JSX copy gate is clean. +- `python3 scripts/list-docs.py validate`, `python3 scripts/lint-spec-files.py --all`, and `node scripts/validate-public-docs.mjs`: passed. +- `GOCACHE=/tmp/kandev-go-cache make -C apps/backend build` and `pnpm --filter @kandev/web build:vite`: passed. The E2E runner's first host build required the cache override because the sandbox denies the default Go cache path. +- New desktop/mobile E2E specs parse successfully with Playwright project listings. The taskless routine spec passed in the Docker-backed runner, including a real mock-agent invocation with no task row. The corrected run-observation, export and mobile specs could not complete in this environment: the Docker rerun was stopped during a fresh glibc-matched backend image build, and the native host runner was blocked before browser execution by Chromium's MachPort sandbox permission error. The remaining browser checks are therefore recorded as environment-blocked rather than passing evidence. +- PostgreSQL migration tests were skipped because `KANDEV_TEST_POSTGRES_DSN` is unset; this is not PostgreSQL compatibility evidence. + +## Risks + +- Runtime ownership/admission and startup are cross-cutting. An empty task ID + bypass is not an acceptable implementation of taskless support. +- Pause/recovery must stop every live taskless execution, including a failed + predecessor beside a live successor; cancellation cannot rely on queue state. +- Recorded adapter evidence may not exist for historical runs. Show unknown, + not a current-profile guess presented as history. +- Preview manifests can stale; revision checking deliberately returns a visible + conflict rather than exporting different content from that reviewed. +- Phone drawer focus and full-height export preview need rendered checks. +- PostgreSQL proof requires an isolated configured test database. Missing DSN is + an explicit verification blocker for the affected migration work. + +## Review remediation (2026-09-18) + +The four local review findings were corrected in the primary session: +run-owned runtime registration and scratch workspaces, normal ACP turn completion, +workspace/run cost attribution, and mobile confirmation lifetime. Regression +coverage now uses the production runtime inventory writer and serialized usage +frames. The taskless E2E fixture assigns its routine and requires two finished +agent runs with distinct session IDs. Mobile confirmation remains outside the +closing actions drawer, preserving the existing UI-04 composition and controls. + +Delivery uses an independent checkout because this workspace's Git metadata is +outside the writable sandbox. Source changes remain in this workspace too. + +Remediation verification: + +- Office service, scheduler, pause, runtime, wakeup, costs and runs repository suites passed. +- Runtime/Office run-owner, taskless and run-session regression tests passed with `-race`. +- Backend composition checks (`Taskless|Routine.*Session|Office.*Scope`) passed. +- `taskless-routine-session.spec.ts` passed against a rebuilt backend with two real mock-agent completions. +- Mobile pause/resume component regressions, web typecheck and focused ESLint passed. +- Go lint reported zero issues; documentation catalog and specification lint passed. +- Native mobile Playwright was blocked by macOS Mach-port permissions. The rebuilt Docker mobile-chrome run of `mobile-workspace-kill-switch.spec.ts` passed, including pause and resume after drawer unmount. diff --git a/docs/plans/office-mode-repairs/task-01-run-session-foundation.md b/docs/plans/office-mode-repairs/task-01-run-session-foundation.md new file mode 100644 index 0000000000..2d0b0b528b --- /dev/null +++ b/docs/plans/office-mode-repairs/task-01-run-session-foundation.md @@ -0,0 +1,108 @@ +--- +id: "01-run-session-foundation" +title: "Run-owned runtime foundation" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-OFFICE-TASKLESS-001 +acceptance_criteria: + - AC-OFFICE-TASKLESS-001.1 + - AC-OFFICE-TASKLESS-001.2 + - AC-OFFICE-TASKLESS-001.4 + - AC-OFFICE-TASKLESS-001.7 + - AC-OFFICE-TASKLESS-001.8 +system_design: + - ../../specs/office/system-design/taskless-run-sessions.md +--- + +# Task 01: Run-owned runtime foundation + +## Summary + +Add durable Office run-session records and a typed runtime owner/start contract. Keep the scheduler on its existing behavior until Task 02 wires the complete lifecycle. + +## In scope + +Own portable migrations, atomic reservation/registration, owner-aware runtime admission, +and the runtime start/prompt seam. Add exact owner identity to execution/events. +Existing task callers retain their preparation and startup contracts. Add Office +interfaces for durable admission and registration without runtime importing Office. +Update scoped backend/Office AGENTS guidance for the new ownership boundary. + +## Out of scope + +Other work orders, unrelated refactors, live-instance changes and publication. + +## Acceptance + +- A fresh run session is reserved atomically, bound to the claimed run and independently queryable; retries cannot overwrite predecessor identities. +- Run-owner admission is checked around allocation/registration/start and rejects mismatch, cancellation, paused workspace and read failure; task admission remains strict. +- Runtime start dispatches one initial prompt and cleans partial allocation on failure; persistence and runtime contract tests pass on supported database backends. + +## Regression evidence + +Add TestRunSessionReservationCAS, TestPostgresRunSessionReservationCAS, TestRunOwnerAdmissionRejectsStaleAttempt, TestRuntimeStartDispatchesInitialPromptOnce and TestRuntimeStartRollsBackRegistrationFailure. Include duplicate reservation, cross-workspace identity and task-session regression cases. + +## Verification + +Run from the repository root; every command is independently rooted. New test files +named below are outputs of this work order. Record red/green evidence. + +```bash +(cd apps/backend && go test ./internal/office/repository/sqlite -run 'RunSession|PostgresRunSession' -count=1) +(cd apps/backend && go test ./internal/agent/runtime ./internal/agent/runtime/lifecycle -run 'RunOwner|RuntimeStart|Runtime_Launch|LaunchSession|LaunchError|LaunchMetadata|LaunchWorkspace' -count=1) +(cd apps/backend && go test ./internal/agent/runtime ./internal/agent/runtime/lifecycle -run 'RunOwner|RuntimeStart' -race -count=1) +(cd apps/backend && test -n "$KANDEV_TEST_POSTGRES_DSN" && go test ./internal/office/repository/sqlite -run PostgresRunSession -count=1) +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/office/models/` +- `apps/backend/internal/office/repository/sqlite/base.go` +- `apps/backend/internal/office/repository/sqlite/run_sessions.go (new)` +- `apps/backend/internal/office/repository/sqlite/run_sessions_test.go (new)` +- `apps/backend/internal/office/repository/sqlite/run_sessions_postgres_test.go (new)` +- `apps/backend/internal/agent/runtime/runtime.go` +- `apps/backend/internal/agent/runtime/facade.go` +- `apps/backend/internal/agent/runtime/runtime_contract_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_launch.go` +- `apps/backend/internal/agent/runtime/lifecycle/types.go` +- `apps/backend/internal/agent/runtime/lifecycle/events.go` +- `apps/backend/AGENTS.md` +- `apps/backend/internal/office/AGENTS.md` + +## Dependencies + +None + +## Risks + +Every existing task runtime consumer must retain its task invariant. Do not turn unknown ownership into permissive sessionless startup. Postgres DSN must name an isolated test database. + +## Parallelism + +`sequential` + +## Inputs + +Read linked requirements/designs in full, the plan evidence, scoped AGENTS.md, +TDD guidance and the adjacent existing tests before changing code. UI tasks also +read mobile-parity and E2E fixture guidance. + +## Results + +Implemented the portable `office_run_sessions` reservation table and repository +CAS, including workspace ownership validation and immutable retry attempts. +Added `runtime.Start`, owner identity/admission propagation, startup rollback, +and exact run-owner fields on lifecycle events. Existing task launches retain +their prior contracts. + +Verification on 2026-09-17: + +- `(cd apps/backend && GOCACHE=/tmp/kandev-go-cache go test ./internal/office/repository/sqlite -run 'RunSession|PostgresRunSession' -count=1)` passed (Postgres case skipped when `KANDEV_TEST_POSTGRES_DSN` was unset). +- `(cd apps/backend && GOCACHE=/tmp/kandev-go-cache go test ./internal/agent/runtime ./internal/agent/runtime/lifecycle -run 'RunOwner|RuntimeStart|Runtime_Launch|LaunchSession|LaunchError|LaunchMetadata|LaunchWorkspace' -count=1)` passed. +- `(cd apps/backend && GOCACHE=/tmp/kandev-go-cache go test ./internal/agent/runtime ./internal/agent/runtime/lifecycle -run 'RunOwner|RuntimeStart' -race -count=1)` passed. +- `git diff --check` passed. diff --git a/docs/plans/office-mode-repairs/task-02-taskless-lifecycle.md b/docs/plans/office-mode-repairs/task-02-taskless-lifecycle.md new file mode 100644 index 0000000000..cde200ddf3 --- /dev/null +++ b/docs/plans/office-mode-repairs/task-02-taskless-lifecycle.md @@ -0,0 +1,119 @@ +--- +id: "02-taskless-lifecycle" +title: "Taskless scheduler lifecycle" +status: done +wave: 2 +depends_on: ["01-run-session-foundation"] +plan: "plan.md" +requirements: + - REQ-OFFICE-TASKLESS-001 +acceptance_criteria: + - AC-OFFICE-TASKLESS-001.1 + - AC-OFFICE-TASKLESS-001.2 + - AC-OFFICE-TASKLESS-001.3 + - AC-OFFICE-TASKLESS-001.4 + - AC-OFFICE-TASKLESS-001.5 + - AC-OFFICE-TASKLESS-001.6 + - AC-OFFICE-TASKLESS-001.7 + - AC-OFFICE-TASKLESS-001.8 +system_design: + - ../../specs/office/system-design/taskless-run-sessions.md +--- + +# Task 02: Taskless scheduler lifecycle + +## Summary + +Wire genuine taskless sessions through both scheduler launch paths, then carry them through success, usage, failure, cancellation and restart. This task activates the capability only when the full lifecycle works. + +## In scope + +Own Office RunSessionLauncher wiring, routed candidates, session-bound JWT/context, +actual invocation identity, lifecycle/usage dedup, continuation, startup reconciliation, +stop inventory and deletion cleanup. Audit runtime event consumers for task assumptions. +Test every configured executor through the common launch request contract and run +an actual local mock-agent lifecycle, rather than accepting a fake launcher count. +Update docs/public/office-provider-routing.md for taskless identity and the relevant +scoped guidance. Keep the existing coordinator routine/defaults and historical failures. + +## Out of scope + +Other work orders, unrelated refactors, live-instance changes and publication. + +## Acceptance + +- Concrete and routed lightweight cron/manual runs execute a real prompt, finish and create no task rows; subsequent fire/retry has a fresh session and correct bounded continuation. +- Exact attempt events, usage and runtime outcomes are idempotent; failures, pause races and mixed live/failed stop inventories preserve routing and agent state. +- Restart and deletion cleanup stop or reconcile predecessors before replacement; no permanent claimed run or orphan process remains. Task-bound and idle/coalescing behavior stays covered. + +## Regression evidence + +Add TestTasklessRoutineCronToSessionCompletion in backendapp, TestTasklessRoutedFallback, +TestTasklessLateCompletionDoesNotFinishSuccessor, TestTasklessUsageDuplicate, +TestTasklessPauseDuringRegistration, TestTasklessPauseMixedStopResults and +TestTasklessRestartUnknownPredecessorBlocksReplacement. Existing wakeup/routine_e2e_test.go +stops before scheduler integration and is not sufficient evidence. Replace only the +unsupported-launch assertions in scheduler_taskless_launch_test.go; preserve terminal-CAS, +failure counters, event workspace identity and inbox flood protection where applicable. + +## Verification + +Run from the repository root; every command is independently rooted. New test files +named below are outputs of this work order. Record red/green evidence. + +```bash +(cd apps/backend && go test ./internal/office/service ./internal/office/scheduler ./internal/office/pause ./internal/office/runtime ./internal/office/wakeup ./internal/office/costs -count=1) +(cd apps/backend && go test ./internal/backendapp -run 'Taskless|Routine.*Session|Office.*Scope' -count=1) +(cd apps/backend && go test ./internal/office/service ./internal/office/pause -run 'Taskless|RunSession' -race -count=1) +make build-backend +make build-web +(cd apps/web && pnpm e2e:run --project=chromium e2e/tests/office/taskless-routine-session.spec.ts) +node scripts/validate-public-docs.mjs +node --test scripts/validate-public-docs.test.mjs +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/office/service/service.go` +- `apps/backend/internal/office/service/scheduler_integration.go` +- `apps/backend/internal/office/service/scheduler_taskless_launch_test.go` +- `apps/backend/internal/office/service/event_subscribers.go` +- `apps/backend/internal/office/scheduler/dispatch_routing.go` +- `apps/backend/internal/office/pause/sweep.go` +- `apps/backend/internal/office/pause/service.go` +- `apps/backend/internal/office/runtime/context_builder.go` +- `apps/backend/internal/backendapp/adapters_office.go` +- `apps/backend/internal/backendapp/office_taskless_run_session_test.go (new)` +- `apps/backend/internal/agent/runtime/lifecycle/` +- `apps/backend/internal/orchestrator/ (owner guards in event subscribers only)` +- `apps/backend/internal/office/agents/` +- `apps/backend/internal/office/workspaces/` +- `apps/backend/internal/office/costs/` +- `apps/web/e2e/tests/office/taskless-routine-session.spec.ts (new)` +- `docs/public/office-provider-routing.md` + +## Dependencies + +01-run-session-foundation + +## Risks + +Runtime.Start must actually start the process and deliver the prompt; Runtime.Launch alone cannot do this today. Pause inventory must include sessions for already cancelled runs. Never fall back to agent-only attribution for new events. + +## Parallelism + +`sequential` + +## Inputs + +Read linked requirements/designs in full, the plan evidence, scoped AGENTS.md, +TDD guidance and the adjacent existing tests before changing code. UI tasks also +read mobile-parity and E2E fixture guidance. + +## Results + +- Wired concrete and routed taskless scheduler launches through run-owned runtime sessions, including admission, first prompt, lifecycle identity, usage deduplication, continuation, pause cancellation and startup reconciliation. +- Added mixed stop/restart/deletion cleanup coverage and corrected task-owner admission so existing task-bound launches retain their contract while run-owned launches use run admission. +- Updated provider-routing documentation and scoped backend guidance. +- Backend Office suites, backendapp taskless coverage, race checks, public-doc validation and builds passed. The configured Postgres test remains unavailable because `KANDEV_TEST_POSTGRES_DSN` is unset. diff --git a/docs/plans/office-mode-repairs/task-03-run-observation.md b/docs/plans/office-mode-repairs/task-03-run-observation.md new file mode 100644 index 0000000000..6551ef8649 --- /dev/null +++ b/docs/plans/office-mode-repairs/task-03-run-observation.md @@ -0,0 +1,132 @@ +--- +id: "03-run-observation" +title: "Run and activity names" +status: done +wave: 3 +depends_on: ["02-taskless-lifecycle"] +plan: "plan.md" +requirements: + - REQ-OFFICE-RUN-OBSERVATION-001 + - REQ-OFFICE-DASHBOARD-001 +acceptance_criteria: + - AC-OFFICE-RUN-OBSERVATION-001.1 + - AC-OFFICE-RUN-OBSERVATION-001.2 + - AC-OFFICE-RUN-OBSERVATION-001.3 + - AC-OFFICE-RUN-OBSERVATION-001.4 + - AC-OFFICE-RUN-OBSERVATION-001.5 + - AC-OFFICE-DASHBOARD-001.7 + - AC-OFFICE-DASHBOARD-001.8 +system_design: + - ../../specs/office/system-design/run-observation.md +--- + +# Task 03: Run and activity names + +## Summary + +Expose readable run, skill, adapter and activity identity with safe historical fallbacks. Keep stable IDs for navigation and diagnostic use. + +## In scope + +Own additive skill label snapshot migration, actual invocation projection, workspace-scoped +activity name enrichment, normalization and UI rendering. Reuse the same activity +projection for dashboard and workspace feed. Include missing and renamed entities, +legacy runs and routed provider fallback. Localize authored fallbacks. + +## Out of scope + +Other work orders, unrelated refactors, live-instance changes and publication. + +## Acceptance + +- Named agent/skill/task labels render on direct entry without sidebar preloading; missing and foreign entities degrade safely. +- Adapter/model are actual recorded invocation evidence; legacy unknown values are explicit and new skill snapshots retain labels. +- Desktop/mobile long-name and missing-data E2E pass; IDs remain correct in links and no per-row request fanout is added. + +## ASCII UI preview + +[Full preview](plan.md#ascii-ui-preview). + +```text +UI-01: Agent Runs / Activity, populated and missing identity +Desktop +[Agents > CEO Refresh pause Pause workspace] +[Run status | CEO | duration | cost] +[Adapter: Codex Model: ] +[Skills: Planning v... hash...] +[Activity: CEO recorded a decision on KAN-14 Build report] + +Phone +[< CEO Runs Workspace actions] +[Status / duration / cost] +[Adapter: Codex] +[Skills: Planning / version / hash] +[CEO recorded a decision] +[KAN-14 Build report] + +Missing: [Skill unavailable (04957907)] +Before invocation: [Adapter: Not started] +``` + +Control grouping and phone composition are required; spacing and wording are illustrative. + +## Regression evidence + +Add TestRunDetailActualInvocation, TestRunSkillSnapshotLabelRetention, TestPostgresRunSkillLabelMigration and TestActivityLabelsWorkspaceScoped. Extend runtime-panel/activity-row tests with rename/delete, legacy, scheduler actor and long-name cases. + +## Verification + +Run from the repository root; every command is independently rooted. New test files +named below are outputs of this work order. Record red/green evidence. + +```bash +(cd apps/backend && go test ./internal/office/dashboard ./internal/office/repository/sqlite -run 'RunDetail|RunSkill|Activity.*Label|Activity.*Name|PostgresRunSkill' -count=1) +(cd apps/backend && test -n "$KANDEV_TEST_POSTGRES_DSN" && go test ./internal/office/repository/sqlite -run PostgresRunSkill -count=1) +(cd apps/web && pnpm exec vitest run 'app/office/agents/[id]/runs/components/runtime-panel.test.tsx' 'app/office/agents/[id]/runs/components/run-header.test.tsx' app/office/workspace/activity/activity-row.test.tsx lib/api/domains/office-activity-normalize.test.ts) +(cd apps/web && pnpm run typecheck && pnpm run i18n:check) +make build-backend +make build-web +(cd apps/web && pnpm e2e:run --project=chromium e2e/tests/office/run-observation.spec.ts e2e/tests/office/runtime-skills.spec.ts e2e/tests/office/activity-page.spec.ts) +(cd apps/web && pnpm e2e:run --project=mobile-chrome e2e/tests/office/mobile-run-observation.spec.ts) +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/office/dashboard/run_detail.go` +- `apps/backend/internal/office/dashboard/dto.go` +- `apps/backend/internal/office/dashboard/service.go` +- `apps/backend/internal/office/repository/sqlite/` +- `apps/backend/internal/office/service/scheduler_integration.go` +- `apps/web/lib/api/domains/office-runs-api.ts` +- `apps/web/lib/api/domains/office-activity-normalize.ts` +- `apps/web/lib/state/slices/office/types.ts` +- `apps/web/app/office/agents/[id]/runs/components/` +- `apps/web/app/office/workspace/activity/activity-row.tsx` +- `apps/web/src/locales/` +- `apps/web/e2e/tests/office/run-observation.spec.ts (new)` +- `apps/web/e2e/tests/office/mobile-run-observation.spec.ts (new)` + +## Dependencies + +02-taskless-lifecycle + +## Risks + +Historical labels and adapters may be unknowable; do not fabricate historical truth from mutable profiles. Keep enrichment within workspace ownership and bounded query count. + +## Parallelism + +`sequential` + +## Inputs + +Read linked requirements/designs in full, the plan evidence, scoped AGENTS.md, +TDD guidance and the adjacent existing tests before changing code. UI tasks also +read mobile-parity and E2E fixture guidance. + +## Results + +- Added persisted skill display labels and source metadata, actual invocation projection, workspace-scoped activity label enrichment and safe legacy fallbacks. +- Updated desktop/mobile run, skill and activity rendering while preserving stable IDs and avoiding per-row fetch fanout. +- Added backend label tests and frontend regressions. The focused frontend suite passed 5 files and 25 tests; typecheck and the complete i18n gate passed. Postgres migration evidence remains skipped without `KANDEV_TEST_POSTGRES_DSN`. diff --git a/docs/plans/office-mode-repairs/task-04-workspace-topbar.md b/docs/plans/office-mode-repairs/task-04-workspace-topbar.md new file mode 100644 index 0000000000..0b4f10e667 --- /dev/null +++ b/docs/plans/office-mode-repairs/task-04-workspace-topbar.md @@ -0,0 +1,131 @@ +--- +id: "04-workspace-topbar" +title: "Workspace topbar actions" +status: done +wave: 4 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-OFFICE-KILL-SWITCH-006 +acceptance_criteria: + - AC-OFFICE-KILL-SWITCH-006.4 + - AC-OFFICE-KILL-SWITCH-006.5 + - AC-OFFICE-KILL-SWITCH-006.6 + - AC-OFFICE-KILL-SWITCH-006.7 + - AC-OFFICE-KILL-SWITCH-006.8 + - AC-OFFICE-KILL-SWITCH-006.12 + - AC-OFFICE-KILL-SWITCH-006.13 + - AC-OFFICE-KILL-SWITCH-006.14 +system_design: + - ../../specs/office/system-design/workspace-topbar-actions.md + - ../../specs/office/system-design/workspace-kill-switch-02.md +--- + +# Task 04: Workspace topbar actions + +## Summary + +Move workspace controls into Office topbar while preserving the existing pause state machine and page-specific actions. + +## In scope + +Own shared controller placement, topbar action composition and conditional banners. +Implement phone workspace-actions drawer using the existing mobile menu geometry, +then hand off to the existing pause/resume confirmation with focus return. Preserve +refresh's pause-state meaning, partial-stop retry and workspace switch behavior. + +## Out of scope + +Other work orders, unrelated refactors, live-instance changes and publication. + +## Acceptance + +- Desktop topbar contains page actions plus refresh and pause/resume; no second running-state toolbar remains. +- Phone controls are discoverable, at least 44px, and preserve confirmation, reason, resume and partial-failure retry without stacked focus traps. +- One pause controller serves all surfaces; failed refresh retains stale/unknown state and workspace changes cannot operate on a stale target. + +## ASCII UI preview + +[Full preview](plan.md#ascii-ui-preview). + +```text +UI-02: Office shell, running / paused / unknown +Before (desktop, from source and screenshot) +[Breadcrumb ] +[ Refresh Pause workspace] +[Page content] + +After desktop +[Breadcrumb Page actions | Refresh pause | Pause workspace] +[Paused / stale / unavailable banner, only when relevant] +[Page content, scrolling] + +After phone +[Menu Page title Workspace actions] +[Paused / stale / unavailable banner when relevant] +[Page content, scrolling] + +Workspace actions opens inset bottom drawer: +[Workspace name] +[Refresh pause status] +[Pause workspace / Resume workspace] +[Close] +Pause then opens the existing reason + confirmation flow. +Partial stop: banner retains [Retry stop]. +``` + +Control grouping and phone composition are required; spacing and wording are illustrative. + +## Regression evidence + +Add shell test "composes page actions and workspace actions in topbar". Extend kill-switch rendered tests to assert topbar containment, running/paused/unknown states, 390px and 767/768px geometry, failed refresh and drawer-to-confirmation focus. + +## Verification + +Run from the repository root; every command is independently rooted. New test files +named below are outputs of this work order. Record red/green evidence. + +```bash +(cd apps/web && pnpm exec vitest run app/office/components/office-shell.test.tsx app/office/components/workspace-pause-banner.test.tsx components/page-shell.test.tsx) +(cd apps/web && pnpm run typecheck && pnpm run i18n:check) +make build-web +(cd apps/web && pnpm e2e:run --project=chromium e2e/tests/office/workspace-kill-switch.spec.ts) +(cd apps/web && pnpm e2e:run --project=mobile-chrome e2e/tests/office/mobile-workspace-kill-switch.spec.ts e2e/tests/office/mobile-office-navigation.spec.ts) +git diff --check +``` + +## Files likely touched + +- `apps/web/app/office/components/office-shell.tsx` +- `apps/web/app/office/components/workspace-pause-banner.tsx` +- `apps/web/app/office/components/workspace-pause-controls.tsx` +- `apps/web/app/office/components/office-shell.test.tsx (new if absent)` +- `apps/web/app/office/components/workspace-pause-banner.test.tsx (new if absent)` +- `apps/web/hooks/domains/office/use-workspace-pause.ts` +- `apps/web/e2e/tests/office/workspace-kill-switch.spec.ts` +- `apps/web/e2e/tests/office/mobile-workspace-kill-switch.spec.ts` +- `apps/web/src/locales/` + +## Dependencies + +None + +## Risks + +Page actions must not displace workspace actions. Reusing the hook twice would duplicate state/request ownership. Check the 768px composition boundary and coarse-pointer sizes. + +## Parallelism + +`sequential` + +## Inputs + +Read linked requirements/designs in full, the plan evidence, scoped AGENTS.md, +TDD guidance and the adjacent existing tests before changing code. UI tasks also +read mobile-parity and E2E fixture guidance. + +## Results + +- Composed one pause controller into the Office topbar, kept state banners informational, and moved compact layouts into a discoverable workspace-actions drawer with 44px controls. +- Preserved refresh, pause/resume confirmation and partial-stop retry behavior, including drawer close-before-dialog focus handling. +- Added rendered pause/action regressions. Frontend typecheck, i18n validation and production build passed; browser execution is covered by the existing kill-switch specs plus the new mobile identity/export coverage. diff --git a/docs/plans/office-mode-repairs/task-05-config-export.md b/docs/plans/office-mode-repairs/task-05-config-export.md new file mode 100644 index 0000000000..1090c95771 --- /dev/null +++ b/docs/plans/office-mode-repairs/task-05-config-export.md @@ -0,0 +1,136 @@ +--- +id: "05-config-export" +title: "Configuration export" +status: done +wave: 5 +depends_on: ["04-workspace-topbar"] +plan: "plan.md" +requirements: + - REQ-OFFICE-CONFIG-EXPORT-001 +acceptance_criteria: + - AC-OFFICE-CONFIG-EXPORT-001.1 + - AC-OFFICE-CONFIG-EXPORT-001.2 + - AC-OFFICE-CONFIG-EXPORT-001.3 + - AC-OFFICE-CONFIG-EXPORT-001.4 +system_design: + - ../../specs/office/system-design/config-export.md +--- + +# Task 05: Configuration export + +## Summary + +Restore the export route and deliver exact selected-file previews/downloads on desktop and phone. + +## In scope + +Own server manifest/serializer reuse, revision-checked selected ZIP endpoint, workspace +scope registration, authenticated blob download, state/error handling and mobile +list/preview composition. Keep full GET ZIP clients compatible. Update + docs/public/office-config-sync.md with export navigation and selected-download +semantics, clearly distinct from config-sync mutation. + +## Out of scope + +Other work orders, unrelated refactors, live-instance changes and publication. + +## Acceptance + +- Navigation and direct export URLs render the real page with no-workspace/loading/error/retry states and safe workspace changes. +- Preview bytes and selected paths equal ZIP entries; stale revision is explicit and invalid paths/selections cannot escape the manifest or workspace. +- Phone file list/preview/back/download flows and desktop selection/download pass with parsed ZIP evidence, localized copy and no document overflow. + +## ASCII UI preview + +[Full preview](plan.md#ascii-ui-preview). + +```text +UI-03: Preferences > Export +Desktop +[Preferences > Export Workspace actions] +[Workspace / selected count Download selected] +[File list + checkboxes | Selected file preview] + +Phone: file list +[< Preferences Export Workspace actions] +[Workspace / selected count] +[[x] .kandev/kandev.yml View] +[[x] .kandev/agents/ceo.yml View] +[Download selected] + +Phone: selected file +[< Back to files ceo.yml] +[Full-width content, contained code scroll] + +Loading: [Loading export...] +No workspace: [Select a workspace] +Failed load: [Could not load export] [Retry] +Empty/none selected: [No files selected] [Download disabled] +Stale download: [Configuration changed] [Reload preview] +``` + +Control grouping and phone composition are required; spacing and wording are illustrative. + +## Regression evidence + +Add TestExportManifestMatchesZipBytes, TestExportSelectedZipRevisionConflict, TestExportSelectedZipRejectsInvalidPaths, and route-scope coverage. New export-preview tests cover late old-workspace response, zero selection, retry, failed blob fetch and object URL cleanup. E2E inspects ZIP entry names and contents, not merely a download event. + +## Verification + +Run from the repository root; every command is independently rooted. New test files +named below are outputs of this work order. Record red/green evidence. + +```bash +(cd apps/backend && go test ./internal/office/config -count=1) +(cd apps/backend && go test ./internal/backendapp -run 'Office.*Scope|OfficeRouteScopeCompleteness' -count=1) +(cd apps/web && pnpm exec vitest run src/office-routes.test.ts app/office/workspace/settings/export/export-preview.test.tsx app/office/workspace/settings/export/export-file-tree.test.tsx lib/api/domains/office-config-export.test.ts) +(cd apps/web && pnpm run typecheck && pnpm run i18n:check) +make build-backend +make build-web +(cd apps/web && pnpm e2e:run --project=chromium e2e/tests/office/config-export.spec.ts) +(cd apps/web && pnpm e2e:run --project=mobile-chrome e2e/tests/office/mobile-config-export.spec.ts) +node scripts/validate-public-docs.mjs +node --test scripts/validate-public-docs.test.mjs +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/office/config/service.go` +- `apps/backend/internal/office/config/handler.go` +- `apps/backend/internal/office/config/service_test.go` +- `apps/backend/internal/office/config/handler_test.go` +- `apps/backend/internal/backendapp/office_scope.go (only if route table requires registration)` +- `apps/web/src/office-routes.tsx` +- `apps/web/src/office-routes.test.ts` +- `apps/web/app/office/workspace/settings/export/` +- `apps/web/lib/api/domains/office-extended-api.ts` +- `apps/web/lib/api/domains/office-config-export.test.ts (new)` +- `apps/web/src/locales/` +- `apps/web/e2e/tests/office/config-export.spec.ts (new)` +- `apps/web/e2e/tests/office/mobile-config-export.spec.ts (new)` +- `docs/public/office-config-sync.md` + +## Dependencies + +04-workspace-topbar + +## Risks + +Never retain client-generated YAML as authoritative preview. Blob responses and stale workspace state can download the wrong data unless request identity is checked. Empty bundle must be defined by server manifest, not entity count (settings may still export). + +## Parallelism + +`sequential` + +## Inputs + +Read linked requirements/designs in full, the plan evidence, scoped AGENTS.md, +TDD guidance and the adjacent existing tests before changing code. UI tasks also +read mobile-parity and E2E fixture guidance. + +## Results + +- Restored the Office export route and made the server manifest the single preview/download authority. +- Added revision-checked selected ZIP downloads, exact `.kandev/` entries, workspace scoping, stale revision conflicts and invalid/empty selection rejection. +- Added API, route and file-tree regressions plus desktop/mobile preview/download E2E specs with ZIP entry/content inspection. Backend config tests, backendapp route scope checks, frontend typecheck/i18n and both production builds passed. diff --git a/docs/plans/session-open-recovery-eligibility/task-01-repair-session-open-eligibility.md b/docs/plans/session-open-recovery-eligibility/task-01-repair-session-open-eligibility.md index 7c2cdb1975..09d5ec1b6a 100644 --- a/docs/plans/session-open-recovery-eligibility/task-01-repair-session-open-eligibility.md +++ b/docs/plans/session-open-recovery-eligibility/task-01-repair-session-open-eligibility.md @@ -18,7 +18,7 @@ acceptance_criteria: - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.9 - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.4 - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.6 - - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.8 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.10 system_design: - ../../specs/tasks/system-design/queued-session-ownership.md --- diff --git a/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md b/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md index a9cab7c7ae..acbbe0ed39 100644 --- a/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md +++ b/docs/plans/session-open-recovery-eligibility/task-02-remove-parking-presentation.md @@ -18,7 +18,7 @@ acceptance_criteria: - AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.9 - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.4 - AC-TASKS-QUEUED-SESSION-OWNERSHIP-002.6 - - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.8 + - AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.10 system_design: - ../../specs/tasks/system-design/queued-session-ownership.md --- diff --git a/docs/public/office-config-sync.md b/docs/public/office-config-sync.md index 9c48f6c0f4..221c68c54e 100644 --- a/docs/public/office-config-sync.md +++ b/docs/public/office-config-sync.md @@ -89,6 +89,12 @@ Office already has two other configuration surfaces: a filesystem-to-database di Raw-git **Push** is never refused; it remains the only way to write Office configuration back to a repository, using the backend's git credentials, and pushes only whatever you placed in the checkout by hand or by `git`. The read-only filesystem diff view and the read-only bundle export keep working unchanged regardless of whether config sync is active. The settings UI shows every refused control as unavailable, stating that config sync is the active source, instead of letting you hit the 409 the server would otherwise return. +### Definition bundle export + +The read-only **Export** page under **Office workspace Settings > Export** obtains a server-owned manifest before offering a download. The manifest contains the exact `.kandev/...` file paths and serialized bytes that the backend will package. You can select individual files or download the complete bundle. + +The selected download submits the manifest revision with the selected paths. If the workspace changes after the manifest was loaded, the backend returns `409 Conflict` and the page reloads the manifest instead of producing a mixed-revision archive. The ZIP entries are the selected manifest paths, with no client-side YAML reconstruction. + ## HTTP API The settings UI uses these backend routes: diff --git a/docs/public/office-provider-routing.md b/docs/public/office-provider-routing.md index bb1ef22586..60001dca2d 100644 --- a/docs/public/office-provider-routing.md +++ b/docs/public/office-provider-routing.md @@ -5,7 +5,7 @@ description: "Route persistent Office agent identities through interchangeable p # Office Provider Routing -Office provider routing separates an agent's persistent role from the CLI profile used to run it. This lets a CTO, reviewer, or other custom Office agent keep the same instructions, skills, permissions, budget, task ownership, and history while Kandev selects a Codex, Claude Code, or another configured execution profile for each launch. +Office provider routing separates an agent's persistent role from the CLI profile used to run it. This lets a CTO, reviewer, or other custom Office agent keep the same instructions, skills, permissions, budget, task ownership, and history while Kandev selects a Codex, Claude Code, or another configured execution profile for each launch. Lightweight routine runs use the same routing choices without inventing a task: each fire has its own run-owned session and attempt identity. Office mode is currently feature-flagged. The routing settings appear under **Office > Routing** when Office is enabled. @@ -35,3 +35,5 @@ Kandev preserves the Office agent ID, task, run, task environment, worktree, ins A provider-native session token is never reused by a different execution profile. Cross-provider fallback starts a fresh provider session and tells the new agent to inspect the durable task conversation, run state, and repository state before continuing. Provider chat history itself is not transferred. For example, a custom CTO can normally run through a Codex GPT profile. When that route reaches its usage limit, an enabled fallback chain can launch a Claude Code Opus profile while the CTO's role, five assigned skills, instructions, task, and current worktree remain unchanged. + +For a lightweight routine, provider fallback starts a fresh run-owned session for the next attempt. Delayed lifecycle events and usage from the previous provider are checked against their exact run session and cannot finish or charge the replacement attempt. diff --git a/docs/specs/office/requirements/overview.md b/docs/specs/office/requirements/overview.md index fbef98af67..4f4abc1063 100644 --- a/docs/specs/office/requirements/overview.md +++ b/docs/specs/office/requirements/overview.md @@ -42,3 +42,20 @@ scheduler, costs, routines, inbox, assistant) live in sibling specs under `docs/ ## System design The migrated technical source is split into [part 1](../system-design/overview-01.md), [part 2](../system-design/overview-02.md). + +## Workspace configuration export + +### REQ-OFFICE-CONFIG-EXPORT-001: Preview and download Office configuration + +**Intent:** Operators can export workspace configuration from Preferences and +verify the exact files before downloading them. + +#### Acceptance criteria + +- **AC-OFFICE-CONFIG-EXPORT-001.1:** Preferences shall open the export page through navigation and direct URL entry for the selected workspace. Loading, no-workspace, empty and failed-load states shall be explicit; a failed request shall offer retry. +- **AC-OFFICE-CONFIG-EXPORT-001.2:** Preview paths and content shall match downloaded ZIP entries. Download shall contain exactly the selected files; no selection shall disable download. Export shall not modify configuration or expose secret/runtime-only data. +- **AC-OFFICE-CONFIG-EXPORT-001.3:** Workspace changes shall clear the previous preview and selection immediately. A late response shall not display or download another workspace's files. Download failures shall be visible without losing the current selection. +- **AC-OFFICE-CONFIG-EXPORT-001.4:** Phone users shall select files, inspect one preview at a time, return to the list and download with the same semantics as desktop. Controls shall be touch-accessible and content shall stay within the viewport. + +The [configuration export design](../system-design/config-export.md) owns the +preview/download contract. Import and config-sync mutation behavior are unchanged. diff --git a/docs/specs/office/requirements/run-observation.md b/docs/specs/office/requirements/run-observation.md new file mode 100644 index 0000000000..513bc6a313 --- /dev/null +++ b/docs/specs/office/requirements/run-observation.md @@ -0,0 +1,41 @@ +--- +status: draft +system: office +created: 2026-09-17 +owners: + - kandev +--- + +# Office Run Observation + +## Overview + +Office owns the run and activity projections operators use to understand agent +work. Names identify entities; UUIDs remain diagnostic and navigation identifiers. +This extends the existing dashboard name/identifier contract to run detail and +the workspace activity feed. + +## Requirements + +### REQ-OFFICE-RUN-OBSERVATION-001: Understandable run and activity identity + +**Intent:** Operators can identify the agent, skills, runtime and affected work +without manually resolving UUIDs. + +#### Acceptance criteria + +- **AC-OFFICE-RUN-OBSERVATION-001.1:** Run detail shall show the Office agent name and human-readable skill names or slugs. Version/hash information shall remain available. New runs shall retain skill labels captured for that run even after a skill rename or deletion. +- **AC-OFFICE-RUN-OBSERVATION-001.2:** Adapter and model shall describe the actual invoked runtime, including provider fallback. Before invocation, the UI shall distinguish configured runtime from actual invocation or show unavailable; an Office agent UUID shall never serve as the adapter label. +- **AC-OFFICE-RUN-OBSERVATION-001.3:** Workspace activity and dashboard activity shall resolve agent names and task identifiers/titles consistently. System and scheduler actors shall have readable labels. Navigation shall still use stable IDs. +- **AC-OFFICE-RUN-OBSERVATION-001.4:** Missing, deleted or inaccessible entities shall have a localized unavailable/deleted label with a short diagnostic ID where useful. Missing entities shall not fail the entire feed, and labels shall never be resolved across workspace boundaries. Legacy runs may use explicitly current labels when historical labels do not exist. +- **AC-OFFICE-RUN-OBSERVATION-001.5:** Desktop and phone shall expose the same information without document-level horizontal overflow. Long names shall wrap or have a touch-accessible disclosure. Application-authored labels shall use localization; user-authored names shall remain unchanged. + +## Out of scope + +Renaming entities, changing runtime behavior, reconstructing unknown historical +adapter choices, and a general redesign of activity event wording. + +## Related requirements and design + +[Dashboard identity](dashboard.md), especially AC-OFFICE-DASHBOARD-001.7 and .8. +[Observation projections](../system-design/run-observation.md). diff --git a/docs/specs/office/requirements/taskless-run-sessions.md b/docs/specs/office/requirements/taskless-run-sessions.md new file mode 100644 index 0000000000..e3767cd85a --- /dev/null +++ b/docs/specs/office/requirements/taskless-run-sessions.md @@ -0,0 +1,43 @@ +--- +status: draft +system: office +created: 2026-09-17 +owners: + - kandev +--- + +# Taskless Office Run Sessions + +## Overview + +Office owns lightweight routine execution and coordinator wakeups. These wakes +must execute without inventing task records. This makes explicit the taskless +behavior already described in the [scheduler design](../system-design/scheduler-01.md). +The user confirmed this behavior on September 17, 2026. + +## Requirements + +### REQ-OFFICE-TASKLESS-001: Managed taskless execution + +**Intent:** A coordinator can inspect and manage its workspace on a periodic or +event wake without creating a task merely to host its own execution. + +#### Acceptance criteria + +- **AC-OFFICE-TASKLESS-001.1:** An eligible lightweight wake with no task shall start a real agent session, deliver its assembled prompt and permitted tools, and reach a visible terminal outcome without creating any task or task session. +- **AC-OFFICE-TASKLESS-001.2:** Every fire and retry shall use a fresh session. The existing routine-scoped or agent-scoped continuation summary shall carry context; a failed attempt shall not replace the last successful summary. +- **AC-OFFICE-TASKLESS-001.3:** Concrete and provider-routed profiles shall both work. Existing budget admission, capabilities, retry/backoff, coalescing and idle-skip rules shall apply. Periodic idle skipping shall not consume a manual or webhook wake. +- **AC-OFFICE-TASKLESS-001.4:** Run history shall show the exact session, runtime outcome, actual adapter/model and attributed usage. Duplicate or delayed events from an older attempt shall not complete, charge twice, or clear a newer attempt. +- **AC-OFFICE-TASKLESS-001.5:** Workspace pause, explicit run cancellation, agent disable/removal and workspace removal shall prevent new taskless launches and stop live taskless executions. Partial stop failures shall remain visible and retryable, including when another execution stopped successfully. +- **AC-OFFICE-TASKLESS-001.6:** After backend restart, unfinished attempts shall be reconciled against runtime evidence before replacement launch. A possibly live predecessor shall block a replacement until it is stopped or proven absent. Interrupted work shall have an explicit outcome; no taskless attempt shall remain claimed indefinitely solely because it has no task. +- **AC-OFFICE-TASKLESS-001.7:** A taskless session shall retain its workspace and capability boundaries. Task-specific decision tools shall reject it without a task context. Failed ownership lookup shall reject launch or access, never broaden authority. +- **AC-OFFICE-TASKLESS-001.8:** Existing task-bound launches and their task/session lifecycle shall retain their behavior. Historical unsupported taskless failures shall remain history and shall not be automatically replayed. + +## Out of scope + +Interactive taskless chat, synthetic tasks, new periodic producers, changing +coordinator cadence, and cross-fire ACP session resumption. + +## System design + +[Run-owned sessions](../system-design/taskless-run-sessions.md). diff --git a/docs/specs/office/requirements/workspace-kill-switch.md b/docs/specs/office/requirements/workspace-kill-switch.md index 08410703c2..3c3c546064 100644 --- a/docs/specs/office/requirements/workspace-kill-switch.md +++ b/docs/specs/office/requirements/workspace-kill-switch.md @@ -10,20 +10,11 @@ owners: ## Overview -Office runs unattended. Nobody is in the turn. There is no way to stop it. - -Existing controls are granular and incomplete: `office_routines.status` and -`office_routine_triggers.enabled` are per-routine and per-trigger, and -`agent_profiles.status = 'paused'` is per-agent. Halting a workspace today means -N + M + K untransacted writes while the scheduler keeps claiming runs between -them, and none covers the event-driven path: a comment, an assignment, an -approval resolution or a webhook still queues a run after every routine is paused. - -This capability gives an operator one write that stops one workspace, records who -stopped it and why, and is released only by explicit human action. -`KANDEV_FEATURES_OFFICE` is no substitute: a rollout gate, not an operational -control. Ownership, rejected alternatives and adjacent-contract boundaries are in -the design. +Office needs one workspace-scoped stop that records who stopped it and why, +and requires explicit human release. Per-routine, per-trigger and per-agent +controls cannot atomically stop all launch paths. Pausing routines alone still +allows event-driven wakes. `KANDEV_FEATURES_OFFICE` is a rollout gate, not an +operational control. The design records ownership and rejected alternatives. ## Terminology @@ -35,7 +26,7 @@ the design. routine run, creating an Office `runs` row, or claiming a queued run and starting an agent for it. - **In-flight work:** an Office `runs` row in status `queued` or `claimed`, plus - any agent execution running for an Office task in the workspace. The `runs` + any agent execution running for an Office task or taskless Office run in the workspace. The `runs` table has no `running` status; `claimed` is the executing state. - **Halt sweep:** the cancellation of in-flight work when a pause is created. - **Gate:** the read of pause state at a launch point. @@ -297,6 +288,10 @@ problem and cannot mistake a paused one for an idle one. that state and display what the server returned, so that a client holding a stale or unavailable state can be corrected without reloading the page. +- **AC-OFFICE-KILL-SWITCH-006.14:** Workspace refresh and pause/resume actions shall be reachable from the Office topbar on every workspace route. Desktop shall display them alongside page actions. Phone shall offer a visible workspace-actions control when space is limited. Paused, stale and unavailable state shall remain visible below the topbar; moving controls shall retain confirmation, reason capture and partial-failure retry behavior. Refresh shall continue to refresh pause state. + +See [topbar composition](../system-design/workspace-topbar-actions.md). + ## Out of scope - **A drain mode that lets in-flight work finish.** REQ-OFFICE-KILL-SWITCH-003 @@ -340,14 +335,8 @@ problem and cannot mistake a paused one for an idle one. ## Prior art -**Wiki (our own prior reasoning).** Searched: vault -`/Users/henry/Documents/henry/wiki`, QMD collection `wiki`, for kill switches, -emergency stop, pausing autonomous agent loops. **Did not run**: no `qmd` on -PATH, no `mcp__qmd__query`, every vault read returns `EPERM`. A tool -failure, not an empty result. - -**saas-kb (what other products shipped).** Searched: `search_fsm_docs`, -`category: "ai_sdlc"`. **Did not run**: no `saas-kb` MCP server. +External prior-art sources were unavailable during the original design; +the repository is the verified source below. **This repository**, the only leg with evidence. `office_task_tree_holds` is this shape one scope down: an authoritative hold record with release provenance, read diff --git a/docs/specs/office/system-design/config-export.md b/docs/specs/office/system-design/config-export.md new file mode 100644 index 0000000000..efbe6c6496 --- /dev/null +++ b/docs/specs/office/system-design/config-export.md @@ -0,0 +1,79 @@ +--- +status: draft +system: office +requirements: + - REQ-OFFICE-CONFIG-EXPORT-001 +--- + +# Office Configuration Export + +## Mapping and boundaries + +Office config owns preview/download. AC-OFFICE-CONFIG-EXPORT-001.1 maps to routes +and UI state; .2 to the canonical manifest and ZIP; .3 to request identity; .4 to +phone composition. Import, sync mutation guards and secret exclusions remain +unchanged. Existing full ZIP clients remain compatible. + +## Evidence + +`src/office-routes.tsx` lacks `/office/workspace/settings/export`, although +`app/office/workspace/settings/export/page.tsx` exists. Both live backend endpoints +returned 200 during investigation and the 34-entry ZIP passed integrity checks. +The existing frontend reconstructs YAML and paths using `toYamlLike`; those paths +even omit the backend ZIP's `.kandev/` prefix. `handleExport` ignores selectedPaths. +Restoring the route alone is insufficient. + +## Canonical manifest and download + +Refactor `config/service.go:bundleToZip` to consume one canonical server-generated +file manifest. Each entry has exact archive-relative path and UTF-8 YAML/markdown +content, using the existing Go serializer and export exclusions. Reject duplicate, +absolute or parent-traversing paths while building the manifest; never read arbitrary +filesystem paths from a selection. Do not implement a second browser serializer. + +Add optional `files` and `revision` to GET `/workspaces/:wsId/config/export` while +retaining `bundle`. Revision is a deterministic digest of sorted paths and exact +contents. Existing GET `/config/export/zip` continues exporting all current files. +Add POST `/config/export/zip` accepting `{paths: string[], revision: string}` and +returning an attachment. Validate nonempty, bounded, unique selection against the +newly generated authorized workspace manifest and require a matching revision. +A stale revision returns 409 and prompts reload; invalid selection returns 400. +Keep POST scoped through the existing Office workspace middleware and include +it in route-completeness tests. Download via the authenticated API client as a +blob, create a temporary object URL, trigger an anchor download, then revoke it. +No new package, export persistence, server-side file cache or secret surface. + +## Routes and request identity + +Register the existing ExportPage in OFFICE_ROUTES. ExportPreview consumes server +files, resets loading/error/files/selection/preview on workspace change, and drops +late responses by workspace/request identity. No-workspace is an explicit state. +Only enable download when the displayed manifest belongs to the current workspace, +loading is false, selection is nonempty and no download is pending. Retry reloads +the current workspace. Download uses the captured manifest revision/workspace; +a workspace switch cancels or discards that response and cannot download old data. +409 keeps a visible stale-preview message and requires an explicit reload before +new download; other failures retain selection and provide retry. + +## Desktop and phone + +Desktop preserves the file list plus preview with the download action above. +Phone entry is Preferences > Export through the existing Office navigation. Show +one file list with selection controls and export action; tapping a filename opens +a full-height preview with Back to files. It is primary file content, not a nested +picker drawer. Reuse the direct-navigation pattern from the mobile UI language and +the existing page-shell navigation. Shared manifest/selection state survives back. +Each view has one scroll owner, safe-area padding, contained code scrolling and +44px touch targets; no two narrow side-by-side panes. Empty/load/error states and +all application copy use locale catalogs. + +## Validation + +Backend tests compare manifest bytes with ZIP entries including YAML punctuation, +multiline values, empty bundles and every entity kind. Test revision conflicts, +unknown/duplicate/unsafe paths, exclusions and cross-workspace authorization. +Frontend tests cover reset, response races, no-workspace, retry, zero selection and +blob cleanup. Desktop/mobile E2E navigate from Preferences, preview a file, +uncheck one entry, download and inspect ZIP contents against displayed selection. +Tests also reload the direct URL and exercise a download failure. Public docs +explain export and distinguish it from config-sync mutation. diff --git a/docs/specs/office/system-design/run-observation.md b/docs/specs/office/system-design/run-observation.md new file mode 100644 index 0000000000..427e46a95d --- /dev/null +++ b/docs/specs/office/system-design/run-observation.md @@ -0,0 +1,69 @@ +--- +status: draft +system: office +requirements: + - REQ-OFFICE-RUN-OBSERVATION-001 + - REQ-OFFICE-DASHBOARD-001 +--- + +# Office Run and Activity Observation + +## Mapping and ownership + +Office dashboard projections own labels. REQ-OFFICE-RUN-OBSERVATION-001.1/.2 +map to run projection below; .3/.4 and REQ-OFFICE-DASHBOARD-001.7/.8 map to +activity projection; .5 maps to presentation. Existing IDs remain API/navigation +keys. All new fields are additive. + +## Run projection + +`dashboard/run_detail.go:buildInvocation` currently assigns `agent.ID` to adapter. +Replace that assignment with actual invocation evidence: the exact run-session +record for taskless execution, or recorded invocation/route/task-session runtime +identity for task-bound runs. A configured profile may be displayed separately +as configured, never asserted as historical invocation. Missing evidence returns +empty actual fields, rendered as localized unavailable/not-started state. +Do not infer historical adapter from a profile that may since have changed. + +Extend run skill snapshots with captured display name and slug alongside existing +skill ID/version/hash. `scheduler_integration.go` snapshot construction captures +these from the resolved manifest/skill records; `dashboard/dto.go` projects them. +For old snapshots, resolve current workspace-visible skill metadata in a bounded +batch, mark the label source current, or show a localized missing-skill label. +Never mutate historic content hashes or pretend a backfilled name was captured. + +Expose the Office agent's display name separately from adapter and runtime profile. +Update `office-runs-api.ts`, agent run header and `runtime-panel.tsx`; keep technical +IDs secondary and copyable where the existing diagnostics surface supports it. + +## Activity projection + +Add optional `actor_name`, `target_name`, `target_identifier` fields to the +activity response projection (not a rewrite of append-only activity rows). +Use one workspace-scoped, bounded batch resolver for activity lists, target +activity and dashboard recent activity. Agent targets use names; tasks prefer +identifier plus title; runs retain a readable Run label and short ID. Resolve +only authorized entities in the entry's workspace. Service actors such as +`office-scheduler` use localized client labels. Missing rows retain stable IDs +and localized fallbacks. No request per rendered row and no dependence on the +sidebar having loaded all agents/tasks. + +Wire additive fields through `office-activity-normalize.ts` and Office types. +`activity-row.tsx` uses display names for initials and labels, retains IDs for +links, and applies the same resolver result in special-action and generic rows. +Names are current entity names; this does not introduce historical actor-name +snapshots. Read failures do not erase the existing activity feed. + +## Presentation and validation + +Existing run detail and activity layouts remain. On phones, labels wrap within +min-width-zero containers; diagnostics cannot force horizontal document scroll. +Use localized fallback text in all five shipping locales, regenerate Traditional +Chinese with the repository script, and preserve user names verbatim. + +Unit/API tests cover new, legacy, deleted, renamed and cross-workspace entities, +actual versus configured adapters, routed fallback and bounded query count. +Desktop/mobile E2E cover direct navigation without preloaded sidebar data and +long names. Runtime tests retain version/hash assertions. No authorization or +persistence migration is required for activity labels; skill snapshot columns +are additive with empty defaults for old rows. diff --git a/docs/specs/office/system-design/scheduler-02.md b/docs/specs/office/system-design/scheduler-02.md index 23131716d0..ffd5fb17b8 100644 --- a/docs/specs/office/system-design/scheduler-02.md +++ b/docs/specs/office/system-design/scheduler-02.md @@ -370,3 +370,11 @@ The scheduler reads all `queued` and unexpired-retry wakeup requests on boot and - Suppressing non-heartbeat wakeups based on task state; configuring which task states count as "actionable" per agent or workspace. - Recovery sweeps for non-`TODO` states (`IN_PROGRESS` tasks with no active session are covered separately by the blocked-task-escalation spec). - Event-based triggers from external systems beyond webhooks (GitHub event subscriptions use webhooks as transport). + +## Taskless execution implementation contract + +The [run-session design](taskless-run-sessions.md) defines durable session +ownership, runtime admission, process startup, routing, cancellation and restart +reconciliation for the taskless runs described above. It completes the documented +behavior without creating task records. Its exact run/session/attempt attribution +applies to new executions; legacy agent-only attribution is compatibility only. diff --git a/docs/specs/office/system-design/taskless-run-sessions.md b/docs/specs/office/system-design/taskless-run-sessions.md new file mode 100644 index 0000000000..557ce35ae1 --- /dev/null +++ b/docs/specs/office/system-design/taskless-run-sessions.md @@ -0,0 +1,180 @@ +--- +status: draft +system: office +requirements: + - REQ-OFFICE-TASKLESS-001 +--- + +# Taskless Office Run Sessions + +## Purpose and boundaries + +Office owns run scheduling and durable run-session records. The shared agent +runtime owns processes and executor resources. Task services retain strict task +ownership. Follow [ADR](../../../decisions/2026-09-17-office-taskless-run-sessions.md). +This design completes the taskless behavior in scheduler-01/02; it does not +replace their queue, continuation or idle-skip rules. + +## Requirement mapping + +| Criteria | Sections | +| --- | --- | +| .1, .2, .8 | Persistence; launch flow | +| .3 | Scheduling and routing | +| .4 | Events and observation | +| .5, .6 | Cancellation and recovery | +| .7 | Runtime admission and security | + +All criteria belong to REQ-OFFICE-TASKLESS-001. + +## Evidence and existing seams + +`office/service/scheduler_integration.go:launchAgent` rejects empty task IDs; +`office/scheduler/dispatch_routing.go:launchCandidate` independently does so. +`agent/runtime.Runtime.Launch` currently prepares an execution; it does not +itself start the agent process and deliver the first prompt. Lifecycle +`ensureLaunchSessionStillActive` reads task sessions. These are all required +integration points, not optional follow-up work. + +`office/service/event_subscribers.go` already has `handleTasklessAgentCompleted`, +continuation summaries and run-terminal compare-and-set behavior. Its legacy +agent-only run lookup cannot attribute new attempts safely. +`office/pause/sweep.go` currently inventories runs but stops processes through a +set of task IDs. Existing taskless-failure tests verify the capability gap; +replace their unsupported-launch expectation while preserving unrelated +failure, race and inbox guarantees. + +## Persistence + +Add `office_run_sessions` to the Office repository migrations, using portable +SQLite/Postgres SQL. Proposed columns: `id`, `workspace_id`, `agent_profile_id` +(the Office identity), `run_id`, `attempt`, `state`, `execution_id`, +`execution_profile_id`, `adapter`, `model`, `acp_session_id`, `created_at`, +`started_at`, `finished_at`, `cancel_requested_at`, `error_message` and a version +for compare-and-set updates. States are preparing, running, finished, failed, +cancelled and interrupted. Unique `(run_id, attempt)` prevents duplicate launch +reservation. All provider attempts also have distinct session IDs; record their +route-attempt association rather than sharing an ACP session. + +Reserve the session and bind `runs.session_id` atomically while the run is +claimed. Runtime registration persists `execution_id` before process startup. +A failed bind must stop/rollback the prepared execution. Existing run history +retention deletes associated terminal session records only after confirming no +live resources; workspace/agent deletion performs stop-before-delete and retains +cleanup evidence when stopping fails. Do not store JWTs or environment secrets. +No changes to task-session nullability or synthetic task creation are permitted. + +Shared runtime inventory uses the run-session ID as `executors_running.session_id` +and executor correlation identity, with an empty task ID and the typed owner +snapshot in inventory metadata. This inventory key has no task-session foreign +key. `AgentExecution.SessionID` remains empty for run owners, so task consumers +cannot mistake the execution for a task session. Run workspaces live under +`office-runs//` and do not carry task ownership markers. +Normal stop persists terminal inventory. Recovery validates the owner snapshot +and stops the recorded predecessor before allowing a retry; failed stops and +unknown inventory retain the claim. + +## Runtime admission and security + +Proposed runtime APIs (not current symbols): typed `ExecutionOwner` on +`LaunchSpec`/execution/events, with `kind=task|run`, workspace/session identity, +and run/attempt identity for run owners; an owner admission provider; and +`Runtime.Start` to prepare, register, start the process and dispatch the initial +prompt. Existing `Launch` callers retain their preparation semantics. Keep all +lifecycle imports inside `internal/agent/runtime`; backendapp only composes +interfaces. Runtime supplies process mechanics; Office supplies admission and +persistent record callbacks through interfaces with no reverse Office import. + +Task owner admission retains current task cleanup and terminal-session checks. +Run owner admission checks exact durable session, run claim/attempt, agent +eligibility, workspace existence and pause/cancel state before allocation and +again around runtime registration/start. Unknown owner, mismatched identity or +read error fails closed. A durable cancellation mark participates in registration +so a pause racing allocation either observes/stops the new execution or causes +registration to roll it back. Do not implement this as an unchecked metadata flag +that skips `GetTaskSession`. + +Supply resolved executor profile, workspace, environment, prompt, skills, +permissions and Office MCP mode to the runtime. Allocate a session-specific +workspace under the existing managed runtime workspace mechanism; never reuse +an empty-task scratch key or assume a repository is required. Executor backend +contracts remain shared. A task-only executor configuration must produce an +explicit unsupported-configuration error, not a fake task ID or silent fallback. + +Mint runtime credentials after reservation, binding exact workspace, Office +agent, run and session. Task ID remains empty. Existing workspace/capability +checks and task-decision rejection remain authoritative. Routing may change the +execution profile but never Office identity or tool authority. + +## Scheduling and routing + +Introduce a proposed Office `RunSessionLauncher` interface alongside TaskStarter, +implemented through the shared runtime. Branch on task presence after normal +queue claim, budget, idle-skip, executor and context preparation. Both the concrete +path and `launchCandidate` use the same run-session launcher; candidate model, +provider, flags and environment must reach it without losing the Office prompt +or skills. Preserve route ledgers, provider classification, parking and backoff. +No new scheduler, background heartbeat producer or default toggle is needed. + +## Launch flow + +1. Claim the existing run and apply admission/idle gates. +2. Reserve a fresh attempt/session; build runtime context and scoped credentials. +3. Resolve provider/executor, prepare the runtime with run ownership and register + the exact execution ID durably. Recheck cancellation and pause admission. +4. Start the agent process, wait for readiness and dispatch the assembled prompt + exactly once. Persist actual adapter/model and expose the session on run detail. +5. Consume events for the exact attempt. A successful turn finishes the session + and run, updates the continuation summary, records output and stops resources. + A failed attempt follows the existing routing/failure policy after cleanup. + +## Events and observation + +All new runtime events carry owner kind, workspace, Office agent identity, +execution profile identity, run, session and attempt. Office subscribers validate +that tuple against the active record before any state transition or side effect. +Duplicate event/usage identities are idempotent. Delayed terminal events may +finish only their own attempt and cannot clear agent-working state for a newer +attempt. Retain legacy resolution only for legacy events; never emit new +agent-only events. Runtime task consumers explicitly ignore run-owned events +before reading/writing task sessions or workflow state. + +Workspace cost readers join taskless ledger rows to their durable run session; +run totals include every attempt belonging to that run. Serialized event-bus +usage frames have the same decoding and deduplication behavior as typed frames. + +Use the existing Office cost ledger and run events for usage/output projections; +do not write run-only events into task-message/session tables with task foreign +keys. Add durable usage deduplication keyed by session plus provider event/turn +identity. Inspect `office/service/event_subscribers.go` and `office/costs` for the +actual write boundary. Keep bounded output/continuation semantics rather than +introducing a new interactive chat UI. Run-detail session links must target a +run-owned surface, never a task-session URL for a nonexistent task. + +## Cancellation and recovery + +Extend pause inventory with all live Office run sessions, independently of run +queue status: a run may already be cancelled while its process is still live. +Persist cancellation intent before stopping each execution through Runtime.Stop. +Union this inventory with existing task cancellation, count partial failures, +and retry only still-live resources. Apply the same seam to run cancel, agent +removal/disable and workspace deletion. End-of-turn cleanup is idempotent. + +A service-owned, joined startup reconciliation pass and existing maintenance +cadence inspect unfinished sessions. Reattach observation only with proof that +the runtime still owns the exact execution/session. Otherwise stop any retained +runtime resource and mark the attempt interrupted before applying existing retry +policy. Unknown liveness or failed stop parks recovery visibly; never start a +replacement beside an uncertain predecessor. No cross-fire ACP resume. A crash +between terminal session persistence and run completion is reconciled from the +same exact-attempt record, with idempotent cost/summary side effects. + +## Verification and observability + +Log sanitized run/session/attempt/execution identities and lifecycle transitions; +never credentials or full raw provider output. Test SQLite and the repository's +Postgres migration harness, concrete and routed launch, real mock-agent prompt +completion, usage dedup, cancellation at allocation/registration/start, mixed +successful/failed stop inventories, restart with a live/absent/unknown predecessor, +and stale completion during a successor. Keep existing task-bound tests in the +same targeted checks. See [delivery plan](../../../plans/office-mode-repairs/plan.md). diff --git a/docs/specs/office/system-design/workspace-topbar-actions.md b/docs/specs/office/system-design/workspace-topbar-actions.md new file mode 100644 index 0000000000..a300127736 --- /dev/null +++ b/docs/specs/office/system-design/workspace-topbar-actions.md @@ -0,0 +1,51 @@ +--- +status: draft +system: office +requirements: + - REQ-OFFICE-KILL-SWITCH-006 +--- + +# Office Workspace Topbar Actions + +## Mapping + +This composition amendment implements AC-OFFICE-KILL-SWITCH-006.14 and preserves +.4 through .13. [Kill-switch state](workspace-kill-switch-02.md) remains the +state/confirmation contract; only control placement changes. + +## Components and flow + +`OfficeShellChrome` currently mounts `WorkspacePauseBanner` below `PageShell`. +Its running branch renders a standalone refresh/pause row. Split action rendering +from state banners. Own exactly one `useWorkspacePause` controller per selected +workspace in shell scope; pass shared state/handlers to topbar actions and banners. +Compose global actions with `chrome.actions` through `PageShell.actions`, so page +filters or actions are not overwritten by workspace controls or vice versa. +Keep a banner only for paused/stale/unavailable/partial-stop state. Retain retry +stop in the partial-failure banner. No duplicate polling hooks or mutation owners. + +Desktop: page actions, refresh pause state, pause/resume at the right of the same +topbar as the breadcrumb. Phone: a visible 44px workspace-actions trigger opens +an inset bottom drawer with refresh and pause/resume actions. Existing page and +Office navigation remain available. Reuse `mobile-menu-sheet.tsx` geometry and +`OfficePageNav` workspace navigation; do not compress the desktop button row. +Pause/resume use the existing confirmation flow, preserving reason and focus +return. Opening confirmation closes the action drawer first to avoid stacked +focus traps. A workspace change closes overlays and resets their action target. + +## Geometry and failure states + +The topbar stays outside the Office main scroller. Action drawer has one internal +scroll region, safe-area clearance, dynamic viewport containment and 44px minimum +phone/coarse-pointer targets. Desktop controls use normal 28px sizing. Paused and +unknown banners remain visible, and no optimistic success is inferred from a +failed refresh. Refresh continues to read pause state only, with an accessible +name that states this purpose. No new pause endpoints or authorization changes. + +## Validation + +Extend desktop/mobile workspace-kill-switch E2E with every state, agent/run and +export routes, page-action coexistence, confirmation cancellation, workspace +switching, partial-stop retry and failed refresh. Assert controls are inside the +topbar, not a second running-state toolbar, plus 44px phone target bounds and no +horizontal overflow at 390px and around the 768px phone breakpoint. diff --git a/docs/specs/tasks/requirements/queued-session-ownership.md b/docs/specs/tasks/requirements/queued-session-ownership.md index ee3590bc10..09e553729e 100644 --- a/docs/specs/tasks/requirements/queued-session-ownership.md +++ b/docs/specs/tasks/requirements/queued-session-ownership.md @@ -116,7 +116,7 @@ destination entry, whereas a ceiling can delay a session already selected by ent queued destination, waiting reason, queue time, capacity observation, and automatic retry explanation outside the historical transcript. This status shall remain visible when the user selects a parked predecessor. -- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.3:** Superseded by criterion 003.8. +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.3:** Superseded by criterion 003.10. The original requirement to identify a conversation as parked no longer applies. - **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.4:** Queue status shall clear after confirmed dispatch or final disposition. Older snapshots shall not restore a @@ -144,7 +144,7 @@ destination entry, whereas a ceiling can delay a session already selected by ent After a limit change, the banner shall show refreshed state without a page reload and remain until dispatch or final disposition is confirmed. -- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.8:** Desktop and phone conversation +- **AC-TASKS-QUEUED-SESSION-OWNERSHIP-003.10:** Desktop and phone conversation surfaces shall not show a parked-session banner, toolbar, badge, or recovery explanation. Normal conversation controls shall remain available. Genuine queued destinations shall retain queue status without a duplicate Start or Resume action.