feat(office): implement taskless runs and Office mode repairs (#3777)

This commit is contained in:
Carlos Florêncio
2026-09-18 15:13:01 +01:00
committed by GitHub
parent 26254fe51f
commit 3c1bf2da4d
119 changed files with 5921 additions and 474 deletions
+1 -1
View File
@@ -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.
@@ -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
@@ -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"`
@@ -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 {
@@ -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) {
@@ -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 {
@@ -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,
@@ -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 <homeDir>/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 {
@@ -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))
}
}
@@ -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
@@ -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,
@@ -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
@@ -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",
))
}
@@ -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
}
@@ -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
}
@@ -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
@@ -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
}
@@ -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()
+18 -3
View File
@@ -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)
@@ -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,
})
}
+1
View File
@@ -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:<id>` and `agent:<id>`; 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.
@@ -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"`
@@ -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 {
@@ -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")
+151 -18
View File
@@ -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 {
@@ -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")
}
}
@@ -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
}
@@ -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)
}
}
@@ -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"`
@@ -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) {
@@ -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
}
@@ -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)
}
}
@@ -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.
@@ -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{
+50 -8
View File
@@ -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
@@ -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
@@ -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 {
@@ -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
@@ -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
@@ -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.
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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 = ?)`,
@@ -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)
@@ -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
@@ -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) {
@@ -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
}
@@ -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.
@@ -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
@@ -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
@@ -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) {
@@ -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,
@@ -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)
}
@@ -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)
}
}
@@ -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
}
@@ -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 == "" {
@@ -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
@@ -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
}
@@ -55,6 +55,12 @@ describe("RunHeader", () => {
);
});
it("renders the captured agent name independently from invocation details", () => {
render(<RunHeader run={{ ...baseRun, agent_name: "CEO" }} />);
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(<RunHeader run={baseRun} />);
expect(screen.getByTestId("run-duration").textContent).toContain("30s");
@@ -146,6 +146,11 @@ function TopRow({ run }: { run: RunDetail }) {
<Badge variant={STATUS_VARIANT[run.status] ?? "secondary"} data-testid="run-status-badge">
{run.status}
</Badge>
{run.agent_name && (
<span className="text-sm text-muted-foreground" data-testid="run-agent-name">
{run.agent_name}
</span>
)}
{run.invocation.adapter && (
<span className="text-sm text-muted-foreground" data-testid="run-adapter">
{run.invocation.adapter}
@@ -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(
<RuntimePanel
runtime={{
capabilities: {},
input_snapshot: {},
skills: [
{
skill_id: "skill-renamed",
display_name: "Planning",
slug: "planning",
version: "2",
content_hash: "hash-2",
materialized_path: "/tmp/skills/planning",
},
{
skill_id: "skill-deleted",
version: "1",
content_hash: "hash-1",
materialized_path: "/tmp/skills/deleted",
},
],
}}
/>,
);
const skills = screen.getByTestId("runtime-skills");
expect(skills.textContent).toContain("Planning");
expect(skills.textContent).toContain("skill-renamed");
expect(skills.textContent).toMatch(/skill unavailable/i);
});
});
@@ -48,7 +48,8 @@ export function RuntimePanel({ runtime }: Props) {
<div className="space-y-1" data-testid="runtime-skills">
{runtime.skills.map((skill) => (
<div key={skill.skill_id} className="text-xs font-mono break-all">
{skill.skill_id}{" "}
<span>{skill.display_name || skill.slug || t("office:skillUnavailable")}</span>{" "}
<span className="text-muted-foreground">({skill.skill_id})</span>{" "}
{skill.version && <span className="text-muted-foreground">v{skill.version}</span>}
{skill.content_hash && (
<span className="text-muted-foreground">
@@ -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={
<>
<WorkspacePauseTopbarActions view={pauseView} />
{chrome?.actions}
</>
}
topbarTestId="office-topbar"
className="gap-2 bg-background px-4"
pageNav={(onClose) => <OfficePageNav onClose={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. */}
<WorkspacePauseBanner />
<WorkspacePauseState view={pauseView} />
{/* `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).
@@ -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> = {}): 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(<WorkspacePauseTopbarActions view={view()} />);
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(
<WorkspacePauseState
view={view({
record: {
id: "pause-1",
reason: "maintenance",
createdBy: "default-user",
createdByKind: "user",
createdAt: "2026-09-17T00:00:00Z",
},
})}
/>,
);
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(<WorkspacePauseTopbarActions view={props} />);
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());
},
);
@@ -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<typeof useWorkspacePause>["status"];
refresh: () => Promise<void>;
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<void>;
onResume: UseWorkspacePauseResult["resume"];
sweep: UseWorkspacePauseResult["sweep"];
onRetryPause: UseWorkspacePauseResult["retryPause"];
isMutating: boolean;
@@ -94,10 +110,6 @@ function PausedBanner({
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<RefreshPauseStateButton onRefresh={onRefresh} testId="office-pause-refresh-paused" />
<ResumeWorkspaceButton onResume={onResume} />
</div>
</div>
);
}
@@ -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<void>;
onPause: UseWorkspacePauseResult["pause"];
}) {
function PauseStateUnavailableBar() {
const { t } = useTranslation();
return (
<div
@@ -120,32 +126,99 @@ function PauseStateUnavailableBar({
>
<IconAlertTriangle className="h-4 w-4 shrink-0 text-amber-700 dark:text-amber-300" />
<span className="min-w-0 flex-1">{t("office:pauseStateUnavailable")}</span>
<div className="flex shrink-0 items-center gap-2">
<RefreshPauseStateButton onRefresh={onRefresh} testId="office-pause-refresh-unavailable" />
<PauseWorkspaceButton onPause={onPause} />
</div>
</div>
);
}
// 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<void>;
onPause: UseWorkspacePauseResult["pause"];
}) {
return (
<div className="flex items-center justify-end gap-2" data-testid="office-workspace-running-bar">
<RefreshPauseStateButton onRefresh={onRefresh} testId="office-pause-refresh-running" />
<PauseWorkspaceButton onPause={onPause} />
</div>
function WorkspacePauseActions({ view }: { view: WorkspacePauseViewProps }) {
const { t } = useTranslation();
const [drawerOpen, setDrawerOpen] = useState(false);
if (!view.activeWorkspaceId) return null;
const renderActions = (onOpen: () => void) => {
const controls = (
<div className="flex flex-wrap items-center gap-2">
<RefreshPauseStateButton onRefresh={view.refresh} testId="office-pause-refresh-topbar" />
<Button
size="sm"
variant={view.record ? "default" : "outline"}
className="min-h-11 cursor-pointer gap-1.5 sm:min-h-0"
disabled={view.isMutating}
data-testid={
view.record ? "office-resume-workspace-button" : "office-pause-workspace-button"
}
onClick={() => {
setDrawerOpen(false);
onOpen();
}}
>
{view.record ? (
<IconPlayerPlay className="h-3.5 w-3.5" />
) : (
<IconPlayerPause className="h-3.5 w-3.5" />
)}
{t(view.record ? "office:resumeWorkspace" : "office:pauseWorkspace")}
</Button>
</div>
);
return (
<>
<div
className="hidden items-center gap-2 md:flex"
data-testid="office-workspace-topbar-actions"
>
{controls}
</div>
<div className="md:hidden">
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerTrigger asChild>
<Button
variant="ghost"
className="min-h-11 min-w-11 cursor-pointer px-2"
aria-label={t("office:workspaceActions")}
data-testid="office-workspace-actions-trigger"
>
<IconAdjustmentsHorizontal className="h-4 w-4" />
</Button>
</DrawerTrigger>
<DrawerContent data-testid="office-workspace-actions-drawer">
<DrawerHeader>
<DrawerTitle>{t("office:workspaceActions")}</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-2 px-4 pb-6 [&_button]:min-h-11">{controls}</div>
</DrawerContent>
</Drawer>
</div>
</>
);
};
return view.record ? (
<ResumeWorkspaceButton onResume={view.resume} renderTrigger={renderActions} />
) : (
<PauseWorkspaceButton onPause={view.pause} renderTrigger={renderActions} />
);
}
export function WorkspacePauseTopbarActions({ view }: { view: WorkspacePauseViewProps }) {
return <WorkspacePauseActions view={view} />;
}
export function WorkspacePauseState({ view }: { view: WorkspacePauseViewProps }) {
if (!view.activeWorkspaceId) return null;
if (view.record) {
return (
<PausedBanner
record={view.record}
stale={view.status === "unknown"}
sweep={view.sweep}
onRetryPause={view.retryPause}
isMutating={view.isMutating}
/>
);
}
if (view.status === "unknown") return <PauseStateUnavailableBar />;
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 (
<PausedBanner
record={record}
stale={status === "unknown"}
onRefresh={refresh}
onResume={resume}
sweep={sweep}
onRetryPause={retryPause}
isMutating={isMutating}
/>
);
}
if (status === "unknown") {
return <PauseStateUnavailableBar onRefresh={refresh} onPause={pause} />;
}
return <RunningControlBar onRefresh={refresh} onPause={pause} />;
return (
<WorkspacePauseState
view={{
activeWorkspaceId,
record,
status,
refresh,
pause,
retryPause,
resume,
sweep,
isMutating,
}}
/>
);
}
@@ -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<WorkspacePauseActionResult>;
disabled?: boolean;
renderTrigger?: (onOpen: () => void) => ReactNode;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -82,17 +84,21 @@ export function PauseWorkspaceButton({
return (
<>
<Button
size="sm"
variant="outline"
className="min-h-11 cursor-pointer gap-1.5 sm:min-h-0"
disabled={disabled}
data-testid="office-pause-workspace-button"
onClick={() => setOpen(true)}
>
<IconPlayerPause className="h-3.5 w-3.5" />
{t("office:pauseWorkspace")}
</Button>
{renderTrigger ? (
renderTrigger(() => setOpen(true))
) : (
<Button
size="sm"
variant="outline"
className="min-h-11 cursor-pointer gap-1.5 sm:min-h-0"
disabled={disabled}
data-testid="office-pause-workspace-button"
onClick={() => setOpen(true)}
>
<IconPlayerPause className="h-3.5 w-3.5" />
{t("office:pauseWorkspace")}
</Button>
)}
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent data-testid="office-pause-workspace-dialog">
<DialogHeader>
@@ -144,9 +150,11 @@ export function PauseWorkspaceButton({
export function ResumeWorkspaceButton({
onResume,
disabled,
renderTrigger,
}: {
onResume: () => Promise<WorkspacePauseActionResult>;
disabled?: boolean;
renderTrigger?: (onOpen: () => void) => ReactNode;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -176,16 +184,20 @@ export function ResumeWorkspaceButton({
return (
<>
<Button
size="sm"
className="min-h-11 cursor-pointer gap-1.5 sm:min-h-0"
disabled={disabled}
data-testid="office-resume-workspace-button"
onClick={() => setOpen(true)}
>
<IconPlayerPlay className="h-3.5 w-3.5" />
{t("office:resumeWorkspace")}
</Button>
{renderTrigger ? (
renderTrigger(() => setOpen(true))
) : (
<Button
size="sm"
className="min-h-11 cursor-pointer gap-1.5 sm:min-h-0"
disabled={disabled}
data-testid="office-resume-workspace-button"
onClick={() => setOpen(true)}
>
<IconPlayerPlay className="h-3.5 w-3.5" />
{t("office:resumeWorkspace")}
</Button>
)}
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent data-testid="office-resume-workspace-dialog">
<AlertDialogHeader>
@@ -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(
<ActivityRow
entry={activity({
actorName: "CEO",
targetName: "Build report",
targetIdentifier: "KAN-14",
action: "task_status_changed",
details: { new_status: "in_review" },
})}
/>,
);
expect(screen.getByText("CEO")).toBeTruthy();
expect(screen.getByText(/KAN-14 Build report/)).toBeTruthy();
});
});
@@ -23,10 +23,10 @@ const CANCEL_REASON_LABEL_KEYS: Record<string, string> = {
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<string, unknown> | undefined): string | null {
@@ -44,11 +44,18 @@ function taskIdentifier(details: Record<string, unknown> | undefined): string |
return null;
}
function taskRefNode(details: Record<string, unknown> | undefined): React.ReactNode {
const id = details?.task_id;
const identifier = taskIdentifier(details);
function taskRefNode(
entry: ActivityEntry,
details: Record<string, unknown> | 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 <span className="font-bold"> {label}</span>;
}
@@ -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 (
<>
<span className="text-muted-foreground"> {t("office:staleRunCancelled")}</span>
{taskRefNode(entry, d)}
{label && <span className="text-muted-foreground"> - {truncate(label)}</span>}
</>
);
}
if (entry.action === "run_stale_cancelled") {
const label = cancelReasonLabel(typeof d?.reason === "string" ? d.reason : "");
return (
<>
<span className="text-muted-foreground"> {t("office:staleRunCancelled")}</span>
{taskRefNode(d)}
{label && <span className="text-muted-foreground"> - {truncate(label)}</span>}
</>
);
}
function renderReassignmentCancellation(entry: ActivityEntry): React.ReactNode {
return (
<>
<span className="text-muted-foreground"> {t("office:retryCancelledReassigned")}</span>
{taskRefNode(entry, entry.details)}
</>
);
}
if (entry.action === "run_retry_cancelled") {
return (
<>
<span className="text-muted-foreground"> {t("office:retryCancelledReassigned")}</span>
{taskRefNode(d)}
</>
);
}
function renderRecoveryDispatch(entry: ActivityEntry): React.ReactNode {
return (
<>
<span className="text-muted-foreground"> {t("office:unstartedTaskRecovered")}</span>
{taskRefNode(entry, entry.details)}
</>
);
}
if (entry.action === "recovery_dispatch") {
return (
<>
<span className="text-muted-foreground"> {t("office:unstartedTaskRecovered")}</span>
{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 (
<>
<span className="text-muted-foreground">
{" "}
{status ? t("office:activityStatusChangedTo", { status }) : t("office:statusChanged")}
</span>
{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 (
<>
<span className="text-muted-foreground">
{" "}
{status ? t("office:activityStatusChangedTo", { status }) : t("office:statusChanged")}
</span>
{taskRefNode(d)}
</>
);
}
const SPECIAL_ACTION_RENDERERS: Record<string, (entry: ActivityEntry) => 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 (
<>
<span className="text-muted-foreground"> {formatted} </span>
{entry.targetType && (
<span className="font-medium">
{entry.targetType}
{entry.targetId ? ` ${entry.targetId}` : ""}
{entry.targetName || entry.targetType}
{entry.targetIdentifier || entry.targetId
? ` ${entry.targetIdentifier || entry.targetId}`
: ""}
</span>
)}
</>
);
}
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 (
<div className="flex items-start gap-3 px-4 py-2.5 text-sm hover:bg-accent/50 transition-colors">
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-medium uppercase text-muted-foreground">
{actorInitial(entry.actorType, entry.actorId)}
{actorInitial(entry.actorType, entry.actorId, entry.actorName)}
</div>
<div className="flex-1 min-w-0 truncate">
<span className="font-medium">{actorLabel(entry)}</span>
@@ -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 (
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<span className="text-sm text-muted-foreground">
{t("office:exportSelectionSummary", {
workspace: workspaceName,
selected: selectedCount,
total,
})}
</span>
<Button
size="sm"
onClick={onExport}
disabled={selectedCount === 0}
className="cursor-pointer"
>
<IconDownload className="h-4 w-4 mr-1.5" />
{t("office:exportFilesCount", { count: selectedCount })}
</Button>
</div>
);
}
type ExportErrorProps = {
errorKey: string;
onRetry: () => void;
};
function ExportDownloadError({ errorKey, onRetry }: ExportErrorProps) {
const { t } = useTranslation();
return (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<span>{t(errorKey)}</span>
{errorKey === "office:exportConfigurationChanged" && (
<Button size="sm" variant="ghost" onClick={onRetry} className="min-h-11 cursor-pointer">
{t("office:retry")}
</Button>
)}
</div>
);
}
type ExportContentProps = {
usesTouchDrawer: boolean;
previewFile: ExportFile | null;
tree: ReturnType<typeof buildFileTree>;
selectedPaths: Set<string>;
onSelectedPathsChange: (paths: Set<string>) => 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 (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<Button
variant="ghost"
className="min-h-11 shrink-0 justify-start gap-2 rounded-none border-b px-4 cursor-pointer"
onClick={() => onPreviewPathChange(null)}
>
<IconArrowLeft className="h-4 w-4" />
{t("office:backToExportFiles")}
</Button>
<ExportFilePreview file={previewFile} />
</div>
);
}
return (
<>
<ExportFileTree
tree={tree}
selectedPaths={selectedPaths}
onSelectedPathsChange={onSelectedPathsChange}
previewPath={previewPath}
onPreviewPathChange={(path) => onPreviewPathChange(path)}
/>
{!usesTouchDrawer && <ExportFilePreview file={previewFile} />}
</>
);
}
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 (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t("office:selectAWorkspaceToExport")}
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
{t("office:loadingExportBundle")}
</div>
);
}
if (errorKey) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-destructive">
<span>{t(errorKey)}</span>
<Button variant="outline" onClick={onRetry} className="cursor-pointer">
{t("office:retry")}
</Button>
</div>
);
}
return null;
}
function useExportManifest(
activeWorkspaceId: string,
usesTouchDrawer: boolean,
reloadToken: number,
) {
const [files, setFiles] = useState<ExportFile[]>([]);
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<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [previewPath, setPreviewPath] = useState<string | null>(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<string | null>(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 (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
{t("office:loadingExportBundle")}
</div>
);
}
if (errorKey) {
return (
<div className="flex items-center justify-center h-full text-sm text-destructive">
{t(errorKey)}
</div>
<ExportStatus
activeWorkspaceId={activeWorkspaceId}
loading={loading}
errorKey={errorKey}
onRetry={handleRetry}
/>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<span className="text-sm text-muted-foreground">
{t("office:exportSelectionSummary", {
workspace: workspaceName,
selected: selectedCount,
total: files.length,
})}
</span>
<Button
size="sm"
onClick={handleExport}
disabled={selectedCount === 0}
className="cursor-pointer"
>
<IconDownload className="h-4 w-4 mr-1.5" />
{t("office:exportFilesCount", { count: selectedCount })}
</Button>
</div>
<div className="flex flex-1 min-h-0">
<ExportFileTree
<ExportToolbar
workspaceName={workspaceName}
selectedCount={selectedCount}
total={files.length}
onExport={() => void handleExport()}
/>
{downloadErrorKey && (
<ExportDownloadError errorKey={downloadErrorKey} onRetry={handleRetry} />
)}
<div className="flex min-h-0 flex-1">
<ExportContent
usesTouchDrawer={usesTouchDrawer}
previewFile={previewFile}
tree={tree}
selectedPaths={selectedPaths}
onSelectedPathsChange={setSelectedPaths}
previewPath={previewPath}
onPreviewPathChange={setPreviewPath}
/>
<ExportFilePreview file={previewFile} />
</div>
</div>
);
@@ -589,6 +589,10 @@ export class OfficeApiClient {
return res.routine ?? (res as unknown as Record<string, unknown>);
}
async listRoutineRuns(routineId: string): Promise<Record<string, unknown>> {
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<Response> {
return this.rawRequest("POST", `/routines/${routineId}/run`);
@@ -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<string, Buffer> {
const archive = readFileSync(path);
const entries = new Map<string, Buffer>();
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:");
});
@@ -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();
});
@@ -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,
});
@@ -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),
);
});
@@ -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);
@@ -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,
});
@@ -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();
});
});
@@ -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<Record<string, unknown>> },
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));
});
});
@@ -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");
@@ -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<string, unknown> | undefined {
}
}
function pick<T>(fallback: T, ...values: Array<T | undefined>): 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<string | undefined>(undefined, raw.actorName, raw.actor_name),
action: pick("", raw.action),
targetType: pick<string | undefined>(undefined, raw.targetType, raw.target_type),
targetId: pick<string | undefined>(undefined, raw.targetId, raw.target_id),
targetName: pick<string | undefined>(undefined, raw.targetName, raw.target_name),
targetIdentifier: pick<string | undefined>(
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<string | undefined>(undefined, raw.runId, raw.run_id),
sessionId: pick<string | undefined>(undefined, raw.sessionId, raw.session_id),
createdAt: pick("", raw.createdAt, raw.created_at),
};
}
+2
View File
@@ -20,6 +20,8 @@ export {
setupChannel,
deleteChannel,
exportConfig,
exportConfigManifest,
exportSelectedConfigZip,
exportConfigZipUrl,
previewImport,
applyImport,
@@ -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<typeof vi.fn>;
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" });
});
});
@@ -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<ConfigExportManifest>(
`${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,
},
});
}
@@ -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,
@@ -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;
@@ -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()");
});
});
@@ -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<string, unknown>;
runId?: string;
sessionId?: string;
+6
View File
@@ -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",
+6
View File
@@ -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": "ŷōũŕ ḿōďēĺ",
+6
View File
@@ -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",
+6
View File
@@ -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": "你的模型",
+6
View File
@@ -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": "你的模型",
+6
View File
@@ -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": "你的模型",
+2
View File
@@ -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<string, RouteRenderer> = {
<SettingsPage />
</SettingsSaveProvider>
),
"/office/workspace/settings/export": () => <ExportPage />,
"/office/workspace/settings/sync": () => <SyncPage />,
"/office/workspace/org": () => <OrgPage />,
};
@@ -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).
+255
View File
@@ -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: <actual 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.

Some files were not shown because too many files have changed in this diff Show More