feat(office): implement taskless runs and Office mode repairs (#3777)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ŷōũŕ ḿōďēĺ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "你的模型",
|
||||
|
||||
@@ -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": "你的模型",
|
||||
|
||||
@@ -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": "你的模型",
|
||||
|
||||
@@ -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).
|
||||
@@ -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
Reference in New Issue
Block a user