Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions go/cmd/compass-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ var version = "0.1.0"
// RPC).
const apiVersion = "compass.v1"

// (no default public URL: the managed-service host is a deployment concern that
// never lives in this repo. --public-url / $COMPASS_PUBLIC_URL supplies it;
// unset means empty, which the responder-assembly boot guard rejects for a
// deployment that consumes Linear webhooks, and which yields relative deep-link
// fragments for a socket-only local deploy.)

// errUsage marks a CLI usage error (a bad flag) that buildServeConfig's FlagSet
// has ALREADY reported to stderr (usage + the parse error). run() returns it so
// main() can exit non-zero without re-logging it through slog — a typo'd flag is
Expand Down Expand Up @@ -192,6 +198,7 @@ func buildServeConfig(args []string) (server.ServeConfig, bool, error) {
StateDir: *f.stateDir,
AdminHandle: *f.adminHandle,
CORSAllowedOrigin: *f.corsAllowedOrigin,
PublicURL: firstNonEmpty(*f.publicURL, os.Getenv("COMPASS_PUBLIC_URL")),
}, false, nil
}

Expand Down Expand Up @@ -230,6 +237,7 @@ type serveFlags struct {
stateDir *string
adminHandle *string
corsAllowedOrigin *string
publicURL *string
}

// registerServeFlags declares the core compass-server flags on the given FlagSet
Expand Down Expand Up @@ -284,6 +292,12 @@ func registerServeFlags(fs *flag.FlagSet) serveFlags {
corsAllowedOrigin: fs.String("cors-allowed-origin", "",
"Single browser origin the network door exposes gRPC-Web CORS for "+
"(e.g. https://host.example.ts.net). Empty = no CORS on the network door."),
publicURL: fs.String("public-url", "",
"Per-deployment public base URL Compass is reachable at (e.g. "+
"https://host.example.ts.net), the base for the Linear Agent "+
"responder's \"Open in Compass\" deep links. Falls back to "+
"$COMPASS_PUBLIC_URL. No default: a deployment that consumes "+
"Linear webhooks must set it."),
}
}

Expand Down Expand Up @@ -432,13 +446,15 @@ func parseForgeRepos(repos string) ([]string, error) {
return out, nil
}

