Files
multica/server/pkg/db/generated/daemon_token.sql.go
Bohan Jiang f5c2994aed feat(workspace): revoke a member's runtimes when they leave or are removed (#2401)
* feat(workspace): revoke a member's runtimes when they leave or are removed

Previously, leaving or being removed from a workspace only deleted the
member row — every runtime the departed user owned in that workspace
remained in the DB, kept its daemon_token valid, and stayed reachable to
the workspace's other members. The departed user lost access but their
machine kept doing work.

This change converges the runtime state in the same transaction as the
member-row deletion: agents pinned to those runtimes are archived,
in-flight tasks are cancelled (so the daemon's per-task status poller
interrupts the running agent gracefully), the runtimes are forced
offline, and the daemon_token rows are deleted. After commit the
DaemonTokenCache is invalidated and agent:archived / daemon:register
events fire so connected clients reconcile immediately.

Server-side state convergence is the production safety net; the
daemon_token revoke takes effect once the mdt_ flow is live (today most
daemons fall back to PAT/JWT, and the member-row deletion is what stops
those requests via requireWorkspaceMember).

Daemon-side handling (recognising the resulting 401/404 and tearing down
the local pairing for that workspace) lands in a follow-up.

Co-authored-by: multica-agent <github@multica.ai>

* fix(workspace): also cancel tasks for archived agents on member revoke

CancelAgentTasksByRuntime only matched tasks whose runtime_id was in the
revoked set, missing a real path: agent.runtime_id can be reassigned via
UpdateAgent, but agent_task_queue.runtime_id keeps the value from when
the task was queued. So an agent currently bound to the leaving member's
runtime gets archived correctly, but its older tasks still pinned to a
prior runtime stay 'queued' — and ClaimAgentTask does not gate on
agent.archived_at, so those orphaned tasks remain claimable by the
prior runtime.

Replace CancelAgentTasksByRuntime with CancelAgentTasksByRuntimeOrAgent,
which OR-matches runtime_ids and the archived agent IDs in one UPDATE.
Pass the archived agent IDs through from revokeAndRemoveMember.

Adds TestDeleteMember_CancelsTasksFromAgentReassignment as a regression
guard: same agent, two runtimes, the older task on the surviving runtime
must end up cancelled while the surviving runtime stays online.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 15:06:50 +08:00

113 lines
3.1 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: daemon_token.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createDaemonToken = `-- name: CreateDaemonToken :one
INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, token_hash, workspace_id, daemon_id, expires_at, created_at
`
type CreateDaemonTokenParams struct {
TokenHash string `json:"token_hash"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
DaemonID string `json:"daemon_id"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}
func (q *Queries) CreateDaemonToken(ctx context.Context, arg CreateDaemonTokenParams) (DaemonToken, error) {
row := q.db.QueryRow(ctx, createDaemonToken,
arg.TokenHash,
arg.WorkspaceID,
arg.DaemonID,
arg.ExpiresAt,
)
var i DaemonToken
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.WorkspaceID,
&i.DaemonID,
&i.ExpiresAt,
&i.CreatedAt,
)
return i, err
}
const deleteDaemonTokensByWorkspaceAndDaemons = `-- name: DeleteDaemonTokensByWorkspaceAndDaemons :many
DELETE FROM daemon_token
WHERE workspace_id = $1
AND daemon_id = ANY($2::text[])
RETURNING token_hash
`
type DeleteDaemonTokensByWorkspaceAndDaemonsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
DaemonIds []string `json:"daemon_ids"`
}
// Deletes every daemon_token row matching the (workspace_id, daemon_id)
// pairs implied by `daemon_ids`. Used by the member-revocation flow to
// nuke tokens for all runtimes a leaving member owned in one shot.
// Returns token_hash so the caller can invalidate auth.DaemonTokenCache
// before the 10-minute TTL expires — without that invalidate, a daemon
// can keep using its stale token until cache eviction even though the
// DB row is gone.
func (q *Queries) DeleteDaemonTokensByWorkspaceAndDaemons(ctx context.Context, arg DeleteDaemonTokensByWorkspaceAndDaemonsParams) ([]string, error) {
rows, err := q.db.Query(ctx, deleteDaemonTokensByWorkspaceAndDaemons, arg.WorkspaceID, arg.DaemonIds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var token_hash string
if err := rows.Scan(&token_hash); err != nil {
return nil, err
}
items = append(items, token_hash)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteExpiredDaemonTokens = `-- name: DeleteExpiredDaemonTokens :exec
DELETE FROM daemon_token
WHERE expires_at <= now()
`
func (q *Queries) DeleteExpiredDaemonTokens(ctx context.Context) error {
_, err := q.db.Exec(ctx, deleteExpiredDaemonTokens)
return err
}
const getDaemonTokenByHash = `-- name: GetDaemonTokenByHash :one
SELECT id, token_hash, workspace_id, daemon_id, expires_at, created_at FROM daemon_token
WHERE token_hash = $1 AND expires_at > now()
`
func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (DaemonToken, error) {
row := q.db.QueryRow(ctx, getDaemonTokenByHash, tokenHash)
var i DaemonToken
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.WorkspaceID,
&i.DaemonID,
&i.ExpiresAt,
&i.CreatedAt,
)
return i, err
}