mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* test(server): reduce slow unit test waits Co-authored-by: multica-agent <[email protected]> * test(server): cut slow Go unit test time further Follow-up to the first pass on MUL-7271. Test-only changes plus a few package-private seams whose production values are unchanged. - Re-executed race-built test binaries no longer pay the race runtime's 1s atexit sleep (GORACE=atexit_sleep_ms=0 for child processes only). - pkg/agent: event-driven or shorter test-only bounds for handshake, drain and idle graces; redundant timeout variants merged. - daemon/execenv: git fixtures copied from a template repo built once; tests that re-ran the same worktree lifecycle folded into their supersets; ForkLock-guarded fake-CLI writes so parallel tests cannot hit ETXTBSY. - handler/service: cap and paging tests shrink their caps instead of seeding thousands of rows; lock waits probe pg_locks instead of sleeping; one shared pgxpool per service test binary; cheaper cron and RSA fixtures. - wecom: the phishing-link sweep reuses one parser and derives CRLF from LF with a sampled cross-check; fake sockets acknowledge instead of waiting out the ack timeout. - migrate: the migration corpus is parsed once; isolated DB tests run in parallel. Co-authored-by: multica-agent <[email protected]> * test(server): remove transport timeout unit tests Co-authored-by: multica-agent <[email protected]> * test(server): restore two non-timeout tests and make a bound load-bearing Follow-up to the review of #8318. All test-only; no production code changes. - daemon: restore the three read-liveness cases that the transport-timeout sweep took with it. They assert that a ping, a pong and an application frame each EXTEND the read deadline, so a healthy wakeup socket survives without traffic of our own — the deadline never fires on the happy path, which is why they are not transport-timeout tests. Verified they fail (all three) when the extension calls are removed from wakeup.go, and pass in 0.57s together. The pure "times out without peer traffic" case stays deleted. - pkg/agent: restore the parent-DEADLINE half of the opaque-env redaction pair. codex.go treats a parent deadline and a parent cancellation as one ending; only the Canceled arm was left covered. The fake now writes the secret to stderr before reading the handshake, so the check no longer races our own initialize write, and the budget is 2s against a measured 430-465ms launch. Verified it fails when the !contextEnded guard is removed. - pkg/agent: TestRunCollectQuietDoesNotWaitWhenTheAnswerIsIn had its bound widened to a literal 5s, which the shrunk 750ms drain grace can never reach — the test could no longer fail. It now raises the drain grace to 10s for its own duration and asserts under 3s: healthy runs take 0.19-0.44s, and a broken short-circuit takes 10.19s and fails. - wecom: gofmt the import block in relay_settle_budget_test.go. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> Co-authored-by: J <[email protected]>
462 lines
17 KiB
Go
462 lines
17 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// TimelineEntry represents a single entry in the issue timeline, which can be
|
|
// either an activity log record or a comment.
|
|
type TimelineEntry struct {
|
|
Type string `json:"type"` // "activity" or "comment"
|
|
ID string `json:"id"`
|
|
|
|
ActorType string `json:"actor_type"`
|
|
ActorID string `json:"actor_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
// Display-only identity is hydrated from the global user row for member
|
|
// actors. It remains available after the member leaves this workspace;
|
|
// actor_type + actor_id stay the durable attribution keys.
|
|
ActorName string `json:"actor_name,omitempty"`
|
|
ActorAvatarURL string `json:"actor_avatar_url,omitempty"`
|
|
|
|
// Activity-only fields
|
|
Action *string `json:"action,omitempty"`
|
|
Details json.RawMessage `json:"details,omitempty"`
|
|
|
|
// Comment-only fields
|
|
Content *string `json:"content,omitempty"`
|
|
ParentID *string `json:"parent_id,omitempty"`
|
|
UpdatedAt *string `json:"updated_at,omitempty"`
|
|
Revision int64 `json:"revision,omitempty"`
|
|
CommentType *string `json:"comment_type,omitempty"`
|
|
// Set only on comments produced by a quick action run. Unforgeable: there
|
|
// is no request field for it on the generic comment endpoint.
|
|
QuickActionID *string `json:"quick_action_id,omitempty"`
|
|
Reactions []ReactionResponse `json:"reactions,omitempty"`
|
|
Attachments []AttachmentResponse `json:"attachments,omitempty"`
|
|
ResolvedAt *string `json:"resolved_at,omitempty"`
|
|
ResolvedByType *string `json:"resolved_by_type,omitempty"`
|
|
ResolvedByID *string `json:"resolved_by_id,omitempty"`
|
|
SourceTaskID *string `json:"source_task_id,omitempty"`
|
|
// Set only on a tombstone: a comment deleted while it still had replies.
|
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
|
}
|
|
|
|
// timelineHardCap bounds the per-issue timeline payload. Sized as a defensive
|
|
// safety net, not a UX page window: see commentHardCap in comment.go for the
|
|
// data-shape rationale (#1929). A variable only so the cap tests can shrink it,
|
|
// like commentHardCap; nothing outside tests assigns it.
|
|
var timelineHardCap = 2000
|
|
|
|
// timelineProbeLimit reads one row past the cap so "we hit the cap" can be
|
|
// distinguished from "the issue happens to have exactly timelineHardCap rows".
|
|
// Without the probe row an issue sitting exactly on the boundary would report a
|
|
// complete timeline as truncated and pay a needless ancestor-backfill query.
|
|
func timelineProbeLimit() int32 { return int32(timelineHardCap) + 1 }
|
|
|
|
// Truncation is signalled with a response header rather than a body field
|
|
// because the unpaginated response is a bare JSON array (TimelineEntriesSchema =
|
|
// z.array(TimelineEntrySchema) in packages/core/api/schemas.ts) with nowhere to
|
|
// put a flag. The header is additive: existing clients keep validating.
|
|
//
|
|
// The value names which kinds were truncated ("activity", "comment", or
|
|
// "activity,comment") because the two caps are independent — in practice it is
|
|
// almost always activity alone.
|
|
//
|
|
// There is deliberately no companion "window from" header. An earlier revision
|
|
// emitted one, but TimestampToString is second-precision RFC3339 while the real
|
|
// ordering key is (created_at, id) at full precision, so it could not be used to
|
|
// resume a read without skipping or repeating rows inside a shared second. A
|
|
// resumable cursor needs to be opaque and carry both halves; that is worth
|
|
// designing when there is a consumer, not shipping as a lossy approximation.
|
|
//
|
|
// Exported so the CORS layer can reference the same identifier
|
|
// (corsExposedHeaders in server/cmd/server/router.go). A custom response header
|
|
// is invisible to browser JS unless explicitly exposed, so a rename here that
|
|
// did not reach the CORS list would silently switch the signal back off.
|
|
const HeaderTimelineTruncated = "X-Timeline-Truncated"
|
|
|
|
// truncatedKinds renders the X-Timeline-Truncated value, "" when nothing was
|
|
// truncated.
|
|
func truncatedKinds(comments, activities bool) string {
|
|
switch {
|
|
case comments && activities:
|
|
return "activity,comment"
|
|
case comments:
|
|
return "comment"
|
|
case activities:
|
|
return "activity"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// takeNewest trims a timelineProbeLimit read down to timelineHardCap and reports
|
|
// whether the probe row proved older rows exist. rows must be ascending, so the
|
|
// newest timelineHardCap entries are the tail.
|
|
func takeNewest[T any](rows []T) ([]T, bool) {
|
|
if len(rows) <= timelineHardCap {
|
|
return rows, false
|
|
}
|
|
return rows[len(rows)-timelineHardCap:], true
|
|
}
|
|
|
|
// timelinePaginatedResponse mirrors the wrapper shape produced by the prior
|
|
// cursor-paginated ListTimeline (#2128). It is preserved as a backward-compat
|
|
// surface for installed Desktop builds and stale Web bundles between #2128 and
|
|
// #1929 that send `?limit=`/`?before=`/`?after=`/`?around=` and parse the
|
|
// response with the old TimelinePageSchema (entries + cursors). Cursors are
|
|
// always nil and `has_more_after` is always false: the new server returns the
|
|
// whole timeline in one shot. `has_more_before` is now truthful — it reports the
|
|
// hard-cap clamp — instead of being hardcoded false.
|
|
type timelinePaginatedResponse struct {
|
|
Entries []TimelineEntry `json:"entries"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
PrevCursor *string `json:"prev_cursor"`
|
|
HasMoreBefore bool `json:"has_more_before"`
|
|
HasMoreAfter bool `json:"has_more_after"`
|
|
TargetIndex *int `json:"target_index,omitempty"`
|
|
}
|
|
|
|
// ListTimeline returns the full issue timeline (comments + activities merged).
|
|
// Two response shapes coexist for boundary compatibility (#1929):
|
|
//
|
|
// - No pagination params → flat ASC `TimelineEntry[]`. Matches the legacy
|
|
// desktop contract (Multica.app ≤ v0.2.25) and the new client.
|
|
// - Any of `limit` / `before` / `after` / `around` present → wrapped object
|
|
// with DESC entries + null cursors + has_more_after=false. Matches what a
|
|
// stale v0.2.26+ build expects when it parses the response with
|
|
// TimelinePageSchema; cursor-walking is now a no-op so the client just
|
|
// sees a single full page.
|
|
//
|
|
// Both shapes carry the same set of entries — paging and ordering differ.
|
|
// Time-based pagination was removed because it split reply threads at page
|
|
// boundaries, and at observed data sizes (p99 ~30 comments per issue) the
|
|
// cursor machinery was pure overhead.
|
|
//
|
|
// When the hard cap fires each list is independently reduced to its newest
|
|
// entries and X-Timeline-Truncated names which kinds were affected. Comment
|
|
// threads cut by the window are completed afterwards within a bounded context
|
|
// budget; a thread that cannot be completed is omitted as one unit (MUL-5492).
|
|
func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
|
|
comments, err := h.Queries.ListCommentsForIssue(ctx, db.ListCommentsForIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
Limit: timelineProbeLimit(),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list comments")
|
|
return
|
|
}
|
|
activities, err := h.Queries.ListActivitiesForIssue(ctx, db.ListActivitiesForIssueParams{
|
|
IssueID: issue.ID,
|
|
Limit: timelineProbeLimit(),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list activities")
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
wantWrapped := q.Get("limit") != "" || q.Get("before") != "" ||
|
|
q.Get("after") != "" || q.Get("around") != ""
|
|
|
|
// Each list is capped independently and reports its own truncation. The two
|
|
// are deliberately NOT clamped to a shared floor.
|
|
//
|
|
// An earlier revision did clamp them, to guarantee the returned window was a
|
|
// contiguous correctly-interleaved slice with no region holding only one of
|
|
// the two kinds. That traded the wrong way round. Comments are human-paced
|
|
// (p99 ~30, max ever observed ~1.1k) so they essentially never reach the cap,
|
|
// while activity is machine-paced and reaches it routinely — so the shared
|
|
// floor was almost always the ACTIVITY floor cutting away comments that had
|
|
// been fetched successfully and would have rendered fine. It deleted real
|
|
// content to buy a cosmetic property, and on a busy issue with thirty
|
|
// comments it was pure loss.
|
|
//
|
|
// Not clamping costs only activity density in the older part of the range,
|
|
// which is metadata, not content — and it is reported rather than hidden.
|
|
// It also keeps the comment set from being cut at an arbitrary row, which is
|
|
// why completeCommentThreads repairs any affected thread below.
|
|
comments, commentsTruncated := takeNewest(comments)
|
|
activities, activitiesTruncated := takeNewest(activities)
|
|
|
|
// Timeline clients derive resolution bars, author lists, and folded counts
|
|
// from the comments in this response. A parent-only repair would therefore
|
|
// make a partial thread look complete. Restore all missing siblings and
|
|
// descendants for affected roots within the shared context budget; if the
|
|
// walk cannot prove a thread complete, omit that thread as one unit.
|
|
if commentsTruncated {
|
|
var err error
|
|
comments, err = h.completeCommentThreads(ctx, issue.ID, issue.WorkspaceID, comments)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to complete comment threads")
|
|
return
|
|
}
|
|
}
|
|
|
|
if kinds := truncatedKinds(commentsTruncated, activitiesTruncated); kinds != "" {
|
|
w.Header().Set(HeaderTimelineTruncated, kinds)
|
|
}
|
|
|
|
entries := h.mergeTimeline(r, comments, activities, !wantWrapped)
|
|
// The current-member directory deliberately excludes departed members, but
|
|
// timeline attribution must remain readable after they leave. Hydrate only
|
|
// the member ids already present in this authorised issue response; lookup
|
|
// failure is display-only and must not make the timeline unavailable.
|
|
h.hydrateTimelineMemberActors(ctx, entries)
|
|
if entries == nil {
|
|
entries = []TimelineEntry{}
|
|
}
|
|
|
|
if wantWrapped {
|
|
resp := timelinePaginatedResponse{
|
|
Entries: entries,
|
|
HasMoreBefore: commentsTruncated || activitiesTruncated,
|
|
}
|
|
// `around=<id>`: locate the anchor in the DESC slice so the legacy
|
|
// client can scroll-to-highlight without a follow-up request.
|
|
if anchor := q.Get("around"); anchor != "" {
|
|
for i, e := range entries {
|
|
if e.ID == anchor {
|
|
idx := i
|
|
resp.TargetIndex = &idx
|
|
break
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, entries)
|
|
}
|
|
|
|
// mergeTimeline merges comments and activities and returns them sorted by
|
|
// (created_at, id). When ascending=true, oldest first (the new flat-array
|
|
// contract); otherwise newest first (the wrapped legacy contract).
|
|
func (h *Handler) mergeTimeline(r *http.Request, comments []db.Comment, activities []db.ActivityLog, ascending bool) []TimelineEntry {
|
|
out := make([]TimelineEntry, 0, len(comments)+len(activities))
|
|
out = append(out, h.commentsToEntries(r, comments)...)
|
|
for _, a := range activities {
|
|
out = append(out, activityToEntry(a))
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].CreatedAt != out[j].CreatedAt {
|
|
if ascending {
|
|
return out[i].CreatedAt < out[j].CreatedAt
|
|
}
|
|
return out[i].CreatedAt > out[j].CreatedAt
|
|
}
|
|
if ascending {
|
|
return out[i].ID < out[j].ID
|
|
}
|
|
return out[i].ID > out[j].ID
|
|
})
|
|
return out
|
|
}
|
|
|
|
// commentsToEntries fetches reactions + attachments for the given comments in
|
|
// one batch each and returns enriched TimelineEntry slices preserving order.
|
|
func (h *Handler) commentsToEntries(r *http.Request, comments []db.Comment) []TimelineEntry {
|
|
if len(comments) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]pgtype.UUID, len(comments))
|
|
for i, c := range comments {
|
|
ids[i] = c.ID
|
|
}
|
|
reactions := h.groupReactions(r, ids)
|
|
attachments := h.groupAttachments(r, ids)
|
|
|
|
out := make([]TimelineEntry, len(comments))
|
|
for i, c := range comments {
|
|
content := c.Content
|
|
commentType := c.Type
|
|
updatedAt := timestampToString(c.UpdatedAt)
|
|
cid := uuidToString(c.ID)
|
|
out[i] = TimelineEntry{
|
|
Type: "comment",
|
|
ID: cid,
|
|
ActorType: c.AuthorType,
|
|
ActorID: uuidToString(c.AuthorID),
|
|
Content: &content,
|
|
CommentType: &commentType,
|
|
QuickActionID: uuidToPtr(c.QuickActionID),
|
|
ParentID: uuidToPtr(c.ParentID),
|
|
CreatedAt: timestampToString(c.CreatedAt),
|
|
UpdatedAt: &updatedAt,
|
|
Revision: c.Revision,
|
|
Reactions: reactions[cid],
|
|
Attachments: attachments[cid],
|
|
ResolvedAt: timestampToPtr(c.ResolvedAt),
|
|
ResolvedByType: textToPtr(c.ResolvedByType),
|
|
ResolvedByID: uuidToPtr(c.ResolvedByID),
|
|
SourceTaskID: uuidToPtr(c.SourceTaskID),
|
|
DeletedAt: timestampToPtr(c.DeletedAt),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func activityToEntry(a db.ActivityLog) TimelineEntry {
|
|
action := a.Action
|
|
actorType := ""
|
|
if a.ActorType.Valid {
|
|
actorType = a.ActorType.String
|
|
}
|
|
return TimelineEntry{
|
|
Type: "activity",
|
|
ID: uuidToString(a.ID),
|
|
ActorType: actorType,
|
|
ActorID: uuidToString(a.ActorID),
|
|
Action: &action,
|
|
Details: a.Details,
|
|
CreatedAt: timestampToString(a.CreatedAt),
|
|
}
|
|
}
|
|
|
|
// hydrateTimelineMemberActors adds display identity for member-authored rows
|
|
// without changing the active-member directory's semantics. The ids came from
|
|
// comments/activity rows on an issue loadIssueForUser already authorised, so
|
|
// this creates no arbitrary user lookup surface. The query is bounded by the
|
|
// timeline hard caps and runs once for the whole response, never once per row.
|
|
func (h *Handler) hydrateTimelineMemberActors(ctx context.Context, entries []TimelineEntry) {
|
|
seen := make(map[string]struct{})
|
|
ids := make([]pgtype.UUID, 0)
|
|
for i := range entries {
|
|
entry := &entries[i]
|
|
if entry.ActorType != "member" || entry.ActorID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[entry.ActorID]; ok {
|
|
continue
|
|
}
|
|
id, err := util.ParseUUID(entry.ActorID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
seen[entry.ActorID] = struct{}{}
|
|
ids = append(ids, id)
|
|
}
|
|
if len(ids) == 0 {
|
|
return
|
|
}
|
|
|
|
users, err := h.Queries.GetUsersByIDs(ctx, ids)
|
|
if err != nil {
|
|
return
|
|
}
|
|
byID := make(map[string]db.GetUsersByIDsRow, len(users))
|
|
for _, user := range users {
|
|
byID[uuidToString(user.ID)] = user
|
|
}
|
|
for i := range entries {
|
|
entry := &entries[i]
|
|
if entry.ActorType != "member" {
|
|
continue
|
|
}
|
|
user, ok := byID[entry.ActorID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
entry.ActorName = user.Name
|
|
if user.AvatarUrl.Valid {
|
|
entry.ActorAvatarURL = h.resolveAvatarURL(user.AvatarUrl.String)
|
|
}
|
|
}
|
|
}
|
|
|
|
// AssigneeFrequencyEntry represents how often a user assigns to a specific target.
|
|
type AssigneeFrequencyEntry struct {
|
|
AssigneeType string `json:"assignee_type"`
|
|
AssigneeID string `json:"assignee_id"`
|
|
Frequency int64 `json:"frequency"`
|
|
}
|
|
|
|
// GetAssigneeFrequency returns assignee usage frequency for the current user,
|
|
// combining data from assignee change activities and initial issue assignments.
|
|
func (h *Handler) GetAssigneeFrequency(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
// Aggregate frequency from both data sources.
|
|
freq := map[string]int64{} // key: "type:id"
|
|
|
|
// Source 1: assignee_changed activities by this user.
|
|
activityCounts, err := h.Queries.CountAssigneeChangesByActor(r.Context(), db.CountAssigneeChangesByActorParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
ActorID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
|
|
return
|
|
}
|
|
for _, row := range activityCounts {
|
|
aType, _ := row.AssigneeType.(string)
|
|
aID, _ := row.AssigneeID.(string)
|
|
if aType != "" && aID != "" {
|
|
freq[aType+":"+aID] += row.Frequency
|
|
}
|
|
}
|
|
|
|
// Source 2: issues created by this user with an assignee.
|
|
issueCounts, err := h.Queries.CountCreatedIssueAssignees(r.Context(), db.CountCreatedIssueAssigneesParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
CreatorID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
|
|
return
|
|
}
|
|
for _, row := range issueCounts {
|
|
if !row.AssigneeType.Valid || !row.AssigneeID.Valid {
|
|
continue
|
|
}
|
|
key := row.AssigneeType.String + ":" + uuidToString(row.AssigneeID)
|
|
freq[key] += row.Frequency
|
|
}
|
|
|
|
// Build sorted response.
|
|
result := make([]AssigneeFrequencyEntry, 0, len(freq))
|
|
for key, count := range freq {
|
|
// Split "type:id" — type is always "member" or "agent" (no colons).
|
|
var aType, aID string
|
|
for i := 0; i < len(key); i++ {
|
|
if key[i] == ':' {
|
|
aType = key[:i]
|
|
aID = key[i+1:]
|
|
break
|
|
}
|
|
}
|
|
result = append(result, AssigneeFrequencyEntry{
|
|
AssigneeType: aType,
|
|
AssigneeID: aID,
|
|
Frequency: count,
|
|
})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].Frequency > result[j].Frequency
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|