// firstNonEmpty returns a if it is non-empty, else b — the flag-then-env
// precedence used across the server config.
func firstNonEmpty(a, b string) string {
if a != "" {
return a
// firstNonEmpty returns the first non-empty argument, or "" when all are empty —
// the flag-then-env precedence used across the server config.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return b
return ""
}

// envTrue reports whether an env value is a truthy toggle ("1"/"true", any case).
Expand Down
31 changes: 27 additions & 4 deletions go/internal/store/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,29 @@ func (s *Store) adminByHandle(ctx context.Context, handle string) (Account, erro
// ErrConflict and fails startup — never silent adoption, mirroring
// adminByHandle's posture.
func (s *Store) EnsureSystemAccount(ctx context.Context) (Account, error) {
return s.ensureSystemSubtypeAccount(ctx, SystemAccountHandle, systemAccountDisplayName)
}

// EnsureLinearBridgeAccount ensures the reserved Linear bridge sender (@linear)
// exists and returns it, idempotently — the author of Part 2 bridge posts. It
// mints a SECOND system-subtype account beside @compass with the exact same
// find-or-create shape (see ensureSystemSubtypeAccount): one accounts row plus a
// system_accounts row on first boot, the existing row fetched on every later
// boot. A pre-existing @linear row of the wrong shape is ErrConflict.
func (s *Store) EnsureLinearBridgeAccount(ctx context.Context) (Account, error) {
return s.ensureSystemSubtypeAccount(ctx, LinearBridgeAccountHandle, linearBridgeDisplayName)
}

// ensureSystemSubtypeAccount is the shared find-or-create for a reserved
// system-subtype account (handle + display name). Mirrors BootstrapAdmin's
// unique-violation-means-fetch shape: on first boot it mints one accounts row
// with a system_accounts subtype row — NOT a user or agent row; on every later
// boot the insert hits the unique handle and the existing row is fetched and
// returned. Its own insert is deliberately NOT routed through validateHandle:
// this is the one path that mints a reserved handle. A pre-existing row of the
// wrong shape (a user or agent row from a pre-guard database) is ErrConflict and
// fails startup — never silent adoption, mirroring adminByHandle's posture.
func (s *Store) ensureSystemSubtypeAccount(ctx context.Context, handle, displayName string) (Account, error) {
id := newID()
tx, err := s.pool.Begin(ctx)
if err != nil {
Expand All @@ -150,11 +173,11 @@ func (s *Store) EnsureSystemAccount(ctx context.Context) (Account, error) {

if _, err := tx.Exec(ctx,
"INSERT INTO accounts (id, handle, display_name) VALUES ($1, $2, $3)",
id, SystemAccountHandle, systemAccountDisplayName,
id, handle, displayName,
); err != nil {
if pgErrIs(err, pgUniqueViolation) {
// Already seeded (restart): fetch and return the existing system account.
return s.systemByHandle(ctx, SystemAccountHandle)
return s.systemByHandle(ctx, handle)
}
return Account{}, fmt.Errorf("store: insert account: %w", err)
}
Expand All @@ -169,8 +192,8 @@ func (s *Store) EnsureSystemAccount(ctx context.Context) (Account, error) {

return Account{
ID: AccountID(id),
Handle: SystemAccountHandle,
DisplayName: systemAccountDisplayName,
Handle: handle,
DisplayName: displayName,
System: &SystemAccount{},
}, nil
}
Expand Down
31 changes: 31 additions & 0 deletions go/internal/store/forge_authored.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,37 @@ func (s *Store) AuthoredArtifactByRequestID(ctx context.Context, agent AccountID
return a, true, nil
}

// AuthoredArtifactByCoordinate reads the ownership row at a forge coordinate —
// the by-coordinate lookup T4 uses to resolve a delegated issue's recorded
// authoring agent (design compass-linear-agent-responder §Part 2). The forge
// coordinate (provider, host, repo, kind, number) IS the
// forge_authored_artifacts PK, so this is a trivial PK lookup: an unknown
// coordinate is ErrNotFound; zero/empty coordinate fields (or a zero kind) are
// ErrInvalidArgument, mirroring the validation RecordAuthoredArtifact applies.
func (s *Store) AuthoredArtifactByCoordinate(ctx context.Context, provider ForgeProvider, host, repo string, kind ForgeArtifactKind, number uint64) (AuthoredArtifact, error) {
if err := validCoordinate(provider, host, repo); err != nil {
return AuthoredArtifact{}, err
}
if kind == ForgeArtifactKindUnspecified {
return AuthoredArtifact{}, fmt.Errorf("%w: artifact kind is required", ErrInvalidArgument)
}
row := s.pool.QueryRow(ctx,
`SELECT forge_provider, forge_host, repo, kind, number,
agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms
FROM forge_authored_artifacts
WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 AND number = $5`,
int32(provider), host, repo, int32(kind), int64(number), //nolint:gosec // G115: number is a canonical forge artifact number (a positive issue/PR number) written to a BIGINT, always well within the int64 domain.
)
a, err := scanAuthoredArtifact(row)
if err != nil {
if noRows(err) {
return AuthoredArtifact{}, fmt.Errorf("%w: authored artifact at coordinate %d/%s/%s kind %d number %d", ErrNotFound, provider, host, repo, kind, number)
}
return AuthoredArtifact{}, fmt.Errorf("store: read authored artifact by coordinate: %w", err)
}
return a, nil
}

// ListAuthoredArtifactsByAgent reads every artifact the agent authored, ordered
// deterministically by created_at then coordinate. No rows is a nil slice, not
// an error. Zero agent -> ErrInvalidArgument.
Expand Down
14 changes: 8 additions & 6 deletions go/internal/store/handle.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ import (
var handleRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)

// reservedHandles are handles no account may register. `compass` is the system
// handle; `everyone`/`agents`/`users` are the server's reserved broadcast
// mentions — an account registered as one of those would shadow a live
// broadcast semantic. The reserved-mention names duplicate delivery's
// reservedMentions (consumer.go:326, the source of truth) rather than importing
// the delivery package, to avoid a store->delivery dependency; keep them in
// sync.
// handle and `linear` the Linear bridge system handle (both seeded as
// system-subtype accounts, never registrable by a user/agent);
// `everyone`/`agents`/`users` are the server's reserved broadcast mentions — an
// account registered as one of those would shadow a live broadcast semantic.
// The reserved-mention names duplicate delivery's reservedMentions
// (consumer.go:326, the source of truth) rather than importing the delivery
// package, to avoid a store->delivery dependency; keep them in sync.
var reservedHandles = map[string]bool{
"compass": true,
"linear": true,
"everyone": true,
"agents": true,
"users": true,
Expand Down
98 changes: 98 additions & 0 deletions go/internal/store/linear_sessions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package store

import (
"context"
"fmt"
"time"

"github.com/jackc/pgx/v5"
)

// The Linear Agent Session association (compass-linear-agent-responder
// design.md §Part 2 / §T3): the durable link between a Linear AgentSession and
// the Compass conversation the responder routed it to. Written on a `created`
// event (UpsertLinearAgentSession, idempotent on the session-id PK) and read on
// a `prompted` event (LinearAgentSession) to route the follow-up to the same
// Manager/topic. No dedup column — message-level dedup is the comms rail's
// client_request_id (§Part 1); the association insert is idempotent on its own.

// LinearAgentSessionRow is one association row: the Linear session id, the Compass
// Manager the delegated issue routed to, that Manager's home channel, the comms
// topic the conversation landed in, and the issue it was delegated on
// (provenance; "" when none). CreatedAt is the server-assigned birth time.
type LinearAgentSessionRow struct {
LinearSessionID string
ManagerAccountID AccountID
ChannelID ChannelID
TopicID string
LinearIssueID string // provenance; "" = no issue recorded (stored as SQL NULL)
CreatedAt time.Time
}

// UpsertLinearAgentSession idempotently records the association at row's
// linear_session_id: INSERT … ON CONFLICT (linear_session_id) DO NOTHING. It
// returns created=true when this call inserted the row and created=false on a
// replay (the session was already associated) — the caller uses that to skip
// the one-time `created`-side work (routing, ack thought, deep link) on a
// redelivered `created` event. An empty linear_session_id is a caller bug
// (ErrInvalidArgument). LinearIssueID "" is stored as SQL NULL.
func (s *Store) UpsertLinearAgentSession(ctx context.Context, row LinearAgentSessionRow) (created bool, err error) {
if row.LinearSessionID == "" {
return false, fmt.Errorf("%w: linear session id is required", ErrInvalidArgument)
}
tag, err := s.pool.Exec(ctx,
`INSERT INTO linear_agent_sessions
(linear_session_id, manager_account_id, channel_id, topic_id, linear_issue_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (linear_session_id) DO NOTHING`,
row.LinearSessionID, string(row.ManagerAccountID), string(row.ChannelID),
row.TopicID, nullIfEmpty(row.LinearIssueID),
)
if err != nil {
return false, fmt.Errorf("store: upsert linear agent session: %w", err)
}
return tag.RowsAffected() == 1, nil
}

// LinearAgentSession reads the association for linearSessionID — the `prompted`
// lookup that routes a follow-up to the recorded Manager/topic. An unknown
// session id is ErrNotFound; an empty id is ErrInvalidArgument.
func (s *Store) LinearAgentSession(ctx context.Context, linearSessionID string) (LinearAgentSessionRow, error) {
if linearSessionID == "" {
return LinearAgentSessionRow{}, fmt.Errorf("%w: linear session id is required", ErrInvalidArgument)
}
row := s.pool.QueryRow(ctx,
`SELECT linear_session_id, manager_account_id, channel_id, topic_id, linear_issue_id, created_at
FROM linear_agent_sessions
WHERE linear_session_id = $1`,
linearSessionID,
)
r, err := scanLinearAgentSession(row)
if err != nil {
if noRows(err) {
return LinearAgentSessionRow{}, fmt.Errorf("%w: linear agent session %q", ErrNotFound, linearSessionID)
}
return LinearAgentSessionRow{}, fmt.Errorf("store: read linear agent session: %w", err)
}
return r, nil
}

// scanLinearAgentSession scans one row into a LinearAgentSessionRow, mapping the
// nullable linear_issue_id column to "" (no issue) via a pgx-native scan.
func scanLinearAgentSession(row pgx.Row) (LinearAgentSessionRow, error) {
var (
r LinearAgentSessionRow
manager string
channel string
issueID *string
)
if err := row.Scan(&r.LinearSessionID, &manager, &channel, &r.TopicID, &issueID, &r.CreatedAt); err != nil {
return LinearAgentSessionRow{}, err
}
r.ManagerAccountID = AccountID(manager)
r.ChannelID = ChannelID(channel)
if issueID != nil {
r.LinearIssueID = *issueID
}
return r, nil
}
Loading
Loading