agent build
This commit is contained in:
274
go-api/internal/tools/activity.go
Normal file
274
go-api/internal/tools/activity.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// Periods a caller may ask for. A closed set, because a period is a window this
|
||||
// code computes — never a date a caller supplies, and never a string
|
||||
// interpolated anywhere near SQL.
|
||||
var periodWindows = map[string]time.Duration{
|
||||
"today": 24 * time.Hour,
|
||||
"last-7-days": 7 * 24 * time.Hour,
|
||||
"last-30-days": 30 * 24 * time.Hour,
|
||||
"this-month": 0, // computed from the 1st — see windowFor
|
||||
"previous-month": 0,
|
||||
}
|
||||
|
||||
type activityInput struct {
|
||||
Period string `json:"period"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// ActivityBreakdown counts the audit log by kind of event and by account.
|
||||
//
|
||||
// The Go port of `activity.breakdown` from the frontend's dataResolver, and the
|
||||
// difference is the entire point of Phase 2. The JavaScript took records that
|
||||
// had already been fetched into the browser:
|
||||
//
|
||||
// 'activity.breakdown': ({ activity = [] }, section, now) => …
|
||||
//
|
||||
// It had no principal, so it could not have authorized anything even if it had
|
||||
// wanted to — the filtering had already happened, or hadn't, somewhere else.
|
||||
// This version takes the caller, resolves the policy, and pushes the resulting
|
||||
// predicate into the query. A talent caller's own scope is a WHERE clause, so
|
||||
// the counts, the shares and the "accounts active" figure are all computed over
|
||||
// exactly the rows that caller could have read directly. I1 and I2, in one
|
||||
// query.
|
||||
func ActivityBreakdown(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "activity_breakdown",
|
||||
Description: "Count workspace activity by kind of event and by account, " +
|
||||
"optionally within a period. Returns totals, the number of distinct event " +
|
||||
"kinds, how many accounts were active, and a row per event kind with its " +
|
||||
"count and share of the total. Use this for questions about what has " +
|
||||
"happened, who did it, and in what proportion.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"period": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"today", "last-7-days", "last-30-days", "this-month", "previous-month"},
|
||||
"description": "The window to count within. Omit to count the whole log. " +
|
||||
"Windows are computed from the current date; do not pass a date.",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "How many event kinds to return, most frequent first. Defaults to 20.",
|
||||
},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: activityBreakdownHandler(db),
|
||||
}
|
||||
}
|
||||
|
||||
func activityBreakdownHandler(db repo.Querier) Handler {
|
||||
return func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
// Authorization, first line of the body. §8.
|
||||
role, ok := domain.ParseRole(tc.Principal.Role)
|
||||
if !ok || !activityPolicy().Allows(domain.OpList, role) {
|
||||
return Denied()
|
||||
}
|
||||
if tc.OrgID() == "" {
|
||||
// No tenant means no query. I5 — there is no "all organizations"
|
||||
// read, and a missing org is a bug upstream, not a wildcard.
|
||||
return Denied()
|
||||
}
|
||||
|
||||
var in activityInput
|
||||
if len(inputs) > 0 {
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
}
|
||||
}
|
||||
if in.Limit <= 0 {
|
||||
in.Limit = 20
|
||||
}
|
||||
if in.Limit > 100 {
|
||||
in.Limit = 100
|
||||
}
|
||||
|
||||
from, to, err := windowFor(in.Period, time.Now())
|
||||
if err != nil {
|
||||
return Failf(CodeInvalidInput, "%s", err.Error())
|
||||
}
|
||||
|
||||
// The predicate, built from the policy rather than written by hand.
|
||||
// Every value is a bind parameter; no identifier comes from input.
|
||||
where := []string{"org_id = $1::uuid"}
|
||||
args := []any{tc.OrgID()}
|
||||
|
||||
if scope := activityPolicy().ScopeFor(role); scope.Kind == domain.ScopeEmail {
|
||||
// A talent caller sees their own entries. Pushed into the query,
|
||||
// so the totals and shares below are computed over their rows and
|
||||
// nobody else's — post-filtering here would leak the organization's
|
||||
// volume through every percentage.
|
||||
args = append(args, tc.Principal.Email)
|
||||
where = append(where, fmt.Sprintf("%s = $%d", scope.Column, len(args)))
|
||||
}
|
||||
|
||||
if !from.IsZero() {
|
||||
args = append(args, from)
|
||||
where = append(where, fmt.Sprintf("created_date >= $%d", len(args)))
|
||||
args = append(args, to)
|
||||
where = append(where, fmt.Sprintf("created_date < $%d", len(args)))
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT event_type, count(*) AS n
|
||||
FROM user_activity
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
GROUP BY event_type
|
||||
ORDER BY n DESC, event_type ASC`
|
||||
|
||||
rows, err := db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the activity log could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type kind struct {
|
||||
Event string `json:"event"`
|
||||
Count int64 `json:"count"`
|
||||
Share int `json:"sharePercent"`
|
||||
}
|
||||
|
||||
var (
|
||||
kinds []kind
|
||||
total int64
|
||||
)
|
||||
for rows.Next() {
|
||||
var eventType string
|
||||
var n int64
|
||||
if err := rows.Scan(&eventType, &n); err != nil {
|
||||
return Failf(CodeFailed, "the activity log could not be read")
|
||||
}
|
||||
total += n
|
||||
// The stored name is a machine key. Rendered as words so the model
|
||||
// is not left translating `hire_candidate` and guessing.
|
||||
kinds = append(kinds, kind{Event: strings.ReplaceAll(eventType, "_", " "), Count: n})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the activity log could not be read")
|
||||
}
|
||||
|
||||
for i := range kinds {
|
||||
if total > 0 {
|
||||
kinds[i].Share = int(float64(kinds[i].Count)/float64(total)*100 + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// Counted before the limit is applied, so "how many kinds are there"
|
||||
// stays true even when the list shown is shorter.
|
||||
distinctKinds := len(kinds)
|
||||
omitted := 0
|
||||
if len(kinds) > in.Limit {
|
||||
omitted = len(kinds) - in.Limit
|
||||
kinds = kinds[:in.Limit]
|
||||
}
|
||||
|
||||
accounts, err := countActiveAccounts(ctx, db, where, args)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the activity log could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"totalEvents": total,
|
||||
"distinctKinds": distinctKinds,
|
||||
"activeAccounts": accounts,
|
||||
"kinds": kinds,
|
||||
"period": periodOrAll(in.Period),
|
||||
}
|
||||
// Never a silent drop: a model handed a shortened list with no marker
|
||||
// reasons about it as if it were the whole.
|
||||
if omitted > 0 {
|
||||
data["omittedKinds"] = omitted
|
||||
}
|
||||
if total == 0 {
|
||||
data["note"] = "No activity matches that. This is a real answer, not a failure to look."
|
||||
}
|
||||
|
||||
return OK(data)
|
||||
}
|
||||
}
|
||||
|
||||
// countActiveAccounts counts distinct actors under the same predicate.
|
||||
//
|
||||
// The same WHERE the breakdown used, so the two figures cannot disagree — a
|
||||
// separate hand-written predicate here is exactly how "12 events across 40
|
||||
// accounts" gets shipped.
|
||||
func countActiveAccounts(ctx context.Context, db repo.Querier, where []string, args []any) (int64, error) {
|
||||
var n int64
|
||||
err := db.QueryRow(ctx,
|
||||
`SELECT count(DISTINCT user_email) FROM user_activity WHERE `+strings.Join(where, " AND "),
|
||||
args...,
|
||||
).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func periodOrAll(p string) string {
|
||||
if p == "" {
|
||||
return "all time"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// windowFor resolves a period name to a half-open [from, to).
|
||||
//
|
||||
// Computed from the clock at read time, never stored and never supplied. A zero
|
||||
// `from` means "no window" — count everything.
|
||||
func windowFor(period string, now time.Time) (from, to time.Time, err error) {
|
||||
if period == "" {
|
||||
return time.Time{}, time.Time{}, nil
|
||||
}
|
||||
if _, ok := periodWindows[period]; !ok {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("%q is not a period this tool knows", period)
|
||||
}
|
||||
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
switch period {
|
||||
case "today":
|
||||
return startOfDay, startOfDay.AddDate(0, 0, 1), nil
|
||||
case "last-7-days":
|
||||
return startOfDay.AddDate(0, 0, -6), startOfDay.AddDate(0, 0, 1), nil
|
||||
case "last-30-days":
|
||||
return startOfDay.AddDate(0, 0, -29), startOfDay.AddDate(0, 0, 1), nil
|
||||
case "this-month":
|
||||
return firstOfMonth, firstOfMonth.AddDate(0, 1, 0), nil
|
||||
case "previous-month":
|
||||
return firstOfMonth.AddDate(0, -1, 0), firstOfMonth, nil
|
||||
}
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("%q is not a period this tool knows", period)
|
||||
}
|
||||
|
||||
// activityPolicy is the audit log's access rules.
|
||||
//
|
||||
// Read from the descriptor table rather than restated here. §13 lists "passing
|
||||
// the tenant id as a plain function argument through five layers" as an
|
||||
// anti-pattern for the same reason this matters: an authorization rule with two
|
||||
// copies has two chances to drift, and the copy in the tool layer would be the
|
||||
// one nobody re-reads when the policy changes.
|
||||
//
|
||||
// A missing descriptor yields a nil *Policy, which denies everything — the
|
||||
// deny-by-default the table already promises.
|
||||
func activityPolicy() *domain.Policy {
|
||||
res, ok := domain.ResourceByPath["user-activity"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return res.Policy
|
||||
}
|
||||
210
go-api/internal/tools/activity_test.go
Normal file
210
go-api/internal/tools/activity_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// freshOrg creates an organization with no history.
|
||||
//
|
||||
// The harness seeds a demo tenant that already has activity in it, so these
|
||||
// tests build their own: an assertion of "exactly 10 events" is only a real
|
||||
// assertion when the fixture controls every row.
|
||||
func freshOrg(t *testing.T, h *testutil.Harness, slug string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := h.Pool.QueryRow(context.Background(),
|
||||
`INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`,
|
||||
slug, slug).Scan(&id); err != nil {
|
||||
t.Fatalf("create org %s: %v", slug, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// seedActivity writes audit rows for two accounts in one org, and one in
|
||||
// another, so a leak across either boundary is detectable.
|
||||
func seedActivity(t *testing.T, h *testutil.Harness, mineOrg, otherOrg string) (admin, talent authctx.Identity) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
rows := []struct {
|
||||
org, eventType, email string
|
||||
n int
|
||||
}{
|
||||
{mineOrg, "login", "boss@example.test", 5},
|
||||
{mineOrg, "hire_candidate", "boss@example.test", 3},
|
||||
{mineOrg, "login", "worker@example.test", 2},
|
||||
// A different tenant entirely. Must never appear in either caller's
|
||||
// counts, shares or account total.
|
||||
{otherOrg, "login", "outsider@example.test", 40},
|
||||
{otherOrg, "delete_position", "outsider@example.test", 40},
|
||||
}
|
||||
for _, r := range rows {
|
||||
for i := 0; i < r.n; i++ {
|
||||
if _, err := h.Pool.Exec(ctx,
|
||||
`INSERT INTO user_activity (org_id, event_type, user_email, user_name)
|
||||
VALUES ($1::uuid, $2, $3, 'Someone')`,
|
||||
r.org, r.eventType, r.email); err != nil {
|
||||
t.Fatalf("seed activity: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return authctx.Identity{UserID: "u1", OrgID: mineOrg, Role: "admin", Email: "boss@example.test"},
|
||||
authctx.Identity{UserID: "u2", OrgID: mineOrg, Role: "talent", Email: "worker@example.test"}
|
||||
}
|
||||
|
||||
// seeded builds two fresh tenants and fills them. Returns the two callers.
|
||||
func seeded(t *testing.T, h *testutil.Harness) (admin, talent authctx.Identity) {
|
||||
t.Helper()
|
||||
mine := freshOrg(t, h, "mine-co")
|
||||
other := freshOrg(t, h, "other-co")
|
||||
return seedActivity(t, h, mine, other)
|
||||
}
|
||||
|
||||
func runBreakdown(t *testing.T, h *testutil.Harness, ident authctx.Identity, args string) tools.Result {
|
||||
t.Helper()
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(tools.ActivityBreakdown(h.Pool))
|
||||
return reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: ident, RunID: "run_test"},
|
||||
"activity_breakdown", json.RawMessage(args))
|
||||
}
|
||||
|
||||
func data(t *testing.T, res tools.Result) map[string]any {
|
||||
t.Helper()
|
||||
if res.Error != nil {
|
||||
t.Fatalf("unexpected tool error: %s — %s", res.Error.Code, res.Error.Message)
|
||||
}
|
||||
encoded, _ := json.Marshal(res.Data)
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(encoded, &m); err != nil {
|
||||
t.Fatalf("result was not an object: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestActivityBreakdownScopesToTheTenant(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
admin, _ := seeded(t, h)
|
||||
|
||||
got := data(t, runBreakdown(t, h, admin, `{}`))
|
||||
|
||||
// 10 in this org. The other tenant's 80 must not be counted, and must not
|
||||
// show up in the share arithmetic either.
|
||||
if n := got["totalEvents"].(float64); n != 10 {
|
||||
t.Errorf("totalEvents = %v, want 10 — the other tenant's rows leaked", n)
|
||||
}
|
||||
if n := got["activeAccounts"].(float64); n != 2 {
|
||||
t.Errorf("activeAccounts = %v, want 2", n)
|
||||
}
|
||||
// `delete_position` exists only in the other org.
|
||||
encoded, _ := json.Marshal(got["kinds"])
|
||||
if string(encoded) != "" && contains(string(encoded), "delete position") {
|
||||
t.Errorf("an event kind from another tenant appeared: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityBreakdownScopesTalentToTheirOwnRows(t *testing.T) {
|
||||
// I1: the agent may read exactly what the caller could read directly. The
|
||||
// policy scopes a talent caller to their own email, and this asserts the
|
||||
// scope is applied as a pre-filter — the totals and shares are computed
|
||||
// over their rows alone.
|
||||
h := testutil.New(t)
|
||||
_, talent := seeded(t, h)
|
||||
|
||||
got := data(t, runBreakdown(t, h, talent, `{}`))
|
||||
|
||||
if n := got["totalEvents"].(float64); n != 2 {
|
||||
t.Errorf("totalEvents = %v, want 2 — a talent caller must see only their own entries", n)
|
||||
}
|
||||
// The leak that post-filtering would produce: the row text is hidden but
|
||||
// the organization's volume shows through the account count.
|
||||
if n := got["activeAccounts"].(float64); n != 1 {
|
||||
t.Errorf("activeAccounts = %v, want 1 — the tenant's account count leaked through the aggregate", n)
|
||||
}
|
||||
encoded, _ := json.Marshal(got["kinds"])
|
||||
if contains(string(encoded), "hire candidate") {
|
||||
t.Errorf("a talent caller saw an event kind they did not perform: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityBreakdownDeniesWithoutATenant(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
res := runBreakdown(t, h, authctx.Identity{UserID: "u", Role: "admin", Email: "x@example.test"}, `{}`)
|
||||
|
||||
if res.Error == nil || res.Error.Code != tools.CodeDenied {
|
||||
t.Fatalf("want a denial, got %+v", res)
|
||||
}
|
||||
// §8: the denial must not reveal whether anything exists.
|
||||
if contains(res.Error.Message, "activity") || contains(res.Error.Message, "org") {
|
||||
t.Errorf("the denial described what was refused: %q", res.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityBreakdownDeniesAnUnknownRole(t *testing.T) {
|
||||
// Deny by default: a role the policy table does not list permits nothing.
|
||||
h := testutil.New(t)
|
||||
res := runBreakdown(t, h,
|
||||
authctx.Identity{UserID: "u", OrgID: h.OrgID, Role: "superuser", Email: "x@example.test"}, `{}`)
|
||||
|
||||
if res.Error == nil || res.Error.Code != tools.CodeDenied {
|
||||
t.Fatalf("an unlisted role must be denied, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityBreakdownRejectsAnUnknownPeriod(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
admin, _ := seeded(t, h)
|
||||
|
||||
res := runBreakdown(t, h, admin, `{"period":"since-tuesday"}`)
|
||||
if res.Error == nil || res.Error.Code != tools.CodeInvalidInput {
|
||||
t.Fatalf("want invalid input for an unknown period, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityBreakdownCountsKindsBeforeLimiting(t *testing.T) {
|
||||
// "How many kinds are there" must stay true even when the list is shorter,
|
||||
// and the shortening must be declared rather than silent.
|
||||
h := testutil.New(t)
|
||||
admin, _ := seeded(t, h)
|
||||
|
||||
got := data(t, runBreakdown(t, h, admin, `{"limit":1}`))
|
||||
|
||||
if n := got["distinctKinds"].(float64); n != 2 {
|
||||
t.Errorf("distinctKinds = %v, want 2 — counted before the limit", n)
|
||||
}
|
||||
if n, ok := got["omittedKinds"].(float64); !ok || n != 1 {
|
||||
t.Errorf("omittedKinds = %v, want 1 — a shortened list must say so", got["omittedKinds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityBreakdownReportsEmptyAsAnAnswer(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
empty := freshOrg(t, h, "empty-co")
|
||||
admin := authctx.Identity{UserID: "u", OrgID: empty, Role: "admin", Email: "boss@example.test"}
|
||||
|
||||
got := data(t, runBreakdown(t, h, admin, `{}`))
|
||||
if n := got["totalEvents"].(float64); n != 0 {
|
||||
t.Fatalf("totalEvents = %v, want 0 on an empty log", n)
|
||||
}
|
||||
if _, ok := got["note"]; !ok {
|
||||
t.Error("an empty result must say it is a real answer, not a failure to look")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return len(haystack) >= len(needle) && (func() bool {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
224
go-api/internal/tools/all_tools_test.go
Normal file
224
go-api/internal/tools/all_tools_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// everyTool is the shipped registry, built the same way the service builds it.
|
||||
//
|
||||
// Duplicated from runtime.DefaultTools deliberately: importing the runtime here
|
||||
// would make the tool package depend on its own caller. The list is asserted
|
||||
// against the registry's own count below, so the two cannot drift silently.
|
||||
func everyTool(db repo.Querier) []tools.Tool {
|
||||
return []tools.Tool{
|
||||
tools.ActivityBreakdown(db), tools.ActivitySignals(db),
|
||||
tools.WorkforceAttendance(db), tools.WorkforceOvertime(db),
|
||||
tools.WorkforceCoverage(db), tools.WorkforceTraining(db),
|
||||
tools.CandidatesQuality(db), tools.HiresRecent(db),
|
||||
tools.HiresPerformance(db), tools.PositionsRisk(db),
|
||||
tools.TalentPool(db), tools.WorkspaceSummary(db), tools.OperationsRisk(db),
|
||||
tools.OpenPositions(db), tools.AvailableWorkers(db), tools.AssignWorker(db),
|
||||
// Retrieval. Nil retriever here: the smoke test drives it with no
|
||||
// corpus, and "there are no documents to search" is the honest answer
|
||||
// for a deployment with no knowledge layer wired.
|
||||
tools.KnowledgeSearch(nil),
|
||||
tools.CandidatesAwaiting(db), tools.MoveApplication(db),
|
||||
}
|
||||
}
|
||||
|
||||
// smokeArgs are the arguments a tool needs before it will do anything.
|
||||
//
|
||||
// Most take none. The two that do are the ones that name a moment rather than a
|
||||
// window, and a required argument is not something to paper over with a default
|
||||
// — a lookup that silently assumed "now" would smoke-test a statement nobody
|
||||
// runs in production.
|
||||
var smokeArgs = map[string][]string{
|
||||
"available_workers": {`{"starts_at":"2030-01-01T09:00:00Z","ends_at":"2030-01-01T17:00:00Z"}`},
|
||||
// A role id and an email that do not exist. The statement still executes,
|
||||
// which is all this test checks; the call is refused on the row not being
|
||||
// found, which is the correct outcome for arguments this made up.
|
||||
"assign_worker": {`{"job_posting_id":"00000000-0000-0000-0000-0000000000ff",` +
|
||||
`"worker_email":"nobody@example.test","starts_at":"2030-01-01T09:00:00Z"}`},
|
||||
// Driven with no retriever wired, so it refuses. Exercised anyway: a tool
|
||||
// registered in the service and never called by any test is a tool whose
|
||||
// schema nobody has looked at.
|
||||
"knowledge_search": {`{"query":"lateness policy"}`},
|
||||
// An application id that does not exist. The statement still executes; the
|
||||
// call is refused on the row not being found, which is correct.
|
||||
"move_application": {`{"application_id":"00000000-0000-0000-0000-0000000000ff","stage":"interview"}`},
|
||||
}
|
||||
|
||||
// TestEveryToolRunsAgainstTheRealSchema is the smoke test that catches a
|
||||
// mistyped column or a status literal that is not in its enum.
|
||||
//
|
||||
// Both fail silently in SQL: a wrong enum member matches no rows and raises
|
||||
// nothing, so a bad guess reads as a confident zero. Only executing the
|
||||
// statement against the real schema finds it, which is why this runs every
|
||||
// tool rather than sampling.
|
||||
func TestEveryToolRunsAgainstTheRealSchema(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
admin := authctx.Identity{
|
||||
UserID: "00000000-0000-0000-0000-000000000001",
|
||||
OrgID: h.OrgID, Role: "admin", Email: "admin@example.test",
|
||||
}
|
||||
|
||||
reg := tools.NewRegistry()
|
||||
for _, tool := range everyTool(h.Pool) {
|
||||
reg.MustRegister(tool)
|
||||
}
|
||||
|
||||
// Every tool, with no arguments and with a period, so both the windowed
|
||||
// and unwindowed statements are executed.
|
||||
for _, name := range reg.Names() {
|
||||
argSets := []string{`{}`, `{"period":"last-30-days"}`, `{"period":"this-month","limit":3}`}
|
||||
if custom, ok := smokeArgs[name]; ok {
|
||||
argSets = custom
|
||||
}
|
||||
tool, _ := reg.Get(name)
|
||||
|
||||
for _, args := range argSets {
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: admin, RunID: "run_smoke"}, name, json.RawMessage(args))
|
||||
|
||||
// A write never reaches its handler here, because nothing has been
|
||||
// approved. What is being smoke-tested is its RENDERER — which runs
|
||||
// the same resolution queries the handler will, so a mistyped column
|
||||
// in either is caught. The one thing that must not happen is data.
|
||||
if tool.Effect == tools.EffectWrite {
|
||||
if res.Data != nil {
|
||||
t.Errorf("%s wrote without an approval", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// knowledge_search is wired with no retriever and no corpus here, so
|
||||
// refusing is the correct outcome. Asserted as a refusal rather than
|
||||
// skipped, because the failure worth catching is it answering.
|
||||
if name == "knowledge_search" {
|
||||
if res.Data != nil {
|
||||
t.Errorf("knowledge_search answered with no knowledge layer wired: %+v", res.Data)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if res.Error != nil {
|
||||
t.Errorf("%s with %s: %s — %s", name, args, res.Error.Code, res.Error.Message)
|
||||
continue
|
||||
}
|
||||
if res.Data == nil {
|
||||
t.Errorf("%s with %s: returned no data", name, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryToolDeniesACallerWithNoTenant(t *testing.T) {
|
||||
// I5, across the whole surface. One tool that forgot would be a
|
||||
// cross-tenant read, so this asserts the property rather than the code.
|
||||
h := testutil.New(t)
|
||||
stranger := authctx.Identity{UserID: "u", Role: "admin", Email: "x@example.test"}
|
||||
|
||||
reg := tools.NewRegistry()
|
||||
for _, tool := range everyTool(h.Pool) {
|
||||
reg.MustRegister(tool)
|
||||
}
|
||||
|
||||
for _, name := range reg.Names() {
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: stranger}, name, json.RawMessage(`{}`))
|
||||
|
||||
// A cross-domain tool reports withheld areas rather than refusing
|
||||
// outright — it has nothing it may read, which is a different answer
|
||||
// from "you may not ask". Either is acceptable; returning data is not.
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
encoded, _ := json.Marshal(res.Data)
|
||||
if !strings.Contains(string(encoded), "withheld") {
|
||||
t.Errorf("%s answered a caller with no tenant: %s", name, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryToolDeniesAnUnknownRole(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
stranger := authctx.Identity{
|
||||
UserID: "u", OrgID: h.OrgID, Role: "superuser", Email: "x@example.test",
|
||||
}
|
||||
|
||||
reg := tools.NewRegistry()
|
||||
for _, tool := range everyTool(h.Pool) {
|
||||
reg.MustRegister(tool)
|
||||
}
|
||||
|
||||
for _, name := range reg.Names() {
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: stranger}, name, json.RawMessage(`{}`))
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
encoded, _ := json.Marshal(res.Data)
|
||||
if !strings.Contains(string(encoded), "withheld") {
|
||||
t.Errorf("%s answered an unlisted role: %s", name, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryToolIsDescribedAndEveryWriteCanExplainItself(t *testing.T) {
|
||||
// This test used to assert that nothing wrote, with a note saying it must
|
||||
// be changed deliberately when the first write landed. assign_worker is
|
||||
// that write, so here is the deliberate change — and the property worth
|
||||
// asserting now is not "no writes" but "every write can say what it does".
|
||||
//
|
||||
// Register() enforces the same thing at boot. Asserted again here because
|
||||
// this list is what a reviewer reads to see the shape of the tool surface,
|
||||
// and a write appearing in it with no renderer should fail loudly next to
|
||||
// its peers rather than only inside a constructor.
|
||||
writes := 0
|
||||
for _, tool := range everyTool(nil) {
|
||||
switch tool.Effect {
|
||||
case tools.EffectRead:
|
||||
if tool.Confirm != nil {
|
||||
t.Errorf("%s is a read with a confirmation renderer that will never run", tool.Name)
|
||||
}
|
||||
case tools.EffectWrite:
|
||||
writes++
|
||||
if tool.Confirm == nil {
|
||||
t.Errorf("%s writes but cannot describe what it would do", tool.Name)
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s declares effect %q, want read or write", tool.Name, tool.Effect)
|
||||
}
|
||||
if len(tool.Description) < 60 {
|
||||
t.Errorf("%s has a %d-character description; the model reads this instead of docs",
|
||||
tool.Name, len(tool.Description))
|
||||
}
|
||||
if tool.InputSchema == nil {
|
||||
t.Errorf("%s has no input schema", tool.Name)
|
||||
}
|
||||
}
|
||||
if writes != 2 {
|
||||
t.Errorf("%d write tools; each one added is a new way for an agent to change the "+
|
||||
"world, so update this count deliberately", writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCountMatchesTheShippedRegistry(t *testing.T) {
|
||||
// Guards the duplication in everyTool: a tool registered in the service
|
||||
// but missing here would never be smoke-tested.
|
||||
reg := tools.NewRegistry()
|
||||
for _, tool := range everyTool(nil) {
|
||||
reg.MustRegister(tool)
|
||||
}
|
||||
if got := len(reg.Names()); got != 19 {
|
||||
t.Errorf("the registry holds %d tools; update this test and runtime.DefaultTools together", got)
|
||||
}
|
||||
}
|
||||
504
go-api/internal/tools/applications.go
Normal file
504
go-api/internal/tools/applications.go
Normal file
@@ -0,0 +1,504 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// Moving a candidate through the funnel: the platform's second write.
|
||||
//
|
||||
// It exists to replace a capability rather than to add one. The browser panel
|
||||
// could already mark an interview ready, through a matcher that recognised the
|
||||
// phrasing and called the app's own mutation — instant, free, and reachable
|
||||
// only by the handful of sentences somebody wrote a pattern for. Routing past
|
||||
// that machinery is only honest if nothing is lost, and this is the thing that
|
||||
// would otherwise have been lost.
|
||||
//
|
||||
// The second write is also the first test of whether the confirmation
|
||||
// mechanism GENERALISES. assign_worker could have been special-cased into the
|
||||
// gate a dozen ways without anyone noticing. This tool shares every piece of it
|
||||
// — the binding, the renderer contract, the single-use token, the replay — and
|
||||
// adds none of its own.
|
||||
|
||||
/* ── Vocabulary ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// applicationStages is the funnel, in order.
|
||||
//
|
||||
// From the `application_status` enum, and in the enum's own order, because
|
||||
// "forward" and "backward" are only meaningful against a fixed sequence. The
|
||||
// two terminal outcomes sit outside it: hiring and rejecting are decisions, not
|
||||
// positions in a queue, and treating them as "further along" would let a
|
||||
// request to advance somebody one step quietly hire them.
|
||||
var applicationStages = []string{"applied", "ai_screened", "shortlisted", "interview"}
|
||||
|
||||
// terminalStages are the outcomes a candidate can be moved to from anywhere.
|
||||
var terminalStages = []string{"hired", "rejected"}
|
||||
|
||||
// settledStages are the outcomes that take somebody out of the running.
|
||||
//
|
||||
// `assigned` appears here and nowhere else in this file's vocabulary: it is not
|
||||
// a stage this tool may *set* (see knownStage), but a candidate already placed
|
||||
// on a shift is not waiting on a decision either. Leaving it out of this list is
|
||||
// how a settled candidate gets chased twice.
|
||||
var settledStages = []string{"hired", "rejected", "assigned"}
|
||||
|
||||
// quotedList renders a package-level vocabulary as a SQL literal list. Never
|
||||
// reachable from caller input — every caller passes one of the vars above.
|
||||
func quotedList(vs []string) string {
|
||||
out := make([]string, len(vs))
|
||||
for i, v := range vs {
|
||||
out[i] = "'" + v + "'"
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// listableStage reports whether a stage can be asked for by name. Wider than
|
||||
// knownStage: every status in the enum can be read, but `assigned` cannot be set.
|
||||
func listableStage(s string) bool {
|
||||
return knownStage(s) || s == "assigned"
|
||||
}
|
||||
|
||||
// stageLabels are how a person reads a stage. The enum values are for the
|
||||
// database; a confirmation dialog saying `ai_screened` is a dialog written for
|
||||
// the schema rather than for the person approving it.
|
||||
var stageLabels = map[string]string{
|
||||
"applied": "Applied",
|
||||
"ai_screened": "Screened",
|
||||
"shortlisted": "Shortlisted",
|
||||
"interview": "Interview",
|
||||
"hired": "Hired",
|
||||
"rejected": "Rejected",
|
||||
"assigned": "Assigned",
|
||||
}
|
||||
|
||||
func stageLabel(s string) string {
|
||||
if l, ok := stageLabels[s]; ok {
|
||||
return l
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// knownStage reports whether a stage is one this tool may set.
|
||||
//
|
||||
// `assigned` is deliberately absent: an application becomes assigned because
|
||||
// somebody was put on a shift, which is assign_worker's business. Letting this
|
||||
// tool set it would create a second path to the same state that writes no
|
||||
// assignment row — a candidate marked assigned to nothing.
|
||||
func knownStage(s string) bool {
|
||||
for _, v := range applicationStages {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, v := range terminalStages {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/* ── The tool ───────────────────────────────────────────────────────────── */
|
||||
|
||||
type moveApplicationInput struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
Stage string `json:"stage"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// MoveApplication advances or rejects a candidate.
|
||||
func MoveApplication(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "move_application",
|
||||
Description: "Move a candidate to a different stage of the hiring funnel — screened, " +
|
||||
"shortlisted, interview, hired or rejected. This changes a real record and the " +
|
||||
"candidate's status in the product. Requires an application id from " +
|
||||
"candidates_quality or hires_recent; never invent one. A person must approve " +
|
||||
"before this takes effect.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"application_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The application's id, exactly as a lookup returned it.",
|
||||
},
|
||||
"stage": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"ai_screened", "shortlisted", "interview", "hired", "rejected"},
|
||||
"description": "Where to move them. Use 'interview' to mark someone ready to " +
|
||||
"interview. 'hired' and 'rejected' are final outcomes.",
|
||||
},
|
||||
"note": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional one-line reason, shown to the person approving. " +
|
||||
"Say why this candidate and not the alternatives.",
|
||||
},
|
||||
},
|
||||
"required": []string{"application_id", "stage"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectWrite,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
|
||||
Confirm: func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result) {
|
||||
plan, denied := planMove(ctx, db, tc, inputs)
|
||||
if denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
|
||||
details := []Detail{
|
||||
{Label: "Candidate", Value: plan.name},
|
||||
{Label: "Role", Value: plan.roleTitle},
|
||||
{Label: "Moving", Value: fmt.Sprintf("%s → %s",
|
||||
stageLabel(plan.currentStage), stageLabel(plan.stage))},
|
||||
}
|
||||
if plan.score > 0 {
|
||||
details = append(details, Detail{
|
||||
Label: "Match score", Value: fmt.Sprintf("%d", plan.score)})
|
||||
}
|
||||
if plan.note != "" {
|
||||
details = append(details, Detail{Label: "Reason", Value: plan.note})
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
// A terminal stage is the one a person most needs to be stopped on:
|
||||
// it is the hardest to walk back, and the model reaching it early is
|
||||
// the most expensive mistake available here.
|
||||
switch plan.stage {
|
||||
case "hired":
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"Hiring is a final outcome. %s will count as hired for this role.", plan.name))
|
||||
case "rejected":
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"Rejecting is a final outcome. %s will be out of the running for this role.", plan.name))
|
||||
}
|
||||
// Skipping stages is legitimate — a strong candidate can go straight
|
||||
// to interview — but it is worth pointing out, because a model
|
||||
// misreading which stage somebody is at produces exactly this shape.
|
||||
if plan.skipped > 1 {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"This skips %d stage(s): %s is currently at %s.",
|
||||
plan.skipped-1, plan.name, stageLabel(plan.currentStage)))
|
||||
}
|
||||
if plan.currentStage == plan.stage {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"%s is already at %s. Approving this changes nothing.",
|
||||
plan.name, stageLabel(plan.stage)))
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("%s moves from %s to %s for %s.",
|
||||
plan.name, stageLabel(plan.currentStage), stageLabel(plan.stage), plan.roleTitle)
|
||||
if plan.stage == "interview" {
|
||||
summary = fmt.Sprintf(
|
||||
"%s will be marked ready to interview for %s, and will appear in the "+
|
||||
"interview queue.", plan.name, plan.roleTitle)
|
||||
}
|
||||
|
||||
return &Confirmation{
|
||||
Title: fmt.Sprintf("Move %s to %s", plan.name, stageLabel(plan.stage)),
|
||||
Summary: summary,
|
||||
Details: details,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
},
|
||||
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
plan, denied := planMove(ctx, db, tc, inputs)
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
|
||||
// Re-read and re-checked, like assign_worker: the approval was given
|
||||
// against a picture some minutes old, and somebody else may have
|
||||
// moved this candidate in between. Moving them again from a stage
|
||||
// the approver never saw is not what they agreed to.
|
||||
if plan.currentStage == plan.stage {
|
||||
return OK(map[string]any{
|
||||
"applicationId": plan.id,
|
||||
"candidate": plan.name,
|
||||
"stage": plan.stage,
|
||||
"changed": false,
|
||||
"note": fmt.Sprintf("%s was already at %s; nothing was changed.",
|
||||
plan.name, stageLabel(plan.stage)),
|
||||
})
|
||||
}
|
||||
|
||||
// screened_at is deliberately not written. Migration 000003 dropped
|
||||
// job_applications_screened_consistent because nothing in the product
|
||||
// ever sets that column; writing it here would make this tool its only
|
||||
// writer, so the column would come to mean "an agent touched this row"
|
||||
// rather than what its name says. status is the screening record.
|
||||
var updated string
|
||||
err := db.QueryRow(ctx, `
|
||||
UPDATE job_applications
|
||||
SET status = $3::application_status,
|
||||
updated_date = now()
|
||||
WHERE id = $1::uuid AND org_id = $2::uuid
|
||||
RETURNING status::text`,
|
||||
plan.id, tc.OrgID(), plan.stage,
|
||||
).Scan(&updated)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the candidate could not be moved")
|
||||
}
|
||||
|
||||
return OK(map[string]any{
|
||||
"applicationId": plan.id,
|
||||
"candidate": plan.name,
|
||||
"role": plan.roleTitle,
|
||||
"from": plan.currentStage,
|
||||
"stage": updated,
|
||||
"changed": true,
|
||||
"confirmed": true,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Resolution ─────────────────────────────────────────────────────────── */
|
||||
|
||||
type movePlan struct {
|
||||
id string
|
||||
name string
|
||||
email string
|
||||
roleTitle string
|
||||
currentStage string
|
||||
stage string
|
||||
note string
|
||||
score int
|
||||
|
||||
// skipped is how many stages forward this moves. 1 is the next one along;
|
||||
// more than that jumps the queue, which is allowed and worth saying.
|
||||
skipped int
|
||||
}
|
||||
|
||||
// planMove authorizes, validates and resolves a move_application call.
|
||||
//
|
||||
// Shared by the renderer and the handler so the thing described and the thing
|
||||
// done are resolved by identical code — the same reason assign_worker has
|
||||
// planAssignment. Two resolutions would drift, and the drift lands exactly
|
||||
// between what a person approved and what happened.
|
||||
func planMove(ctx context.Context, db repo.Querier, tc Context, inputs json.RawMessage) (*movePlan, *Result) {
|
||||
// Update, not List. `job-applications` lists to everyone — a talent caller
|
||||
// may read their own — and updates for operators only. Asking the read
|
||||
// question here would let a candidate advance themselves.
|
||||
if _, denied := authorizeOp(tc, "job-applications", domain.OpUpdate, ""); denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
|
||||
var in moveApplicationInput
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
bad := Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
return nil, &bad
|
||||
}
|
||||
id := strings.TrimSpace(in.ApplicationID)
|
||||
stage := strings.TrimSpace(strings.ToLower(in.Stage))
|
||||
if id == "" {
|
||||
bad := Failf(CodeInvalidInput, "an application id is required")
|
||||
return nil, &bad
|
||||
}
|
||||
if !knownStage(stage) {
|
||||
bad := Failf(CodeInvalidInput,
|
||||
"%q is not a stage; use ai_screened, shortlisted, interview, hired or rejected", in.Stage)
|
||||
return nil, &bad
|
||||
}
|
||||
if stage == "applied" {
|
||||
// Moving somebody back to the start is not a funnel action, it is an
|
||||
// undo — and an undo that erases the record of having been screened.
|
||||
bad := Failf(CodeInvalidInput, "a candidate cannot be moved back to applied")
|
||||
return nil, &bad
|
||||
}
|
||||
|
||||
plan := &movePlan{id: id, stage: stage, note: strings.TrimSpace(in.Note)}
|
||||
|
||||
// Behind the caller's own read predicate. Referencing an application this
|
||||
// caller could not have read would confirm it exists.
|
||||
q, denied := authorizeAs(tc, "job-applications", "a")
|
||||
if denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
q.eq("id::text", id)
|
||||
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT a.id::text, a.applicant_name, a.email::text, a.status::text, a.ai_score,
|
||||
coalesce(nullif(p.title, ''), 'an unnamed role')
|
||||
FROM job_applications a
|
||||
JOIN job_postings p ON p.id = a.job_posting_id
|
||||
WHERE `+q.clause(), q.args...,
|
||||
).Scan(&plan.id, &plan.name, &plan.email, &plan.currentStage, &plan.score, &plan.roleTitle); err != nil {
|
||||
denied := Denied()
|
||||
return nil, &denied
|
||||
}
|
||||
if strings.TrimSpace(plan.name) == "" {
|
||||
plan.name = plan.email
|
||||
}
|
||||
|
||||
plan.skipped = stagesBetween(plan.currentStage, plan.stage)
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// stagesBetween counts how far forward a move goes.
|
||||
//
|
||||
// Zero for a terminal outcome or a move that is not forward along the funnel —
|
||||
// there is no meaningful "distance" to rejecting somebody, and reporting one
|
||||
// would produce a warning about skipping stages on a decision that skips
|
||||
// nothing.
|
||||
func stagesBetween(from, to string) int {
|
||||
index := func(s string) int {
|
||||
for i, v := range applicationStages {
|
||||
if v == s {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
f, t := index(from), index(to)
|
||||
if f < 0 || t < 0 || t <= f {
|
||||
return 0
|
||||
}
|
||||
return t - f
|
||||
}
|
||||
|
||||
/* ── Lookup ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
type candidatesInput struct {
|
||||
Stage string `json:"stage"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// CandidatesAwaiting lists candidates at a stage, with the ids to move them.
|
||||
//
|
||||
// §4: a tool whose schema demands an id the model was never given is a design
|
||||
// bug, and the fix is a lookup rather than a friendlier error message. Every
|
||||
// analytics tool in this package returns aggregates on purpose — an id in a
|
||||
// count is noise — so move_application would be unusable without this.
|
||||
//
|
||||
// Ordered by match score. A person asking "who is waiting" almost always means
|
||||
// "who should I look at first", and returning them in insertion order makes the
|
||||
// model do ranking it has no basis for.
|
||||
func CandidatesAwaiting(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "candidates_awaiting",
|
||||
Description: "List candidates currently at a given stage of the funnel — strongest " +
|
||||
"match first — with the application id needed to move them. Use this before " +
|
||||
"moving anyone: it is the only way to learn an application's id, and an id must " +
|
||||
"never be guessed.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"stage": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"applied", "ai_screened", "shortlisted", "interview",
|
||||
"hired", "rejected", "assigned"},
|
||||
"description": "Which stage to list. Omit for everyone still in the running " +
|
||||
"(that is, not hired, rejected or already assigned to a shift).",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer", "minimum": 1, "maximum": 50,
|
||||
"description": "How many to list. Defaults to 15.",
|
||||
},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorizeAs(tc, "job-applications", "a")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
|
||||
var in candidatesInput
|
||||
if len(inputs) > 0 {
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
}
|
||||
}
|
||||
stage := strings.TrimSpace(strings.ToLower(in.Stage))
|
||||
if stage != "" && !listableStage(stage) {
|
||||
return Failf(CodeInvalidInput, "%q is not a stage", in.Stage)
|
||||
}
|
||||
|
||||
if stage != "" {
|
||||
q.eq("status::text", stage)
|
||||
} else {
|
||||
// Still in the running. Built from this tool's own vocabulary
|
||||
// rather than a hand-written list, so a settled outcome added
|
||||
// there cannot be left behind here.
|
||||
q.raw("a.status NOT IN (" + quotedList(settledStages) + ")")
|
||||
}
|
||||
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = 15
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
args := append(append([]any{}, q.args...), limit)
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT a.id::text, a.applicant_name, a.email::text, a.status::text, a.ai_score,
|
||||
coalesce(nullif(p.title, ''), 'an unnamed role'),
|
||||
(a.status <> 'applied')
|
||||
FROM job_applications a
|
||||
JOIN job_postings p ON p.id = a.job_posting_id
|
||||
WHERE `+q.clause()+`
|
||||
ORDER BY a.ai_score DESC, a.created_date ASC
|
||||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the candidates could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
candidates := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, name, email, status, role string
|
||||
score int
|
||||
screened bool
|
||||
)
|
||||
if err := rows.Scan(&id, &name, &email, &status, &score, &role, &screened); err != nil {
|
||||
return Failf(CodeFailed, "the candidates could not be read")
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = email
|
||||
}
|
||||
row := map[string]any{
|
||||
"applicationId": id,
|
||||
"name": name,
|
||||
"role": role,
|
||||
"stage": status,
|
||||
"stageLabel": stageLabel(status),
|
||||
"screened": screened,
|
||||
}
|
||||
// Absent rather than 0: a candidate nobody scored has no score,
|
||||
// and reporting 0 invites the model to rank them as the worst.
|
||||
if score > 0 {
|
||||
row["matchScore"] = score
|
||||
}
|
||||
candidates = append(candidates, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the candidates could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"candidates": candidates,
|
||||
"count": len(candidates),
|
||||
"stage": stage,
|
||||
}
|
||||
if stage == "" {
|
||||
data["stage"] = "still in the running"
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
data["note"] = "Nobody is at that stage. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
474
go-api/internal/tools/applications_test.go
Normal file
474
go-api/internal/tools/applications_test.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// The second write tool.
|
||||
//
|
||||
// Half of what is asserted here is the same as assign_worker's, and that is the
|
||||
// point rather than duplication: the confirmation gate was built once, and a
|
||||
// second tool that has to re-earn "cannot write without approval" would mean it
|
||||
// had been special-cased into the first. It is not — this tool declares an
|
||||
// effect and a renderer, and everything else comes from the registry.
|
||||
//
|
||||
// The other half is this tool's own: a funnel has an order, terminal outcomes
|
||||
// are different from steps along it, and moving somebody backwards is not a
|
||||
// move at all.
|
||||
|
||||
type funnelFixture struct {
|
||||
orgID string
|
||||
admin authctx.Identity
|
||||
talent authctx.Identity
|
||||
appID string
|
||||
name string
|
||||
}
|
||||
|
||||
func seedFunnel(t *testing.T, h *testutil.Harness, slug string) funnelFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
org := freshOrg(t, h, slug)
|
||||
|
||||
f := funnelFixture{orgID: org, name: "Dana Okonkwo"}
|
||||
f.admin = authctx.Identity{
|
||||
UserID: seedUser(t, h, org, fmt.Sprintf("boss-%s@example.test", slug), "admin"),
|
||||
OrgID: org, Role: "admin", Email: fmt.Sprintf("boss-%s@example.test", slug),
|
||||
}
|
||||
candidateEmail := fmt.Sprintf("dana-%s@example.test", slug)
|
||||
f.talent = authctx.Identity{
|
||||
UserID: seedUser(t, h, org, candidateEmail, "talent"),
|
||||
OrgID: org, Role: "talent", Email: candidateEmail,
|
||||
}
|
||||
|
||||
var postingID string
|
||||
if err := h.Pool.QueryRow(ctx, `
|
||||
INSERT INTO job_postings (org_id, title, status, headcount)
|
||||
VALUES ($1::uuid, 'Sous Chef', 'active', 1) RETURNING id::text`, org).Scan(&postingID); err != nil {
|
||||
t.Fatalf("seed posting: %v", err)
|
||||
}
|
||||
if err := h.Pool.QueryRow(ctx, `
|
||||
INSERT INTO job_applications (org_id, job_posting_id, applicant_name, email, status, ai_score)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, 'ai_screened', 88) RETURNING id::text`,
|
||||
org, postingID, f.name, candidateEmail).Scan(&f.appID); err != nil {
|
||||
t.Fatalf("seed application: %v", err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func stageOf(t *testing.T, h *testutil.Harness, appID string) string {
|
||||
t.Helper()
|
||||
var s string
|
||||
if err := h.Pool.QueryRow(context.Background(),
|
||||
`SELECT status::text FROM job_applications WHERE id = $1::uuid`, appID).Scan(&s); err != nil {
|
||||
t.Fatalf("read stage: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func moveArgs(appID, stage string) string {
|
||||
return fmt.Sprintf(`{"application_id":%q,"stage":%q}`, appID, stage)
|
||||
}
|
||||
|
||||
/* ── The gate, again ────────────────────────────────────────────────────── */
|
||||
|
||||
func TestMovingACandidateNeedsApproval(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-gate")
|
||||
reg := liveRegistry(t, h)
|
||||
tc := tools.Context{Principal: f.admin, RunID: "run_m1"}
|
||||
args := moveArgs(f.appID, "interview")
|
||||
|
||||
res := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args))
|
||||
if res.Confirmation == nil {
|
||||
t.Fatalf("expected a confirmation, got %+v", res)
|
||||
}
|
||||
if got := stageOf(t, h, f.appID); got != "ai_screened" {
|
||||
t.Fatalf("the candidate moved to %q before anyone approved anything", got)
|
||||
}
|
||||
|
||||
// Names and readable stages, not enum values and uuids.
|
||||
body, _ := json.Marshal(res.Confirmation)
|
||||
for _, want := range []string{"Dana Okonkwo", "Sous Chef", "Interview"} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("the confirmation does not mention %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(res.Confirmation.Title, "ai_screened") {
|
||||
t.Error("the confirmation shows an enum value where it should show a label")
|
||||
}
|
||||
|
||||
tc.Confirmation = res.Confirmation.Token
|
||||
if out := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args)); out.Error != nil {
|
||||
t.Fatalf("an approved move should run: %+v", out.Error)
|
||||
}
|
||||
if got := stageOf(t, h, f.appID); got != "interview" {
|
||||
t.Fatalf("stage is %q after approval, want interview", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACandidateCannotMoveThemselves(t *testing.T) {
|
||||
// `job-applications` lists to everyone — a talent caller may read their own
|
||||
// — and updates for operators only. Asking the policy the READ question
|
||||
// here would let a candidate advance themselves to interview.
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-self")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: f.talent, RunID: "run_m2"},
|
||||
"move_application", json.RawMessage(moveArgs(f.appID, "interview")))
|
||||
|
||||
if res.Confirmation != nil {
|
||||
t.Fatal("a candidate was asked to approve their own advancement")
|
||||
}
|
||||
if res.Error == nil || res.Error.Code != tools.CodeDenied {
|
||||
t.Fatalf("want the standard denial, got %+v", res.Error)
|
||||
}
|
||||
if got := stageOf(t, h, f.appID); got != "ai_screened" {
|
||||
t.Fatalf("a candidate moved themselves to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherTenantsCandidateIsAbsentRatherThanForbidden(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
mine := seedFunnel(t, h, "funnel-mine")
|
||||
theirs := seedFunnel(t, h, "funnel-theirs")
|
||||
reg := liveRegistry(t, h)
|
||||
tc := tools.Context{Principal: mine.admin, RunID: "run_m3"}
|
||||
|
||||
real := reg.Dispatch(context.Background(), tc, "move_application",
|
||||
json.RawMessage(moveArgs(theirs.appID, "interview")))
|
||||
fake := reg.Dispatch(context.Background(), tc, "move_application",
|
||||
json.RawMessage(moveArgs("00000000-0000-0000-0000-0000000000ff", "interview")))
|
||||
|
||||
if real.Error == nil || fake.Error == nil {
|
||||
t.Fatal("a cross-tenant or invented application id was accepted")
|
||||
}
|
||||
if real.Error.Code != fake.Error.Code || real.Error.Message != fake.Error.Message {
|
||||
t.Errorf("a real-but-forbidden candidate is distinguishable from an imaginary one:\n"+
|
||||
" other tenant: %s\n invented: %s", real.Error.Message, fake.Error.Message)
|
||||
}
|
||||
if got := stageOf(t, h, theirs.appID); got != "ai_screened" {
|
||||
t.Fatal("a write crossed a tenant boundary")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The funnel's own rules ─────────────────────────────────────────────── */
|
||||
|
||||
func TestATerminalOutcomeIsWarnedAbout(t *testing.T) {
|
||||
// Hiring and rejecting are the hardest decisions to walk back, and a model
|
||||
// reaching them early is the most expensive mistake available here. They
|
||||
// get a warning above the button, not a footnote.
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-terminal")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
for stage, word := range map[string]string{"hired": "final outcome", "rejected": "final outcome"} {
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: f.admin, RunID: "run_m4"},
|
||||
"move_application", json.RawMessage(moveArgs(f.appID, stage)))
|
||||
if res.Confirmation == nil {
|
||||
t.Fatalf("%s: expected a confirmation, got %+v", stage, res)
|
||||
}
|
||||
var found bool
|
||||
for _, w := range res.Confirmation.Warnings {
|
||||
if strings.Contains(w, word) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("moving to %s was described without warning it is final: %v",
|
||||
stage, res.Confirmation.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkippingStagesIsWarnedAbout(t *testing.T) {
|
||||
// Legitimate — a strong candidate can go straight to interview — but a
|
||||
// model misreading which stage somebody is at produces exactly this shape,
|
||||
// so the person approving should be told.
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-skip")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
// ai_screened → interview skips shortlisted.
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: f.admin, RunID: "run_m5"},
|
||||
"move_application", json.RawMessage(moveArgs(f.appID, "interview")))
|
||||
if res.Confirmation == nil {
|
||||
t.Fatalf("expected a confirmation, got %+v", res)
|
||||
}
|
||||
var found bool
|
||||
for _, w := range res.Confirmation.Warnings {
|
||||
if strings.Contains(w, "skips") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("skipping a stage was not mentioned: %v", res.Confirmation.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACandidateCannotBeMovedBackToApplied(t *testing.T) {
|
||||
// Not a funnel action but an undo — and one that would erase the record of
|
||||
// having been screened. Refused as invalid input rather than described.
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-back")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: f.admin, RunID: "run_m6"},
|
||||
"move_application", json.RawMessage(moveArgs(f.appID, "applied")))
|
||||
|
||||
if res.Confirmation != nil {
|
||||
t.Fatal("moving a candidate backwards was offered for approval")
|
||||
}
|
||||
if res.Error == nil || res.Error.Code != tools.CodeInvalidInput {
|
||||
t.Fatalf("want invalid input, got %+v", res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingSomebodyToWhereTheyAlreadyAreChangesNothing(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-noop")
|
||||
reg := liveRegistry(t, h)
|
||||
tc := tools.Context{Principal: f.admin, RunID: "run_m7"}
|
||||
args := moveArgs(f.appID, "ai_screened")
|
||||
|
||||
res := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args))
|
||||
if res.Confirmation == nil {
|
||||
t.Fatalf("expected a confirmation, got %+v", res)
|
||||
}
|
||||
// Said out loud rather than silently doing nothing.
|
||||
var warned bool
|
||||
for _, w := range res.Confirmation.Warnings {
|
||||
if strings.Contains(w, "changes nothing") {
|
||||
warned = true
|
||||
}
|
||||
}
|
||||
if !warned {
|
||||
t.Errorf("a no-op move was not flagged as one: %v", res.Confirmation.Warnings)
|
||||
}
|
||||
|
||||
tc.Confirmation = res.Confirmation.Token
|
||||
out := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args))
|
||||
if out.Error != nil {
|
||||
t.Fatalf("a no-op move should succeed: %+v", out.Error)
|
||||
}
|
||||
body, _ := json.Marshal(out.Data)
|
||||
if !strings.Contains(string(body), `"changed":false`) {
|
||||
t.Errorf("a no-op did not report itself as one: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The lookup ─────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestCandidatesAwaitingReturnsIdsAndReadableStages(t *testing.T) {
|
||||
// §4: a tool that requires the model to guess an id is a design bug. This
|
||||
// is the lookup that makes move_application usable without guessing.
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-lookup")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: f.admin, RunID: "run_m8"},
|
||||
"candidates_awaiting", json.RawMessage(`{}`))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("candidates_awaiting failed: %+v", res.Error)
|
||||
}
|
||||
body, _ := json.Marshal(res.Data)
|
||||
for _, want := range []string{f.appID, "Dana Okonkwo", "Screened", "matchScore"} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("the lookup does not carry %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesAwaitingExcludesSettledCandidates(t *testing.T) {
|
||||
// "Still in the running" is the useful default: a person asking who is
|
||||
// waiting does not mean the people already hired or rejected.
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-settled")
|
||||
reg := liveRegistry(t, h)
|
||||
tc := tools.Context{Principal: f.admin, RunID: "run_m9"}
|
||||
|
||||
if _, err := h.Pool.Exec(context.Background(),
|
||||
`UPDATE job_applications SET status = 'rejected' WHERE id = $1::uuid`, f.appID); err != nil {
|
||||
t.Fatalf("settle: %v", err)
|
||||
}
|
||||
|
||||
res := reg.Dispatch(context.Background(), tc, "candidates_awaiting", json.RawMessage(`{}`))
|
||||
if body, _ := json.Marshal(res.Data); strings.Contains(string(body), f.appID) {
|
||||
t.Errorf("a rejected candidate was listed as still in the running: %s", body)
|
||||
}
|
||||
|
||||
// But asked for explicitly, they are there.
|
||||
res = reg.Dispatch(context.Background(), tc, "candidates_awaiting", json.RawMessage(`{"stage":"rejected"}`))
|
||||
if body, _ := json.Marshal(res.Data); !strings.Contains(string(body), f.appID) {
|
||||
t.Errorf("asking for rejected candidates did not return one: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenedIsDerivedFromStageNotFromAVestigialColumn(t *testing.T) {
|
||||
// A live run reported five candidates sitting at the Screened stage all
|
||||
// carrying screened:false, and read it as the stage label running ahead of
|
||||
// the work. The data was fine; the tool was wrong. `screened` was read from
|
||||
// job_applications.screened_at, a column migration 000003 established that
|
||||
// nothing in the product ever writes — so it was null for every real row and
|
||||
// the flag was false for everyone, forever. The product's own definition,
|
||||
// in eight places, is status = 'applied'. This asserts that definition.
|
||||
//
|
||||
// The old test fixture hid this by inserting screened_at itself, which no
|
||||
// production path does; the seed below deliberately does not.
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-screened")
|
||||
reg := liveRegistry(t, h)
|
||||
ctx := context.Background()
|
||||
|
||||
var freshID string
|
||||
if err := h.Pool.QueryRow(ctx, `
|
||||
INSERT INTO job_applications (org_id, job_posting_id, applicant_name, email, status, ai_score)
|
||||
SELECT org_id, job_posting_id, 'Ivo Brandt', 'ivo-screened@example.test', 'applied', 0
|
||||
FROM job_applications WHERE id = $1::uuid
|
||||
RETURNING id::text`, f.appID).Scan(&freshID); err != nil {
|
||||
t.Fatalf("seed applied candidate: %v", err)
|
||||
}
|
||||
|
||||
res := reg.Dispatch(ctx,
|
||||
tools.Context{Principal: f.admin, RunID: "run_m10"},
|
||||
"candidates_awaiting", json.RawMessage(`{}`))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("candidates_awaiting failed: %+v", res.Error)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Candidates []struct {
|
||||
ID string `json:"applicationId"`
|
||||
Screened bool `json:"screened"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
body, _ := json.Marshal(res.Data)
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode %s: %v", body, err)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, c := range payload.Candidates {
|
||||
seen[c.ID] = c.Screened
|
||||
}
|
||||
if got, ok := seen[f.appID]; !ok {
|
||||
t.Fatalf("the ai_screened candidate was not returned: %s", body)
|
||||
} else if !got {
|
||||
t.Errorf("a candidate at the Screened stage reported screened:false — " +
|
||||
"the flag is being read from something other than the stage")
|
||||
}
|
||||
if got, ok := seen[freshID]; !ok {
|
||||
t.Fatalf("the applied candidate was not returned: %s", body)
|
||||
} else if got {
|
||||
t.Errorf("a candidate still at Applied reported screened:true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingACandidateDoesNotWriteTheVestigialColumn(t *testing.T) {
|
||||
// Keeping this tool as the sole writer of screened_at would quietly redefine
|
||||
// the column to mean "an agent touched this row". status carries the stage
|
||||
// and the trajectory carries the who and when.
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-vestigial")
|
||||
reg := liveRegistry(t, h)
|
||||
ctx := context.Background()
|
||||
tc := tools.Context{Principal: f.admin, RunID: "run_m11"}
|
||||
args := moveArgs(f.appID, "interview")
|
||||
|
||||
res := reg.Dispatch(ctx, tc, "move_application", json.RawMessage(args))
|
||||
if res.Confirmation == nil {
|
||||
t.Fatalf("expected a confirmation, got %+v", res)
|
||||
}
|
||||
out, ok := reg.DispatchApproved(ctx, tc, res.Confirmation.Token)
|
||||
if !ok {
|
||||
t.Fatal("a valid token could not be redeemed")
|
||||
}
|
||||
if out.Result.Error != nil {
|
||||
t.Fatalf("approved move failed: %+v", out.Result.Error)
|
||||
}
|
||||
if got := stageOf(t, h, f.appID); got != "interview" {
|
||||
t.Fatalf("the approved move did not land: stage is %q", got)
|
||||
}
|
||||
|
||||
var written bool
|
||||
if err := h.Pool.QueryRow(ctx,
|
||||
`SELECT screened_at IS NOT NULL FROM job_applications WHERE id = $1::uuid`,
|
||||
f.appID).Scan(&written); err != nil {
|
||||
t.Fatalf("read screened_at: %v", err)
|
||||
}
|
||||
if written {
|
||||
t.Errorf("move_application wrote screened_at, making it the column's only writer")
|
||||
}
|
||||
}
|
||||
|
||||
// A candidate placed on a shift is not waiting on a decision. `assigned` is in
|
||||
// the application_status enum but was in neither of this file's stage lists, so
|
||||
// the "still in the running" predicate — written as NOT IN ('hired','rejected')
|
||||
// — let them through, and candidates_awaiting listed somebody already working
|
||||
// as somebody to chase. The same omission on the frontend dropped `assigned`
|
||||
// out of every funnel bucket; here it fell into the wrong one instead.
|
||||
func TestAnAssignedCandidateIsNotStillInTheRunning(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-assigned")
|
||||
reg := liveRegistry(t, h)
|
||||
ctx := context.Background()
|
||||
tc := tools.Context{Principal: f.admin, RunID: "run_m12"}
|
||||
|
||||
if _, err := h.Pool.Exec(ctx,
|
||||
`UPDATE job_applications SET status = 'assigned' WHERE id = $1::uuid`, f.appID); err != nil {
|
||||
t.Fatalf("assign: %v", err)
|
||||
}
|
||||
|
||||
res := reg.Dispatch(ctx, tc, "candidates_awaiting", json.RawMessage(`{}`))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("candidates_awaiting failed: %+v", res.Error)
|
||||
}
|
||||
if body, _ := json.Marshal(res.Data); strings.Contains(string(body), f.appID) {
|
||||
t.Errorf("a candidate already assigned to a shift was listed as awaiting a decision: %s", body)
|
||||
}
|
||||
|
||||
// Asked for by name they are there — the read tool can show every status,
|
||||
// even the one move_application is not allowed to set.
|
||||
res = reg.Dispatch(ctx, tc, "candidates_awaiting", json.RawMessage(`{"stage":"assigned"}`))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("listing assigned candidates failed: %+v", res.Error)
|
||||
}
|
||||
body, _ := json.Marshal(res.Data)
|
||||
if !strings.Contains(string(body), f.appID) {
|
||||
t.Errorf("asking for assigned candidates did not return one: %s", body)
|
||||
}
|
||||
if !strings.Contains(string(body), "Assigned") {
|
||||
t.Errorf("the stage label is not readable: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// move_application must still refuse to *set* assigned: that state means an
|
||||
// assignment row exists, and this tool writes none.
|
||||
func TestMoveApplicationStillRefusesToSetAssigned(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "funnel-noassign")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: f.admin, RunID: "run_m13"},
|
||||
"move_application", json.RawMessage(moveArgs(f.appID, "assigned")))
|
||||
if res.Error == nil {
|
||||
t.Fatalf("move_application accepted 'assigned'; it would mark a candidate assigned to nothing: %+v", res)
|
||||
}
|
||||
if res.Confirmation != nil {
|
||||
t.Errorf("it even offered a confirmation for it")
|
||||
}
|
||||
}
|
||||
595
go-api/internal/tools/assignments.go
Normal file
595
go-api/internal/tools/assignments.go
Normal file
@@ -0,0 +1,595 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// Assignments: the first tools that change something, and the two lookups that
|
||||
// make changing something possible.
|
||||
//
|
||||
// §4 has a line that dictates the shape of this file: "a tool description that
|
||||
// requires the model to guess an ID it has not been given is a design bug. Add
|
||||
// a lookup tool instead." Every read tool built so far returns aggregates —
|
||||
// counts, averages, the weakest five — and none of them return a row id,
|
||||
// deliberately: an id in an analytics answer is noise. But an assignment names
|
||||
// a posting and a person, so the write is unusable until the model has a
|
||||
// legitimate way to learn those two things.
|
||||
//
|
||||
// Hence three tools, in the order an agent actually uses them:
|
||||
//
|
||||
// open_positions → which roles need people, with their ids
|
||||
// available_workers → who is free in that window, with their emails
|
||||
// assign_worker → put one on the other, once a person has said yes
|
||||
//
|
||||
// The alternative — a single write that accepts a worker's name and resolves it
|
||||
// itself — reads as friendlier and is considerably worse. Two people called
|
||||
// Chen makes it ambiguous, and the disambiguation would happen inside a write,
|
||||
// after approval, with no one watching.
|
||||
|
||||
/* ── Lookup: open positions ─────────────────────────────────────────────── */
|
||||
|
||||
type openPositionsInput struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// OpenPositions lists roles that still need people, with the ids to fill them.
|
||||
func OpenPositions(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "open_positions",
|
||||
Description: "List the roles that are currently open, with how many people each " +
|
||||
"needs, how many are already assigned, and how many are still to fill. " +
|
||||
"Returns an id for each role. Use this before assigning anyone: it is the " +
|
||||
"only way to learn a role's id, and an id must never be guessed.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"limit": map[string]any{
|
||||
"type": "integer", "minimum": 1, "maximum": 100,
|
||||
"description": "How many roles to list, most urgent first. Defaults to 20.",
|
||||
},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorizeAs(tc, "job-postings", "p")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
var in openPositionsInput
|
||||
if len(inputs) > 0 {
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
}
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Only roles that can actually be staffed. A draft has not been
|
||||
// agreed, a paused role has been stopped on purpose, and a closed
|
||||
// one is history — offering any of them as assignable would invite
|
||||
// the agent to staff a role nobody is hiring for.
|
||||
q.raw("p.status = 'active'")
|
||||
|
||||
args := append(append([]any{}, q.args...), limit)
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT p.id::text, p.title, p.role_category, p.location,
|
||||
p.headcount, p.priority::text, p.start_date,
|
||||
(SELECT count(*) FROM assignments a
|
||||
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id
|
||||
AND a.status = 'active')
|
||||
FROM job_postings p
|
||||
WHERE `+q.clause()+`
|
||||
ORDER BY p.priority DESC, p.created_date ASC
|
||||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the open roles could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
positions := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, title, category, location, priority string
|
||||
headcount int
|
||||
startDate *time.Time
|
||||
assigned int
|
||||
)
|
||||
if err := rows.Scan(&id, &title, &category, &location,
|
||||
&headcount, &priority, &startDate, &assigned); err != nil {
|
||||
return Failf(CodeFailed, "the open roles could not be read")
|
||||
}
|
||||
stillToFill := headcount - assigned
|
||||
if stillToFill < 0 {
|
||||
stillToFill = 0
|
||||
}
|
||||
p := map[string]any{
|
||||
"id": id, "title": title, "priority": priority,
|
||||
"headcount": headcount, "assigned": assigned, "stillToFill": stillToFill,
|
||||
}
|
||||
if category != "" {
|
||||
p["roleCategory"] = category
|
||||
}
|
||||
if location != "" {
|
||||
p["location"] = location
|
||||
}
|
||||
if startDate != nil {
|
||||
p["startDate"] = startDate.Format("2006-01-02")
|
||||
}
|
||||
positions = append(positions, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the open roles could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{"positions": positions, "count": len(positions)}
|
||||
if len(positions) == 0 {
|
||||
data["note"] = "No roles are open. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Lookup: available workers ──────────────────────────────────────────── */
|
||||
|
||||
type availableWorkersInput struct {
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// AvailableWorkers lists workers with no clashing assignment in a window.
|
||||
//
|
||||
// "Available" here means one specific, checkable thing: no active assignment
|
||||
// overlapping the window. It does not mean willing, qualified, or within their
|
||||
// contracted hours. The description says so, because a model given a tool
|
||||
// called `available_workers` will otherwise report its output as availability
|
||||
// in the ordinary sense of the word, and a manager will read it that way.
|
||||
func AvailableWorkers(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "available_workers",
|
||||
Description: "List workers who have no clashing assignment in a given window, " +
|
||||
"best-scoring first, with the email needed to assign them. Availability here " +
|
||||
"means only that nothing else is booked over that window — it does not mean " +
|
||||
"the person has agreed, is qualified for the role, or is within their hours. " +
|
||||
"Say so when reporting it.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"starts_at": map[string]any{
|
||||
"type": "string",
|
||||
"description": "When the work starts, as an RFC 3339 timestamp " +
|
||||
"(for example 2026-09-12T18:00:00Z). Required.",
|
||||
},
|
||||
"ends_at": map[string]any{
|
||||
"type": "string",
|
||||
"description": "When the work ends, as an RFC 3339 timestamp. " +
|
||||
"Omit for open-ended work.",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer", "minimum": 1, "maximum": 100,
|
||||
"description": "How many workers to list. Defaults to 10.",
|
||||
},
|
||||
},
|
||||
"required": []string{"starts_at"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorizeAs(tc, "worker-profiles", "w")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
var in availableWorkersInput
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
}
|
||||
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// The clash test is a NOT EXISTS against the same tenant, so it is
|
||||
// a pre-filter like every other predicate here rather than a list
|
||||
// fetched and then thinned in Go.
|
||||
args := append(append([]any{}, q.args...), starts, ends, limit)
|
||||
startIdx, endIdx, limIdx := len(args)-2, len(args)-1, len(args)
|
||||
rows, err := db.Query(ctx, fmt.Sprintf(`
|
||||
SELECT w.full_name, w.email::text, nullif(w.krow_score, 0),
|
||||
nullif(w.reliability_score, 0), nullif(w.experience_years, 0),
|
||||
w.current_position
|
||||
FROM worker_profiles w
|
||||
WHERE %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM assignments a
|
||||
WHERE a.org_id = w.org_id
|
||||
AND a.worker_email = w.email
|
||||
AND a.status = 'active'
|
||||
AND tstzrange(a.starts_at, coalesce(a.ends_at, 'infinity'::timestamptz))
|
||||
&& tstzrange($%d, coalesce($%d::timestamptz, 'infinity'::timestamptz)))
|
||||
ORDER BY w.krow_score DESC NULLS LAST, w.reliability_score DESC NULLS LAST
|
||||
LIMIT $%d`, q.clause(), startIdx, endIdx, limIdx), args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
workers := []map[string]any{}
|
||||
for rows.Next() {
|
||||
// Pointers, so an unrated worker carries no rating rather than a
|
||||
// 0 the model would read as the worst possible score. This list
|
||||
// feeds assign_worker, so the distinction picks who gets offered.
|
||||
var (
|
||||
name, email, position string
|
||||
krow, reliability, experience *int
|
||||
)
|
||||
if err := rows.Scan(&name, &email, &krow, &reliability, &experience, &position); err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
w := map[string]any{"name": name, "email": email}
|
||||
if krow != nil {
|
||||
w["krowScore"] = *krow
|
||||
}
|
||||
if reliability != nil {
|
||||
w["reliabilityScore"] = *reliability
|
||||
}
|
||||
if experience != nil {
|
||||
w["experienceYears"] = *experience
|
||||
}
|
||||
if position != "" {
|
||||
w["currentPosition"] = position
|
||||
}
|
||||
workers = append(workers, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"window": windowLabel(starts, ends),
|
||||
"workers": workers,
|
||||
"count": len(workers),
|
||||
"meaning": "No clashing assignment in this window. Not a statement that " +
|
||||
"they have agreed or are qualified.",
|
||||
}
|
||||
if len(workers) == 0 {
|
||||
data["note"] = "Nobody is free in that window. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Write: assign a worker ─────────────────────────────────────────────── */
|
||||
|
||||
type assignWorkerInput struct {
|
||||
JobPostingID string `json:"job_posting_id"`
|
||||
WorkerEmail string `json:"worker_email"`
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
}
|
||||
|
||||
// AssignWorker puts a worker on a role. The first tool in this service that
|
||||
// changes anything.
|
||||
//
|
||||
// The pattern every future write should copy is the split between Confirm and
|
||||
// Handler, and specifically what is duplicated across them. Both authorize.
|
||||
// Both resolve the posting and the worker. Both check for a clash. That looks
|
||||
// like repetition and is not: the renderer runs to describe, and the handler
|
||||
// runs after a person has read that description and agreed — with an unbounded
|
||||
// gap in between, during which somebody else may have taken the same shift.
|
||||
//
|
||||
// So the renderer's clash check produces a WARNING, and the handler's produces
|
||||
// a REFUSAL. A description is about the moment it was written; a write is about
|
||||
// the moment it happens.
|
||||
func AssignWorker(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "assign_worker",
|
||||
Description: "Assign a worker to an open role for a given period. This creates a " +
|
||||
"real assignment: the person is scheduled to work. Requires a role id from " +
|
||||
"open_positions and a worker email from available_workers — never invent " +
|
||||
"either. A person must approve before this takes effect.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"job_posting_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The role's id, exactly as returned by open_positions.",
|
||||
},
|
||||
"worker_email": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The worker's email, exactly as returned by available_workers.",
|
||||
},
|
||||
"starts_at": map[string]any{
|
||||
"type": "string",
|
||||
"description": "When the work starts, as an RFC 3339 timestamp. Required.",
|
||||
},
|
||||
"ends_at": map[string]any{
|
||||
"type": "string",
|
||||
"description": "When the work ends, as an RFC 3339 timestamp. Omit for open-ended.",
|
||||
},
|
||||
},
|
||||
"required": []string{"job_posting_id", "worker_email", "starts_at"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectWrite,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
|
||||
Confirm: func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result) {
|
||||
plan, denied := planAssignment(ctx, db, tc, inputs)
|
||||
if denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
|
||||
details := []Detail{
|
||||
{Label: "Worker", Value: plan.workerName},
|
||||
{Label: "Role", Value: plan.postingTitle},
|
||||
{Label: "Period", Value: windowLabel(plan.starts, plan.ends)},
|
||||
}
|
||||
if plan.location != "" {
|
||||
details = append(details, Detail{Label: "Location", Value: plan.location})
|
||||
}
|
||||
details = append(details, Detail{
|
||||
Label: "Role filled",
|
||||
Value: fmt.Sprintf("%d of %d, this would make %d",
|
||||
plan.assigned, plan.headcount, plan.assigned+1),
|
||||
})
|
||||
|
||||
var warnings []string
|
||||
if plan.clashes > 0 {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"%s already has %s over this period. Assigning them will double-book.",
|
||||
plan.workerName, plural(plan.clashes, "assignment", "assignments")))
|
||||
}
|
||||
if plan.assigned >= plan.headcount {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"%s already has all %s it asked for. This would go over headcount.",
|
||||
plan.postingTitle, plural(plan.headcount, "person", "people")))
|
||||
}
|
||||
if plan.starts.Before(time.Now()) {
|
||||
warnings = append(warnings, "This period starts in the past.")
|
||||
}
|
||||
|
||||
return &Confirmation{
|
||||
Title: fmt.Sprintf("Assign %s to %s", plan.workerName, plan.postingTitle),
|
||||
Summary: fmt.Sprintf(
|
||||
"%s will be scheduled to work %s as %s. They will appear on the roster "+
|
||||
"for this role and count towards its headcount.",
|
||||
plan.workerName, windowLabel(plan.starts, plan.ends), plan.postingTitle),
|
||||
Details: details,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
},
|
||||
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
plan, denied := planAssignment(ctx, db, tc, inputs)
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
|
||||
// Re-checked here, not merely described above. The approval was
|
||||
// given against a picture of the world that is now some minutes
|
||||
// old, and a double-booking created between the asking and the
|
||||
// answering is one nobody agreed to.
|
||||
if plan.clashes > 0 {
|
||||
return Failf(CodeFailed,
|
||||
"%s was booked over this period since this was approved; nothing was assigned",
|
||||
plan.workerName)
|
||||
}
|
||||
|
||||
var id string
|
||||
err := db.QueryRow(ctx, `
|
||||
INSERT INTO assignments
|
||||
(org_id, job_posting_id, worker_profile_id, worker_email,
|
||||
worker_name, starts_at, ends_at, status, source, match_score)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, 'active', 'agent', $8)
|
||||
RETURNING id::text`,
|
||||
tc.OrgID(), plan.postingID, plan.workerProfileID, plan.workerEmail,
|
||||
plan.workerName, plan.starts, nullableTime(plan.ends), plan.matchScore,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the assignment could not be created")
|
||||
}
|
||||
|
||||
return OK(map[string]any{
|
||||
"assignmentId": id,
|
||||
"worker": plan.workerName,
|
||||
"role": plan.postingTitle,
|
||||
"period": windowLabel(plan.starts, plan.ends),
|
||||
"status": "active",
|
||||
"confirmed": true,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// assignmentPlan is everything both Confirm and Handler need, resolved once.
|
||||
type assignmentPlan struct {
|
||||
postingID string
|
||||
postingTitle string
|
||||
location string
|
||||
headcount int
|
||||
assigned int
|
||||
workerProfileID *string
|
||||
workerEmail string
|
||||
workerName string
|
||||
matchScore *int
|
||||
starts time.Time
|
||||
ends *time.Time
|
||||
clashes int
|
||||
}
|
||||
|
||||
// planAssignment authorizes, validates and resolves an assign_worker call.
|
||||
//
|
||||
// Shared by the renderer and the handler so that the thing described and the
|
||||
// thing done are resolved by the same code. Two separate resolutions would
|
||||
// drift, and the drift would land exactly where nobody looks: between what a
|
||||
// person approved and what then happened.
|
||||
//
|
||||
// Every refusal is the same opaque Denied(). A posting id that belongs to
|
||||
// another tenant, one that does not exist, and one this caller may not see are
|
||||
// all indistinguishable — otherwise assign_worker becomes a way to ask whether
|
||||
// a given uuid is real.
|
||||
func planAssignment(ctx context.Context, db repo.Querier, tc Context, inputs json.RawMessage) (*assignmentPlan, *Result) {
|
||||
// Create, not List. `assignments` lists to everyone and creates for
|
||||
// operators only, so a talent caller is refused here even though they may
|
||||
// read their own roster perfectly well.
|
||||
if _, denied := authorizeOp(tc, "assignments", domain.OpCreate, ""); denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
|
||||
var in assignWorkerInput
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
bad := Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
return nil, &bad
|
||||
}
|
||||
if strings.TrimSpace(in.JobPostingID) == "" || strings.TrimSpace(in.WorkerEmail) == "" {
|
||||
bad := Failf(CodeInvalidInput, "a role id and a worker email are both required")
|
||||
return nil, &bad
|
||||
}
|
||||
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
|
||||
if bad != nil {
|
||||
return nil, bad
|
||||
}
|
||||
|
||||
plan := &assignmentPlan{starts: starts, ends: ends}
|
||||
|
||||
// The posting, behind the caller's own read predicate. Referencing a row
|
||||
// the caller could not have read would confirm it exists.
|
||||
pq, denied := authorizeAs(tc, "job-postings", "p")
|
||||
if denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
pq.eq("id::text", strings.TrimSpace(in.JobPostingID))
|
||||
pq.raw("p.status = 'active'")
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT p.id::text, p.title, p.location, p.headcount,
|
||||
(SELECT count(*) FROM assignments a
|
||||
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id AND a.status = 'active')
|
||||
FROM job_postings p
|
||||
WHERE `+pq.clause(), pq.args...,
|
||||
).Scan(&plan.postingID, &plan.postingTitle, &plan.location, &plan.headcount, &plan.assigned)
|
||||
if err != nil {
|
||||
denied := Denied()
|
||||
return nil, &denied
|
||||
}
|
||||
|
||||
// The worker, likewise.
|
||||
wq, denied := authorizeAs(tc, "worker-profiles", "w")
|
||||
if denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
wq.eq("email", strings.TrimSpace(in.WorkerEmail))
|
||||
var profileID string
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT w.id::text, w.full_name, w.email::text, nullif(w.krow_score, 0)
|
||||
FROM worker_profiles w
|
||||
WHERE `+wq.clause()+`
|
||||
LIMIT 1`, wq.args...,
|
||||
).Scan(&profileID, &plan.workerName, &plan.workerEmail, &plan.matchScore); err != nil {
|
||||
denied := Denied()
|
||||
return nil, &denied
|
||||
}
|
||||
plan.workerProfileID = &profileID
|
||||
if strings.TrimSpace(plan.workerName) == "" {
|
||||
plan.workerName = plan.workerEmail
|
||||
}
|
||||
|
||||
// Clashes: active assignments overlapping the window. Counted in SQL —
|
||||
// fetching the person's roster and comparing in Go would be the same
|
||||
// post-filter I2 forbids, and would read rows this call has no reason to.
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT count(*) FROM assignments
|
||||
WHERE org_id = $1::uuid AND worker_email = $2 AND status = 'active'
|
||||
AND tstzrange(starts_at, coalesce(ends_at, 'infinity'::timestamptz))
|
||||
&& tstzrange($3, coalesce($4::timestamptz, 'infinity'::timestamptz))`,
|
||||
tc.OrgID(), plan.workerEmail, starts, nullableTime(ends),
|
||||
).Scan(&plan.clashes); err != nil {
|
||||
failed := Failf(CodeFailed, "the worker's existing assignments could not be read")
|
||||
return nil, &failed
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
/* ── Shared helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
// decodeWindow parses a caller-supplied period.
|
||||
//
|
||||
// RFC 3339 only, and validated here rather than at the database. A timestamp
|
||||
// that reaches SQL as an uninterpretable string is a constraint violation
|
||||
// wearing the costume of a tool failure, and the model cannot correct what it
|
||||
// cannot read.
|
||||
func decodeWindow(startsAt, endsAt string) (time.Time, *time.Time, *Result) {
|
||||
starts, err := time.Parse(time.RFC3339, strings.TrimSpace(startsAt))
|
||||
if err != nil {
|
||||
bad := Failf(CodeInvalidInput,
|
||||
"starts_at must be an RFC 3339 timestamp, for example 2026-09-12T18:00:00Z")
|
||||
return time.Time{}, nil, &bad
|
||||
}
|
||||
if strings.TrimSpace(endsAt) == "" {
|
||||
return starts, nil, nil
|
||||
}
|
||||
ends, err := time.Parse(time.RFC3339, strings.TrimSpace(endsAt))
|
||||
if err != nil {
|
||||
bad := Failf(CodeInvalidInput,
|
||||
"ends_at must be an RFC 3339 timestamp, for example 2026-09-12T23:00:00Z")
|
||||
return time.Time{}, nil, &bad
|
||||
}
|
||||
// The table has a CHECK for this. Caught here so the model gets a sentence
|
||||
// it can act on rather than a constraint name it cannot.
|
||||
if !ends.After(starts) {
|
||||
bad := Failf(CodeInvalidInput, "ends_at must be after starts_at")
|
||||
return time.Time{}, nil, &bad
|
||||
}
|
||||
return starts, &ends, nil
|
||||
}
|
||||
|
||||
// windowLabel renders a period the way a person reads one.
|
||||
func windowLabel(starts time.Time, ends *time.Time) string {
|
||||
if ends == nil {
|
||||
return starts.Format("Mon 2 Jan 2006, 15:04") + " onwards"
|
||||
}
|
||||
if ends.YearDay() == starts.YearDay() && ends.Year() == starts.Year() {
|
||||
return fmt.Sprintf("%s–%s",
|
||||
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("15:04"))
|
||||
}
|
||||
return fmt.Sprintf("%s – %s",
|
||||
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("Mon 2 Jan 2006, 15:04"))
|
||||
}
|
||||
|
||||
func nullableTime(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return *t
|
||||
}
|
||||
|
||||
func plural(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprintf("1 %s", one)
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
}
|
||||
443
go-api/internal/tools/assignments_test.go
Normal file
443
go-api/internal/tools/assignments_test.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// The write path, end to end, against the real schema and the real store.
|
||||
//
|
||||
// Everything in confirm_test.go is about the mechanism and runs against a spy.
|
||||
// This file is about the one tool that uses it, and the assertion that matters
|
||||
// throughout is the same one: count the rows in `assignments`. A test that only
|
||||
// checks what Dispatch returned cannot tell a refusal that wrote from a refusal
|
||||
// that did not.
|
||||
|
||||
type assignFixture struct {
|
||||
orgID string
|
||||
admin authctx.Identity
|
||||
talent authctx.Identity
|
||||
postingID string
|
||||
worker string
|
||||
starts time.Time
|
||||
ends time.Time
|
||||
}
|
||||
|
||||
func seedAssignable(t *testing.T, h *testutil.Harness, slug string) assignFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
org := freshOrg(t, h, slug)
|
||||
|
||||
// Emails are globally unique, not merely unique per organization, so every
|
||||
// fixture scopes its own by slug. Two tenants in one test would otherwise
|
||||
// collide on the second seed.
|
||||
boss := fmt.Sprintf("boss-%s@example.test", slug)
|
||||
f := assignFixture{
|
||||
orgID: org,
|
||||
worker: fmt.Sprintf("maya-%s@example.test", slug),
|
||||
starts: time.Date(2030, 9, 12, 18, 0, 0, 0, time.UTC),
|
||||
ends: time.Date(2030, 9, 12, 23, 0, 0, 0, time.UTC),
|
||||
}
|
||||
// Real user rows, not invented uuids. agent_confirmations references
|
||||
// users(id), so a synthetic principal cannot have a confirmation issued for
|
||||
// it — which is correct (a confirmation is asked OF somebody) and means the
|
||||
// fixture has to be honest about who is asking.
|
||||
f.admin = authctx.Identity{
|
||||
UserID: seedUser(t, h, org, boss, "admin"),
|
||||
OrgID: org, Role: "admin", Email: boss,
|
||||
}
|
||||
f.talent = authctx.Identity{
|
||||
UserID: seedUser(t, h, org, f.worker, "talent"),
|
||||
OrgID: org, Role: "talent", Email: f.worker,
|
||||
}
|
||||
|
||||
if err := h.Pool.QueryRow(ctx, `
|
||||
INSERT INTO job_postings (org_id, title, status, headcount, location)
|
||||
VALUES ($1::uuid, 'Bar Supervisor', 'active', 2, 'Shoreditch')
|
||||
RETURNING id::text`, org).Scan(&f.postingID); err != nil {
|
||||
t.Fatalf("seed posting: %v", err)
|
||||
}
|
||||
if _, err := h.Pool.Exec(ctx, `
|
||||
INSERT INTO worker_profiles (org_id, full_name, email, krow_score)
|
||||
VALUES ($1::uuid, 'Maya Chen', $2, 88)`, org, f.worker); err != nil {
|
||||
t.Fatalf("seed worker: %v", err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, h *testutil.Harness, org, email, role string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := h.Pool.QueryRow(context.Background(), `
|
||||
INSERT INTO users (org_id, email, full_name, role)
|
||||
VALUES ($1::uuid, $2, $3, $4) RETURNING id::text`,
|
||||
org, email, email, role).Scan(&id); err != nil {
|
||||
t.Fatalf("seed user %s: %v", email, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// liveRegistry is the shipped tool set over the real Postgres confirmation
|
||||
// store — the wiring the service actually runs, not the in-memory stand-in.
|
||||
func liveRegistry(t *testing.T, h *testutil.Harness) *tools.Registry {
|
||||
t.Helper()
|
||||
reg := tools.NewRegistryWithStore(tools.NewPostgresStore(h.Pool))
|
||||
for _, tool := range everyTool(h.Pool) {
|
||||
reg.MustRegister(tool)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func assignmentCount(t *testing.T, h *testutil.Harness, org string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := h.Pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM assignments WHERE org_id = $1::uuid`, org).Scan(&n); err != nil {
|
||||
t.Fatalf("count assignments: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func assignArgs(f assignFixture) string {
|
||||
return fmt.Sprintf(`{"job_posting_id":%q,"worker_email":%q,"starts_at":%q,"ends_at":%q}`,
|
||||
f.postingID, f.worker, f.starts.Format(time.RFC3339), f.ends.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
/* ── The cycle ──────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestAssignWorkerDescribesTheWriteInWordsAPersonCanCheck(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
f := seedAssignable(t, h, "assign-describe")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: f.admin, RunID: "run_a"}, "assign_worker", json.RawMessage(assignArgs(f)))
|
||||
|
||||
if res.Confirmation == nil {
|
||||
t.Fatalf("expected a confirmation, got %+v", res)
|
||||
}
|
||||
if n := assignmentCount(t, h, f.orgID); n != 0 {
|
||||
t.Fatalf("%d assignments were created before anyone approved anything", n)
|
||||
}
|
||||
|
||||
c := res.Confirmation
|
||||
// The names, not the ids. A person asked to approve a pair of uuids is a
|
||||
// person clicking yes without reading, which is the failure mode the whole
|
||||
// renderer exists to avoid.
|
||||
body, _ := json.Marshal(c)
|
||||
for _, want := range []string{"Maya Chen", "Bar Supervisor"} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("the confirmation does not mention %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(c.Title, f.postingID) || strings.Contains(c.Summary, f.postingID) {
|
||||
t.Error("the confirmation shows a raw id where it should show a name")
|
||||
}
|
||||
if !strings.Contains(string(body), "12 Sep 2030") {
|
||||
t.Errorf("the confirmation does not say when the work is: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignWorkerWritesOnlyAfterApproval(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
f := seedAssignable(t, h, "assign-approve")
|
||||
reg := liveRegistry(t, h)
|
||||
args := assignArgs(f)
|
||||
tc := tools.Context{Principal: f.admin, RunID: "run_b"}
|
||||
|
||||
c := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)).Confirmation
|
||||
if c == nil {
|
||||
t.Fatal("no confirmation was raised")
|
||||
}
|
||||
if n := assignmentCount(t, h, f.orgID); n != 0 {
|
||||
t.Fatalf("%d assignments before approval", n)
|
||||
}
|
||||
|
||||
tc.Confirmation = c.Token
|
||||
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("an approved write should have run: %+v", res.Error)
|
||||
}
|
||||
if n := assignmentCount(t, h, f.orgID); n != 1 {
|
||||
t.Fatalf("%d assignments after approval, want 1", n)
|
||||
}
|
||||
|
||||
// And the row says an agent did it, not a person. `source` is what an
|
||||
// operator reads when they ask why somebody is on a roster.
|
||||
var source, name string
|
||||
if err := h.Pool.QueryRow(context.Background(),
|
||||
`SELECT source, worker_name FROM assignments WHERE org_id = $1::uuid`, f.orgID,
|
||||
).Scan(&source, &name); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if source != "agent" {
|
||||
t.Errorf("assignment source is %q; an agent-created row must say so", source)
|
||||
}
|
||||
if name != "Maya Chen" {
|
||||
t.Errorf("worker_name is %q, want Maya Chen", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovingOneAssignmentDoesNotApproveAnother(t *testing.T) {
|
||||
// The end-to-end version of the binding test, against real rows: a person
|
||||
// approves Maya on the bar shift, and the token is then presented for a
|
||||
// different period. Nothing may be written.
|
||||
h := testutil.New(t)
|
||||
f := seedAssignable(t, h, "assign-swap")
|
||||
reg := liveRegistry(t, h)
|
||||
tc := tools.Context{Principal: f.admin, RunID: "run_c"}
|
||||
|
||||
c := reg.Dispatch(context.Background(), tc, "assign_worker",
|
||||
json.RawMessage(assignArgs(f))).Confirmation
|
||||
if c == nil {
|
||||
t.Fatal("no confirmation was raised")
|
||||
}
|
||||
|
||||
other := f
|
||||
other.starts = f.starts.AddDate(0, 0, 1)
|
||||
other.ends = f.ends.AddDate(0, 0, 1)
|
||||
|
||||
tc.Confirmation = c.Token
|
||||
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(assignArgs(other)))
|
||||
|
||||
if n := assignmentCount(t, h, f.orgID); n != 0 {
|
||||
t.Fatalf("%d assignments written on a substituted approval", n)
|
||||
}
|
||||
// The substituted call is described rather than merely refused, so the
|
||||
// person is asked about the shift that is actually being proposed.
|
||||
if res.Confirmation == nil {
|
||||
t.Fatal("the substituted call raised no confirmation of its own")
|
||||
}
|
||||
if res.Confirmation.Token == c.Token {
|
||||
t.Fatal("the approval for one shift was handed back for another")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Authorization ──────────────────────────────────────────────────────── */
|
||||
|
||||
func TestTalentCannotAssignThemselves(t *testing.T) {
|
||||
// `assignments` lists to everyone and creates for operators only. A talent
|
||||
// caller may read their own roster perfectly well, so asking the policy the
|
||||
// READ question here would have let them put themselves on a shift — which
|
||||
// is exactly the bug authorizeOp exists to prevent.
|
||||
h := testutil.New(t)
|
||||
f := seedAssignable(t, h, "assign-talent")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: f.talent, RunID: "run_d"}, "assign_worker",
|
||||
json.RawMessage(assignArgs(f)))
|
||||
|
||||
if res.Confirmation != nil {
|
||||
t.Fatal("a talent caller was asked to approve a write they may not make")
|
||||
}
|
||||
if res.Error == nil || res.Error.Code != tools.CodeDenied {
|
||||
t.Fatalf("want the standard denial, got %+v", res.Error)
|
||||
}
|
||||
if n := assignmentCount(t, h, f.orgID); n != 0 {
|
||||
t.Fatalf("%d assignments created by a talent caller", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignWorkerCannotReachAnotherTenantsRole(t *testing.T) {
|
||||
// The posting is resolved behind the caller's own read predicate, so a role
|
||||
// id from another organization is not merely refused — it is refused
|
||||
// identically to one that does not exist. Otherwise assign_worker becomes a
|
||||
// way to ask whether a given uuid is real.
|
||||
h := testutil.New(t)
|
||||
mine := seedAssignable(t, h, "assign-mine")
|
||||
theirs := seedAssignable(t, h, "assign-theirs")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
crossed := mine
|
||||
crossed.postingID = theirs.postingID
|
||||
real := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: mine.admin, RunID: "run_e"}, "assign_worker",
|
||||
json.RawMessage(assignArgs(crossed)))
|
||||
|
||||
invented := mine
|
||||
invented.postingID = "00000000-0000-0000-0000-0000000000ff"
|
||||
fake := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: mine.admin, RunID: "run_e"}, "assign_worker",
|
||||
json.RawMessage(assignArgs(invented)))
|
||||
|
||||
if real.Error == nil {
|
||||
t.Fatal("a role from another tenant was accepted")
|
||||
}
|
||||
if fake.Error == nil {
|
||||
t.Fatal("an invented role id was accepted")
|
||||
}
|
||||
if real.Error.Code != fake.Error.Code || real.Error.Message != fake.Error.Message {
|
||||
t.Errorf("a real-but-forbidden role is distinguishable from an imaginary one:\n"+
|
||||
" other tenant: %s — %s\n invented: %s — %s",
|
||||
real.Error.Code, real.Error.Message, fake.Error.Code, fake.Error.Message)
|
||||
}
|
||||
if n := assignmentCount(t, h, theirs.orgID); n != 0 {
|
||||
t.Fatal("a write crossed a tenant boundary")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── What the description warns about ───────────────────────────────────── */
|
||||
|
||||
func TestADoubleBookingIsWarnedAboutAndThenRefused(t *testing.T) {
|
||||
// The renderer warns; the handler refuses. Both, because the gap between
|
||||
// asking and answering is unbounded, and a clash that appears inside it is
|
||||
// one nobody was shown.
|
||||
h := testutil.New(t)
|
||||
f := seedAssignable(t, h, "assign-clash")
|
||||
reg := liveRegistry(t, h)
|
||||
args := assignArgs(f)
|
||||
tc := tools.Context{Principal: f.admin, RunID: "run_f"}
|
||||
|
||||
// First assignment, approved and written.
|
||||
c := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)).Confirmation
|
||||
tc.Confirmation = c.Token
|
||||
if res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)); res.Error != nil {
|
||||
t.Fatalf("first assignment failed: %+v", res.Error)
|
||||
}
|
||||
|
||||
// Second, over the same window. Now the renderer has something to say.
|
||||
tc.Confirmation = ""
|
||||
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
|
||||
if res.Confirmation == nil {
|
||||
t.Fatal("no confirmation was raised for the clashing assignment")
|
||||
}
|
||||
if len(res.Confirmation.Warnings) == 0 {
|
||||
t.Fatal("a double-booking was described without a warning")
|
||||
}
|
||||
found := false
|
||||
for _, w := range res.Confirmation.Warnings {
|
||||
if strings.Contains(w, "double-book") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the warnings do not mention the clash: %v", res.Confirmation.Warnings)
|
||||
}
|
||||
|
||||
// And approving it anyway is still refused, because the clash is real now
|
||||
// rather than merely predicted.
|
||||
tc.Confirmation = res.Confirmation.Token
|
||||
if out := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)); out.Error == nil {
|
||||
t.Fatal("an approved double-booking was written")
|
||||
}
|
||||
if n := assignmentCount(t, h, f.orgID); n != 1 {
|
||||
t.Fatalf("%d assignments, want 1 — the clash was written anyway", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoingOverHeadcountIsWarnedAbout(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
f := seedAssignable(t, h, "assign-headcount")
|
||||
reg := liveRegistry(t, h)
|
||||
ctx := context.Background()
|
||||
|
||||
// The posting asks for 2. Fill both with other people, so the third is over
|
||||
// headcount without also being a clash for our worker.
|
||||
for i, email := range []string{"a@example.test", "b@example.test"} {
|
||||
if _, err := h.Pool.Exec(ctx, `
|
||||
INSERT INTO assignments (org_id, job_posting_id, worker_email, worker_name, starts_at, ends_at)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6)`,
|
||||
f.orgID, f.postingID, email, fmt.Sprintf("Worker %d", i), f.starts, f.ends); err != nil {
|
||||
t.Fatalf("seed assignment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
res := reg.Dispatch(ctx, tools.Context{Principal: f.admin, RunID: "run_g"},
|
||||
"assign_worker", json.RawMessage(assignArgs(f)))
|
||||
if res.Confirmation == nil {
|
||||
t.Fatalf("expected a confirmation, got %+v", res)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, w := range res.Confirmation.Warnings {
|
||||
if strings.Contains(w, "headcount") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("going over headcount was not warned about: %v", res.Confirmation.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The lookups ────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestOpenPositionsReturnsIdsAndCountsWhatIsLeftToFill(t *testing.T) {
|
||||
// §4: a tool that requires the model to guess an id is a design bug. This
|
||||
// is the lookup that makes assign_worker usable without guessing.
|
||||
h := testutil.New(t)
|
||||
f := seedAssignable(t, h, "open-positions")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: f.admin, RunID: "run_h"}, "open_positions", json.RawMessage(`{}`))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("open_positions failed: %+v", res.Error)
|
||||
}
|
||||
body, _ := json.Marshal(res.Data)
|
||||
if !strings.Contains(string(body), f.postingID) {
|
||||
t.Errorf("open_positions did not return the role's id: %s", body)
|
||||
}
|
||||
if !strings.Contains(string(body), "stillToFill") {
|
||||
t.Errorf("open_positions does not say how many are still needed: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableWorkersExcludesSomebodyAlreadyBooked(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
f := seedAssignable(t, h, "available")
|
||||
reg := liveRegistry(t, h)
|
||||
ctx := context.Background()
|
||||
window := fmt.Sprintf(`{"starts_at":%q,"ends_at":%q}`,
|
||||
f.starts.Format(time.RFC3339), f.ends.Format(time.RFC3339))
|
||||
tc := tools.Context{Principal: f.admin, RunID: "run_i"}
|
||||
|
||||
res := reg.Dispatch(ctx, tc, "available_workers", json.RawMessage(window))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("available_workers failed: %+v", res.Error)
|
||||
}
|
||||
if body, _ := json.Marshal(res.Data); !strings.Contains(string(body), f.worker) {
|
||||
t.Fatalf("a free worker was not listed: %s", body)
|
||||
}
|
||||
|
||||
if _, err := h.Pool.Exec(ctx, `
|
||||
INSERT INTO assignments (org_id, job_posting_id, worker_email, worker_name, starts_at, ends_at)
|
||||
VALUES ($1::uuid, $2::uuid, $3, 'Maya Chen', $4, $5)`,
|
||||
f.orgID, f.postingID, f.worker, f.starts, f.ends); err != nil {
|
||||
t.Fatalf("seed clash: %v", err)
|
||||
}
|
||||
|
||||
res = reg.Dispatch(ctx, tc, "available_workers", json.RawMessage(window))
|
||||
if body, _ := json.Marshal(res.Data); strings.Contains(string(body), f.worker) {
|
||||
t.Errorf("a booked worker was still reported as available: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableWorkersSaysWhatAvailableMeans(t *testing.T) {
|
||||
// The word carries more meaning to a reader than the query can support. A
|
||||
// model handed a tool called `available_workers` will otherwise report its
|
||||
// output as availability in the ordinary sense, and a manager will act on
|
||||
// it as though somebody had been asked.
|
||||
h := testutil.New(t)
|
||||
f := seedAssignable(t, h, "available-meaning")
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: f.admin, RunID: "run_j"}, "available_workers",
|
||||
json.RawMessage(fmt.Sprintf(`{"starts_at":%q}`, f.starts.Format(time.RFC3339))))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("available_workers failed: %+v", res.Error)
|
||||
}
|
||||
body, _ := json.Marshal(res.Data)
|
||||
if !strings.Contains(string(body), "not mean") && !strings.Contains(string(body), "Not a statement") {
|
||||
t.Errorf("the result does not qualify what availability means: %s", body)
|
||||
}
|
||||
}
|
||||
384
go-api/internal/tools/confirm.go
Normal file
384
go-api/internal/tools/confirm.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The confirmation gate: I4, and the one place a write is allowed to happen.
|
||||
//
|
||||
// The invariant is short — "any tool that writes, sends, deletes, charges or
|
||||
// notifies cannot execute without a resolved confirmation token" — but the
|
||||
// naive reading of it is not safe, and the difference is the whole of this
|
||||
// file.
|
||||
//
|
||||
// The naive reading is a boolean: ask, get a yes, run. That version has a hole
|
||||
// wide enough to drive a payroll through. A person approves "assign Maya Chen
|
||||
// to Friday's bar shift"; the model, on the next turn, calls the same tool with
|
||||
// a different worker and the same yes still applies. Nothing in a boolean
|
||||
// distinguishes those two calls, so the approval a person gave to one becomes
|
||||
// an approval they never gave to the other.
|
||||
//
|
||||
// So a confirmation here is a *binding*, not a flag. A token is issued against
|
||||
// a fingerprint of exactly what was described to the person:
|
||||
//
|
||||
// tool name + canonical inputs + principal + tenant
|
||||
//
|
||||
// and it validates only against a call carrying that same fingerprint. Change
|
||||
// the worker, change the shift, change the caller, cross a tenant — each of
|
||||
// those produces a different fingerprint and the token is refused. It is also
|
||||
// single-use, so one approval buys exactly one write.
|
||||
//
|
||||
// WHY THE RUN IS RECORDED BUT NOT MATCHED
|
||||
//
|
||||
// The fingerprint originally included the run id, which is the tighter thing to
|
||||
// do and was wrong. A confirmation exists precisely so that a run can END and a
|
||||
// person can be asked; the run that resumes afterwards is a new run with a new
|
||||
// id, so matching on it made every token unredeemable — the mechanism refused
|
||||
// exactly the case it was built for.
|
||||
//
|
||||
// The run id is still stored, because "which conversation proposed this write"
|
||||
// is worth being able to answer. It is not part of the match, and what covers
|
||||
// the gap is the rest of the binding: the arguments are identical, so a token
|
||||
// replayed in a later run authorises the very write it described; the TTL bounds
|
||||
// how stale the surrounding facts can be; single-use bounds it to one; and the
|
||||
// handler re-checks the world before writing. What a run-scoped match would have
|
||||
// added on top of that is protection against a surface that hands back a token
|
||||
// the user never clicked — which is a bug in the surface, not a hole a token
|
||||
// format can close.
|
||||
//
|
||||
// The second half is Confirmer. A person cannot approve what they cannot read,
|
||||
// and `{"job_posting_id":"3f2b...","worker_profile_id":"91ac..."}` is not
|
||||
// something anyone can approve honestly. Every write tool must render a plain
|
||||
// language description of what will happen — and must do it *behind the same
|
||||
// authorization as the write itself*, because a renderer that resolves a name
|
||||
// the caller may not see has leaked that name in the course of asking whether
|
||||
// to proceed.
|
||||
//
|
||||
// Timing: the description is rendered from the same inputs that are
|
||||
// fingerprinted, at the moment of asking. It is a description of the call, not
|
||||
// a promise about the world — the underlying rows can still change between
|
||||
// asking and executing. Where that matters, the handler re-checks; see
|
||||
// assignments.go for the one case where it does.
|
||||
|
||||
/* ── What a person is asked to approve ──────────────────────────────────── */
|
||||
|
||||
// Confirmation is a pending write, described for a human.
|
||||
type Confirmation struct {
|
||||
// Token is what resolves this confirmation. Opaque, single-use, and bound
|
||||
// to the exact call it was issued for.
|
||||
Token string `json:"token"`
|
||||
|
||||
Tool string `json:"tool"`
|
||||
|
||||
// Title is one line, plain language, no ids. "Assign Maya Chen to Bar
|
||||
// Supervisor".
|
||||
Title string `json:"title"`
|
||||
|
||||
// Summary says what will happen if this is approved, in a sentence a
|
||||
// person can hold against their own intent.
|
||||
Summary string `json:"summary"`
|
||||
|
||||
// Details are the specifics, resolved to names rather than ids. Rendered as
|
||||
// a list beside the summary.
|
||||
Details []Detail `json:"details,omitempty"`
|
||||
|
||||
// Warnings are things the person should know before saying yes — a clash,
|
||||
// an overtime threshold, a role already filled. Present precisely because
|
||||
// the model is not trusted to surface them.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// Detail is one labelled fact in a confirmation.
|
||||
type Detail struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Confirmer renders what a write will do, before it does it.
|
||||
//
|
||||
// Returns either a description or a refusal, never both. The refusal is the
|
||||
// same opaque Denied() every handler returns: a renderer that explained why it
|
||||
// could not describe something would answer, at confirmation time, the question
|
||||
// the denial exists to leave unanswered.
|
||||
//
|
||||
// A renderer must not write anything. It runs before any approval exists.
|
||||
type Confirmer func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result)
|
||||
|
||||
/* ── The binding ────────────────────────────────────────────────────────── */
|
||||
|
||||
// binding is the fingerprint a token is issued against.
|
||||
type binding struct {
|
||||
Tool string
|
||||
InputsHash string
|
||||
UserID string
|
||||
OrgID string
|
||||
RunID string
|
||||
|
||||
// Inputs are the arguments themselves, carried alongside their hash so a
|
||||
// store can record them. The hash is what a re-derived call is MATCHED
|
||||
// against; these are what a redeemed token REPLAYS. Both paths exist —
|
||||
// see Store.Redeem for why the second one had to.
|
||||
Inputs json.RawMessage
|
||||
|
||||
// AgentID is whose spec proposed this. Not used to authorise; recorded
|
||||
// because a redeemed call runs outside any agent's resolved tool list, and
|
||||
// "which agent offered this" is the question an audit of that asks.
|
||||
AgentID string
|
||||
}
|
||||
|
||||
// bind fingerprints a call.
|
||||
//
|
||||
// RunID is captured for the record rather than for the match.
|
||||
//
|
||||
// Inputs are canonicalised before hashing, so a model that reorders keys or
|
||||
// re-spaces its JSON between the asking turn and the executing turn does not
|
||||
// invalidate a perfectly good approval. Anything that canonicalisation cannot
|
||||
// parse is hashed verbatim — a malformed body is not a reason to widen what a
|
||||
// token matches.
|
||||
func bind(tc Context, tool string, inputs json.RawMessage) binding {
|
||||
canonical := canonicalJSON(inputs)
|
||||
sum := sha256.Sum256(canonical)
|
||||
return binding{
|
||||
Tool: tool,
|
||||
InputsHash: hex.EncodeToString(sum[:]),
|
||||
UserID: tc.Principal.UserID,
|
||||
OrgID: tc.Principal.OrgID,
|
||||
RunID: tc.RunID,
|
||||
Inputs: json.RawMessage(canonical),
|
||||
AgentID: tc.AgentID,
|
||||
}
|
||||
}
|
||||
|
||||
// matches reports whether two bindings are the same call.
|
||||
//
|
||||
// RunID is deliberately absent — see the note at the top of this file. Every
|
||||
// other field is compared, and the hash is compared in constant time: the token
|
||||
// itself is unguessable so this is not the load-bearing secret, but a
|
||||
// comparison that leaks where two inputs first differ is a comparison worth not
|
||||
// writing in the first place.
|
||||
func (b binding) matches(other binding) bool {
|
||||
return b.Tool == other.Tool &&
|
||||
b.UserID == other.UserID &&
|
||||
b.OrgID == other.OrgID &&
|
||||
subtle.ConstantTimeCompare([]byte(b.InputsHash), []byte(other.InputsHash)) == 1
|
||||
}
|
||||
|
||||
// canonicalJSON re-encodes a JSON document with object keys sorted.
|
||||
//
|
||||
// Two calls that mean the same thing must fingerprint the same, or a person
|
||||
// would be asked to approve the identical write twice because the model
|
||||
// happened to emit its arguments in a different order.
|
||||
func canonicalJSON(raw json.RawMessage) []byte {
|
||||
if len(raw) == 0 {
|
||||
return []byte("null")
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return raw
|
||||
}
|
||||
var b strings.Builder
|
||||
writeCanonical(&b, v)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func writeCanonical(b *strings.Builder, v any) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
b.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
encoded, _ := json.Marshal(k)
|
||||
b.Write(encoded)
|
||||
b.WriteByte(':')
|
||||
writeCanonical(b, t[k])
|
||||
}
|
||||
b.WriteByte('}')
|
||||
case []any:
|
||||
b.WriteByte('[')
|
||||
for i, item := range t {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
writeCanonical(b, item)
|
||||
}
|
||||
b.WriteByte(']')
|
||||
default:
|
||||
encoded, _ := json.Marshal(t)
|
||||
b.Write(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Where pending confirmations live ───────────────────────────────────── */
|
||||
|
||||
// ConfirmationTTL is how long an unanswered confirmation stays answerable.
|
||||
//
|
||||
// Bounded because an approval is a judgement about a moment. A yes clicked on
|
||||
// a two-day-old "assign Maya to Friday's shift" is a yes to a question whose
|
||||
// answer has probably changed, and there is no way for the person clicking to
|
||||
// know that. Expiring forces the question to be asked again against current
|
||||
// facts.
|
||||
const ConfirmationTTL = 30 * time.Minute
|
||||
|
||||
// Store holds confirmations between being asked and being answered.
|
||||
//
|
||||
// Two operations, and the second is the interesting one: Resolve must be
|
||||
// atomic. Two concurrent calls carrying the same token must not both succeed,
|
||||
// or a single approval buys two writes — which is the same hole the binding
|
||||
// closes, arriving by a different door.
|
||||
type Store interface {
|
||||
// Issue records a pending confirmation and returns its token.
|
||||
Issue(ctx context.Context, b binding, c *Confirmation) error
|
||||
|
||||
// Resolve consumes a token, reporting whether it authorises this exact
|
||||
// call. A token that does not exist, has expired, has already been used or
|
||||
// was issued for a different call all return false — indistinguishably,
|
||||
// because telling them apart is an oracle over other people's pending
|
||||
// approvals, and because the caller's response to all four is the same:
|
||||
// describe the call and ask again.
|
||||
//
|
||||
// Only a matching token is consumed. A mismatch must leave the token
|
||||
// spendable by the call it was issued for.
|
||||
Resolve(ctx context.Context, token string, b binding) bool
|
||||
|
||||
// Redeem consumes a token and returns the call it authorised.
|
||||
//
|
||||
// The difference from Resolve is which direction the arguments travel, and
|
||||
// it is the whole reason this exists. Resolve is handed a call and asked
|
||||
// "was this approved?" — which requires the model to have produced the same
|
||||
// call again. Redeem is handed only the token and asked "what was
|
||||
// approved?", so honouring an approval does not depend on a model
|
||||
// reproducing itself.
|
||||
//
|
||||
// The caller is still checked: a token belongs to one principal in one
|
||||
// tenant, and Redeem refuses one presented by anybody else. What it does
|
||||
// NOT check is the arguments, because it is the source of them.
|
||||
Redeem(ctx context.Context, token string, p Principal) (Approved, bool)
|
||||
}
|
||||
|
||||
// Principal identifies who is redeeming, without the whole identity.
|
||||
//
|
||||
// Deliberately just the two fields a token is bound to. A Store has no business
|
||||
// with a caller's role or email — it is answering "is this the person who was
|
||||
// asked?", not "may this person do things?", and the second question was
|
||||
// already settled when the confirmation was raised.
|
||||
type Principal struct {
|
||||
UserID string
|
||||
OrgID string
|
||||
}
|
||||
|
||||
// Approved is a call a person authorised.
|
||||
type Approved struct {
|
||||
Tool string
|
||||
Inputs json.RawMessage
|
||||
AgentID string
|
||||
}
|
||||
|
||||
// newToken returns an unguessable confirmation token.
|
||||
func newToken() (string, error) {
|
||||
var b [24]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("tools: no randomness for a confirmation token: %w", err)
|
||||
}
|
||||
return "cnf_" + hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
/* ── In-memory store ────────────────────────────────────────────────────── */
|
||||
|
||||
// MemoryStore keeps confirmations in this process.
|
||||
//
|
||||
// Correct for a single instance and for tests. It is deliberately NOT the
|
||||
// default in wiring: behind more than one replica, the approval would land on
|
||||
// whichever instance the callback happened to reach, and roughly half of all
|
||||
// approvals would be refused for no reason a user could act on. See
|
||||
// PostgresStore.
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]pendingRecord
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type pendingRecord struct {
|
||||
binding binding
|
||||
inputs json.RawMessage
|
||||
agentID string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewMemoryStore builds an empty store.
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{pending: map[string]pendingRecord{}, now: time.Now}
|
||||
}
|
||||
|
||||
// Issue records a pending confirmation.
|
||||
func (s *MemoryStore) Issue(_ context.Context, b binding, c *Confirmation) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pending[c.Token] = pendingRecord{
|
||||
binding: b, inputs: b.Inputs, agentID: b.AgentID, expiresAt: c.ExpiresAt,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Redeem consumes a token and returns what it authorised.
|
||||
func (s *MemoryStore) Redeem(_ context.Context, token string, p Principal) (Approved, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
rec, ok := s.pending[token]
|
||||
if !ok {
|
||||
return Approved{}, false
|
||||
}
|
||||
if s.now().After(rec.expiresAt) {
|
||||
return Approved{}, false
|
||||
}
|
||||
// The caller has to be the one who was asked. Same tenant, same person.
|
||||
if rec.binding.UserID != p.UserID || rec.binding.OrgID != p.OrgID {
|
||||
return Approved{}, false
|
||||
}
|
||||
delete(s.pending, token)
|
||||
return Approved{Tool: rec.binding.Tool, Inputs: rec.inputs, AgentID: rec.agentID}, true
|
||||
}
|
||||
|
||||
// Resolve consumes a token if it authorises this call.
|
||||
//
|
||||
// Lookup, check and delete all happen under one lock, which is what makes
|
||||
// single-use mean single-use rather than "usually single-use": two goroutines
|
||||
// arriving together cannot both find the token present.
|
||||
//
|
||||
// A token that does not match this call is left alone rather than spent. It was
|
||||
// issued for some other call, and that call may still be about to arrive — in
|
||||
// the same turn, even. Spending it here would refuse the write the person
|
||||
// actually approved.
|
||||
func (s *MemoryStore) Resolve(_ context.Context, token string, b binding) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
rec, ok := s.pending[token]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if s.now().After(rec.expiresAt) || !rec.binding.matches(b) {
|
||||
return false
|
||||
}
|
||||
delete(s.pending, token)
|
||||
return true
|
||||
}
|
||||
178
go-api/internal/tools/confirm_store.go
Normal file
178
go-api/internal/tools/confirm_store.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// PostgresStore keeps pending confirmations in agent_confirmations.
|
||||
//
|
||||
// The store MemoryStore should have been. A confirmation is asked for in one
|
||||
// request and answered in another, and there is no reason those two requests
|
||||
// reach the same process — behind two replicas, an in-process store refuses
|
||||
// roughly half of all approvals, and refuses them in a way the person clicking
|
||||
// cannot act on and cannot even see the cause of.
|
||||
type PostgresStore struct {
|
||||
db repo.Querier
|
||||
}
|
||||
|
||||
// NewPostgresStore builds a store over a pool or transaction.
|
||||
func NewPostgresStore(db repo.Querier) *PostgresStore { return &PostgresStore{db: db} }
|
||||
|
||||
var _ Store = (*PostgresStore)(nil)
|
||||
|
||||
// Issue records a pending confirmation.
|
||||
//
|
||||
// Fails loudly. The registry treats an Issue error as a reason to refuse the
|
||||
// tool outright, because showing somebody a question whose answer will be
|
||||
// discarded is worse than saying the tool is unavailable.
|
||||
func (s *PostgresStore) Issue(ctx context.Context, b binding, c *Confirmation) error {
|
||||
if b.OrgID == "" {
|
||||
// I5. There is no tenant-less confirmation, and writing one would put a
|
||||
// row in the table that no caller could ever legitimately resolve.
|
||||
return errors.New("tools: a confirmation needs an organization")
|
||||
}
|
||||
if c == nil || c.Token == "" {
|
||||
return errors.New("tools: a confirmation needs a token")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tools: encoding a confirmation: %w", err)
|
||||
}
|
||||
|
||||
inputs := b.Inputs
|
||||
if len(inputs) == 0 {
|
||||
inputs = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO agent_confirmations
|
||||
(token, org_id, user_id, run_id, tool, inputs_hash, inputs, agent_id, payload, expires_at)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7::jsonb, $8, $9::jsonb, $10)`,
|
||||
c.Token, b.OrgID, nullableUUID(b.UserID), b.RunID, b.Tool, b.InputsHash,
|
||||
string(inputs), b.AgentID, payload, c.ExpiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tools: recording a confirmation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve consumes a token if it authorises this exact call.
|
||||
//
|
||||
// The claim and the check are ONE statement, and they have to be. Reading the
|
||||
// row and then updating it would be the same logic with a race in the middle,
|
||||
// and the race is a duplicated write — precisely the failure this mechanism
|
||||
// exists to prevent. `consumed_at IS NULL` in the WHERE clause combined with
|
||||
// RETURNING means exactly one concurrent caller can take a token.
|
||||
//
|
||||
// The binding is in the same WHERE clause rather than compared afterwards, so a
|
||||
// token presented against a DIFFERENT call is not consumed. That matters when a
|
||||
// turn contains two write calls: spending the token on whichever was dispatched
|
||||
// first would refuse the one the person actually approved.
|
||||
//
|
||||
// run_id is stored but not matched — a resumed run has a new id by definition.
|
||||
// See the note in confirm.go.
|
||||
//
|
||||
// The hash comparison is SQL's rather than constant-time. The token is the
|
||||
// secret and it is unguessable; the hash is only reached by a caller who
|
||||
// already holds the token, so there is no oracle to protect here.
|
||||
func (s *PostgresStore) Resolve(ctx context.Context, token string, b binding) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var claimed string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE agent_confirmations
|
||||
SET consumed_at = now()
|
||||
WHERE token = $1
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at > now()
|
||||
AND tool = $2
|
||||
AND inputs_hash = $3
|
||||
AND org_id = $4::uuid
|
||||
AND user_id IS NOT DISTINCT FROM $5::uuid
|
||||
RETURNING token`,
|
||||
token, b.Tool, b.InputsHash, b.OrgID, nullableUUID(b.UserID),
|
||||
).Scan(&claimed)
|
||||
|
||||
// No rows is the ordinary case: no such token, already spent, expired, or
|
||||
// issued for a different call. Any other error is a database problem, and
|
||||
// the safe reading of "I could not verify this approval" is that it is not
|
||||
// approved. All of them refuse identically — a caller able to distinguish
|
||||
// "spent" from "never existed" could probe other people's approvals.
|
||||
return err == nil && claimed == token
|
||||
}
|
||||
|
||||
// Sweep deletes confirmations that can no longer be answered.
|
||||
//
|
||||
// Housekeeping, not a security control: Resolve refuses an expired token
|
||||
// whether or not this has run. Returns how many rows it removed so a scheduled
|
||||
// caller can log something true.
|
||||
func (s *PostgresStore) Sweep(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
DELETE FROM agent_confirmations
|
||||
WHERE expires_at < now() - $1::interval`,
|
||||
olderThan.String())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("tools: sweeping confirmations: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// nullableUUID renders an empty principal id as SQL NULL.
|
||||
//
|
||||
// user_id is nullable and references users; an empty string would fail the cast
|
||||
// rather than storing "unknown", which is a real state — a run started by a
|
||||
// service principal has no user row behind it.
|
||||
func nullableUUID(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Redeem consumes a token and returns the call it authorised.
|
||||
//
|
||||
// One statement again, and for the same reason Resolve is: the claim and the
|
||||
// read have to be atomic or two concurrent redemptions both succeed. The
|
||||
// difference is what is checked — the caller and the expiry, but NOT the
|
||||
// arguments, because this is where the arguments come from.
|
||||
//
|
||||
// A row written before migration 000009 has a NULL `inputs`, and those cannot
|
||||
// be replayed. They are refused rather than replayed as `{}`: an empty argument
|
||||
// set is a different call from the one a person approved, and running it would
|
||||
// be worse than declining to.
|
||||
func (s *PostgresStore) Redeem(ctx context.Context, token string, p Principal) (Approved, bool) {
|
||||
if token == "" || p.OrgID == "" {
|
||||
return Approved{}, false
|
||||
}
|
||||
|
||||
var (
|
||||
tool string
|
||||
inputs []byte
|
||||
agentID string
|
||||
)
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE agent_confirmations
|
||||
SET consumed_at = now()
|
||||
WHERE token = $1
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at > now()
|
||||
AND org_id = $2::uuid
|
||||
AND user_id IS NOT DISTINCT FROM $3::uuid
|
||||
AND inputs IS NOT NULL
|
||||
RETURNING tool, inputs, agent_id`,
|
||||
token, p.OrgID, nullableUUID(p.UserID),
|
||||
).Scan(&tool, &inputs, &agentID)
|
||||
if err != nil {
|
||||
return Approved{}, false
|
||||
}
|
||||
return Approved{Tool: tool, Inputs: json.RawMessage(inputs), AgentID: agentID}, true
|
||||
}
|
||||
530
go-api/internal/tools/confirm_test.go
Normal file
530
go-api/internal/tools/confirm_test.go
Normal file
@@ -0,0 +1,530 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// These tests are about one question: what, exactly, does a person's approval
|
||||
// authorise?
|
||||
//
|
||||
// The answer I4 is usually given is "the write" — and if a confirmation were a
|
||||
// boolean, that answer would be wrong in a way nobody notices until it matters.
|
||||
// A yes given to "assign Maya to Friday" would equally authorise "assign Dan to
|
||||
// Saturday", because a boolean cannot tell them apart. Everything below exists
|
||||
// to prove the token can.
|
||||
|
||||
/* ── A harness that records what actually ran ───────────────────────────── */
|
||||
|
||||
// spyWrite is a write tool that counts its own executions.
|
||||
//
|
||||
// The assertion that matters in most of these tests is not what Dispatch
|
||||
// returned but whether the handler ran at all. A refusal that still wrote is a
|
||||
// bug that a result-shaped assertion would sail straight past.
|
||||
type spyWrite struct {
|
||||
runs atomic.Int64
|
||||
asked atomic.Int64
|
||||
denied bool
|
||||
panics bool
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
func (s *spyWrite) tool() tools.Tool {
|
||||
return tools.Tool{
|
||||
Name: "assign_worker",
|
||||
Description: "Assign somebody to something.",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
Effect: tools.EffectWrite,
|
||||
Confirm: func(_ context.Context, tc tools.Context, in json.RawMessage) (*tools.Confirmation, *tools.Result) {
|
||||
s.asked.Add(1)
|
||||
if s.panics {
|
||||
panic("a renderer that blew up")
|
||||
}
|
||||
if s.denied {
|
||||
d := tools.Denied()
|
||||
return nil, &d
|
||||
}
|
||||
return &tools.Confirmation{
|
||||
Title: "Assign somebody",
|
||||
Summary: "Somebody will be assigned to something.",
|
||||
Details: []tools.Detail{{Label: "Arguments", Value: string(in)}},
|
||||
ExpiresAt: s.expiry,
|
||||
}, nil
|
||||
},
|
||||
Handler: func(context.Context, tools.Context, json.RawMessage) tools.Result {
|
||||
s.runs.Add(1)
|
||||
return tools.OK(map[string]any{"written": true})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func caller(user, org string) tools.Context {
|
||||
return tools.Context{
|
||||
Principal: authctx.Identity{UserID: user, OrgID: org, Role: "admin"},
|
||||
RunID: "run_one",
|
||||
}
|
||||
}
|
||||
|
||||
// ask dispatches a write with no token and returns the confirmation it raised.
|
||||
func ask(t *testing.T, reg *tools.Registry, tc tools.Context, args string) *tools.Confirmation {
|
||||
t.Helper()
|
||||
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
|
||||
if res.Confirmation == nil {
|
||||
t.Fatalf("expected a confirmation, got %+v", res)
|
||||
}
|
||||
return res.Confirmation
|
||||
}
|
||||
|
||||
// notApproved asserts that a call was not authorised: nothing was written, and
|
||||
// the caller was asked afresh rather than let through.
|
||||
//
|
||||
// "Asked afresh" is the shape of every refusal here, and it is deliberate. A
|
||||
// token that does not authorise THIS call — wrong arguments, wrong caller,
|
||||
// expired, already spent, invented — all mean the same thing, which is that
|
||||
// nobody has approved what is about to happen. The honest response to that is
|
||||
// to describe it and ask, not to hand the model an error it cannot act on.
|
||||
func notApproved(t *testing.T, res tools.Result, spy *spyWrite, staleToken string) {
|
||||
t.Helper()
|
||||
if spy.runs.Load() != 0 {
|
||||
t.Fatalf("the write ran %d times without an approval for it", spy.runs.Load())
|
||||
}
|
||||
if res.Data != nil {
|
||||
t.Fatal("an unapproved write produced a result")
|
||||
}
|
||||
if res.Confirmation == nil {
|
||||
if res.Error == nil {
|
||||
t.Fatal("an unapproved write was neither refused nor re-described")
|
||||
}
|
||||
return
|
||||
}
|
||||
if staleToken != "" && res.Confirmation.Token == staleToken {
|
||||
t.Fatal("the stale token was handed straight back as if it were a fresh approval")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The gate ───────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestAWriteIsDescribedBeforeItIsDone(t *testing.T) {
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
c := ask(t, reg, caller("u1", "org1"), `{"worker":"maya"}`)
|
||||
|
||||
if spy.runs.Load() != 0 {
|
||||
t.Fatal("the handler ran before anybody approved anything")
|
||||
}
|
||||
if c.Token == "" {
|
||||
t.Error("a confirmation with no token can never be answered")
|
||||
}
|
||||
if c.Tool != "assign_worker" {
|
||||
t.Errorf("confirmation names tool %q, want assign_worker", c.Tool)
|
||||
}
|
||||
if c.Title == "" || c.Summary == "" {
|
||||
t.Error("a person cannot approve a confirmation with nothing written on it")
|
||||
}
|
||||
if c.ExpiresAt.IsZero() {
|
||||
t.Error("a confirmation that never expires is a standing authorisation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnApprovalAuthorisesOnlyTheCallItDescribed(t *testing.T) {
|
||||
// The whole reason a confirmation is a binding rather than a flag.
|
||||
//
|
||||
// A person is shown "assign maya" and approves it. The model then calls the
|
||||
// same tool for a different worker, carrying the same token. If that
|
||||
// succeeded, the approval a person gave to one write would have silently
|
||||
// become approval of another — which is not a permissions bug the user
|
||||
// could ever detect, because the dialog they saw was accurate.
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
tc := caller("u1", "org1")
|
||||
approved := ask(t, reg, tc, `{"worker":"maya","shift":"friday"}`)
|
||||
|
||||
tc.Confirmation = approved.Token
|
||||
res := reg.Dispatch(context.Background(), tc, "assign_worker",
|
||||
json.RawMessage(`{"worker":"dan","shift":"friday"}`))
|
||||
|
||||
// Not merely refused: the substituted call is DESCRIBED, so the person is
|
||||
// asked about the write that is actually being proposed.
|
||||
notApproved(t, res, spy, approved.Token)
|
||||
if res.Confirmation == nil {
|
||||
t.Fatal("the substituted call should have raised its own confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnApprovalRunsTheCallItDescribed(t *testing.T) {
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
tc := caller("u1", "org1")
|
||||
args := `{"worker":"maya","shift":"friday"}`
|
||||
approved := ask(t, reg, tc, args)
|
||||
|
||||
tc.Confirmation = approved.Token
|
||||
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
|
||||
|
||||
if res.Error != nil {
|
||||
t.Fatalf("an approved write should run: %+v", res.Error)
|
||||
}
|
||||
if spy.runs.Load() != 1 {
|
||||
t.Fatalf("handler ran %d times, want exactly 1", spy.runs.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderedArgumentsAreStillTheSameCall(t *testing.T) {
|
||||
// The other direction, and the reason inputs are canonicalised rather than
|
||||
// hashed verbatim. A model that emits its arguments in a different order on
|
||||
// the resumed turn has not changed what it is asking for, and refusing it
|
||||
// would make approvals fail at random.
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
tc := caller("u1", "org1")
|
||||
approved := ask(t, reg, tc, `{"worker":"maya","shift":"friday"}`)
|
||||
|
||||
tc.Confirmation = approved.Token
|
||||
res := reg.Dispatch(context.Background(), tc, "assign_worker",
|
||||
json.RawMessage(`{ "shift" : "friday", "worker" : "maya" }`))
|
||||
|
||||
if res.Error != nil {
|
||||
t.Fatalf("reordered and re-spaced arguments are the same call: %+v", res.Error)
|
||||
}
|
||||
if spy.runs.Load() != 1 {
|
||||
t.Fatal("the same call, written differently, should have run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnApprovalIsSpentOnce(t *testing.T) {
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
tc := caller("u1", "org1")
|
||||
args := `{"worker":"maya"}`
|
||||
approved := ask(t, reg, tc, args)
|
||||
tc.Confirmation = approved.Token
|
||||
|
||||
reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
|
||||
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
|
||||
|
||||
if spy.runs.Load() != 1 {
|
||||
t.Fatalf("one approval bought %d writes", spy.runs.Load())
|
||||
}
|
||||
if res.Data != nil {
|
||||
t.Fatal("a spent token authorised a second write")
|
||||
}
|
||||
if res.Confirmation == nil {
|
||||
t.Fatal("the second call should have raised its own confirmation")
|
||||
}
|
||||
if res.Confirmation.Token == approved.Token {
|
||||
t.Fatal("a spent token was reissued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAttemptsSpendAnApprovalOnce(t *testing.T) {
|
||||
// Single-use has to survive two goroutines arriving at the same instant, or
|
||||
// it is only single-use in the happy path — and the unhappy path is a
|
||||
// duplicated assignment nobody ordered.
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
tc := caller("u1", "org1")
|
||||
args := `{"worker":"maya"}`
|
||||
tc.Confirmation = ask(t, reg, tc, args).Token
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := spy.runs.Load(); got != 1 {
|
||||
t.Fatalf("16 concurrent attempts on one token produced %d writes, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnApprovalDoesNotCrossCallers(t *testing.T) {
|
||||
// A token is not a bearer credential for the tool. It authorises one
|
||||
// person's decision, and a second caller holding it — in the same tenant,
|
||||
// same run, same arguments — is not that person.
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
args := `{"worker":"maya"}`
|
||||
approved := ask(t, reg, caller("u1", "org1"), args)
|
||||
|
||||
other := caller("u2", "org1")
|
||||
other.Confirmation = approved.Token
|
||||
notApproved(t, reg.Dispatch(context.Background(), other, "assign_worker", json.RawMessage(args)),
|
||||
spy, approved.Token)
|
||||
}
|
||||
|
||||
func TestAnApprovalDoesNotCrossTenants(t *testing.T) {
|
||||
// I5, arriving by way of I4. The same user id in a different organization
|
||||
// is a different principal, and a confirmation issued in one tenant must
|
||||
// not act in another.
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
args := `{"worker":"maya"}`
|
||||
approved := ask(t, reg, caller("u1", "org1"), args)
|
||||
|
||||
elsewhere := caller("u1", "org2")
|
||||
elsewhere.Confirmation = approved.Token
|
||||
notApproved(t, reg.Dispatch(context.Background(), elsewhere, "assign_worker", json.RawMessage(args)),
|
||||
spy, approved.Token)
|
||||
}
|
||||
|
||||
func TestAnApprovalSurvivesTheRunEnding(t *testing.T) {
|
||||
// The case the whole mechanism exists for, and the one an earlier version
|
||||
// of this code broke.
|
||||
//
|
||||
// A confirmation ends the run — that is the point: the model stops, a person
|
||||
// is asked, and the answer arrives later. The run that resumes is a NEW run
|
||||
// with a new id, so a token scoped to the run that raised it could never be
|
||||
// redeemed by the run that resumes. Binding on the run read as the tighter
|
||||
// choice and was in fact the choice that refused every legitimate approval.
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
args := `{"worker":"maya"}`
|
||||
approved := ask(t, reg, caller("u1", "org1"), args)
|
||||
|
||||
resumed := caller("u1", "org1")
|
||||
resumed.RunID = "run_two" // a different run, as a resumed one always is
|
||||
resumed.Confirmation = approved.Token
|
||||
|
||||
if res := reg.Dispatch(context.Background(), resumed, "assign_worker", json.RawMessage(args)); res.Error != nil {
|
||||
t.Fatalf("an approval must survive the run that raised it: %+v", res.Error)
|
||||
}
|
||||
if spy.runs.Load() != 1 {
|
||||
t.Fatal("the approved write did not run on resumption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExpiredApprovalIsRefused(t *testing.T) {
|
||||
// An approval is a judgement about a moment. Honouring a two-day-old yes
|
||||
// answers a question whose facts have moved on, and the person who clicked
|
||||
// had no way to know that.
|
||||
spy := &spyWrite{expiry: time.Now().Add(-time.Minute)}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
tc := caller("u1", "org1")
|
||||
args := `{"worker":"maya"}`
|
||||
stale := ask(t, reg, tc, args).Token
|
||||
tc.Confirmation = stale
|
||||
|
||||
notApproved(t, reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)), spy, stale)
|
||||
}
|
||||
|
||||
func TestAnInventedTokenAuthorisesNothing(t *testing.T) {
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
tc := caller("u1", "org1")
|
||||
tc.Confirmation = "cnf_this-looks-about-right"
|
||||
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(`{}`))
|
||||
|
||||
notApproved(t, res, spy, tc.Confirmation)
|
||||
if res.Confirmation == nil {
|
||||
t.Fatal("an invented token should leave the call unapproved and described afresh")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The renderer ───────────────────────────────────────────────────────── */
|
||||
|
||||
func TestARefusedRendererIssuesNothingAndSaysNothing(t *testing.T) {
|
||||
// A renderer authorizes on the same terms as the write. When it refuses,
|
||||
// the refusal must be the ordinary opaque one — a distinguishable "I cannot
|
||||
// describe that" would answer, at confirmation time, exactly the question
|
||||
// the denial exists to leave unanswered.
|
||||
spy := &spyWrite{denied: true}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
res := reg.Dispatch(context.Background(), caller("u1", "org1"), "assign_worker", json.RawMessage(`{}`))
|
||||
|
||||
if res.Confirmation != nil {
|
||||
t.Fatal("a refused caller was still handed a token")
|
||||
}
|
||||
if res.Error == nil || res.Error.Code != tools.CodeDenied {
|
||||
t.Fatalf("want the standard denial, got %+v", res.Error)
|
||||
}
|
||||
if res.Error.Message != tools.Denied().Error.Message {
|
||||
t.Error("a renderer's refusal must be worded identically to every other refusal")
|
||||
}
|
||||
if spy.runs.Load() != 0 {
|
||||
t.Fatal("a refused write ran anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPanickingRendererDoesNotWrite(t *testing.T) {
|
||||
// A renderer is author-written code running before any approval exists. It
|
||||
// gets the same containment a handler does, and the failure direction is
|
||||
// closed: no description, no token, no write.
|
||||
spy := &spyWrite{panics: true}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
res := reg.Dispatch(context.Background(), caller("u1", "org1"), "assign_worker", json.RawMessage(`{}`))
|
||||
|
||||
if res.Error == nil {
|
||||
t.Fatal("a renderer that panicked should have produced an error result")
|
||||
}
|
||||
if res.Confirmation != nil {
|
||||
t.Fatal("a panicking renderer still issued a token")
|
||||
}
|
||||
if spy.runs.Load() != 0 {
|
||||
t.Fatal("a write ran after its renderer panicked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolsAreNotGated(t *testing.T) {
|
||||
// The gate applies to effects, not to every tool. A read that had to be
|
||||
// approved would teach people to approve without reading, which is how a
|
||||
// confirmation dialog stops being a control.
|
||||
reg := tools.NewRegistry()
|
||||
var ran atomic.Int64
|
||||
reg.MustRegister(tools.Tool{
|
||||
Name: "activity_breakdown", Description: "Read.", Effect: tools.EffectRead,
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
Handler: func(context.Context, tools.Context, json.RawMessage) tools.Result {
|
||||
ran.Add(1)
|
||||
return tools.OK(map[string]any{"ok": true})
|
||||
},
|
||||
})
|
||||
|
||||
res := reg.Dispatch(context.Background(), caller("u1", "org1"), "activity_breakdown", json.RawMessage(`{}`))
|
||||
if res.Error != nil || ran.Load() != 1 {
|
||||
t.Fatalf("a read should run unasked: err=%+v ran=%d", res.Error, ran.Load())
|
||||
}
|
||||
if res.Confirmation != nil {
|
||||
t.Error("a read raised a confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Redeeming ──────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestRedeemingAnApprovalPerformsExactlyWhatWasDescribed(t *testing.T) {
|
||||
// The path that makes a confirmation reliable rather than hopeful.
|
||||
//
|
||||
// Resolve asks "was THIS call approved?", which needs the caller to produce
|
||||
// the same call again. Redeem asks "what WAS approved?", so honouring an
|
||||
// approval does not depend on a model reproducing itself — which, against a
|
||||
// real model, it does not reliably do.
|
||||
var got json.RawMessage
|
||||
spy := &spyWrite{}
|
||||
tool := spy.tool()
|
||||
inner := tool.Handler
|
||||
tool.Handler = func(ctx context.Context, tc tools.Context, in json.RawMessage) tools.Result {
|
||||
got = in
|
||||
return inner(ctx, tc, in)
|
||||
}
|
||||
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(tool)
|
||||
|
||||
tc := caller("u1", "org1")
|
||||
args := `{"shift":"friday","worker":"maya"}`
|
||||
approved := ask(t, reg, tc, args)
|
||||
|
||||
// Nothing about the original call is supplied — only the token.
|
||||
out, ok := reg.DispatchApproved(context.Background(), caller("u1", "org1"), approved.Token)
|
||||
if !ok {
|
||||
t.Fatal("a valid token could not be redeemed")
|
||||
}
|
||||
if spy.runs.Load() != 1 {
|
||||
t.Fatalf("the write ran %d times, want 1", spy.runs.Load())
|
||||
}
|
||||
if out.Tool != "assign_worker" {
|
||||
t.Errorf("redeemed tool = %q", out.Tool)
|
||||
}
|
||||
// The arguments are the ones that were described, recovered from the token.
|
||||
var recovered map[string]string
|
||||
if err := json.Unmarshal(got, &recovered); err != nil {
|
||||
t.Fatalf("the replayed arguments were not JSON: %s", got)
|
||||
}
|
||||
if recovered["worker"] != "maya" || recovered["shift"] != "friday" {
|
||||
t.Errorf("replayed %v, want the approved call", recovered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARedeemedApprovalIsSpentOnce(t *testing.T) {
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
tc := caller("u1", "org1")
|
||||
approved := ask(t, reg, tc, `{"worker":"maya"}`)
|
||||
|
||||
if _, ok := reg.DispatchApproved(context.Background(), tc, approved.Token); !ok {
|
||||
t.Fatal("the first redemption failed")
|
||||
}
|
||||
if _, ok := reg.DispatchApproved(context.Background(), tc, approved.Token); ok {
|
||||
t.Fatal("a token was redeemed twice")
|
||||
}
|
||||
if spy.runs.Load() != 1 {
|
||||
t.Fatalf("one approval bought %d writes", spy.runs.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyThePersonWhoWasAskedCanRedeem(t *testing.T) {
|
||||
// A token is not a bearer credential. Redeem does not check the arguments —
|
||||
// it is the source of them — so the caller check is the only thing standing
|
||||
// between a leaked token and somebody else's write.
|
||||
spy := &spyWrite{}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
approved := ask(t, reg, caller("u1", "org1"), `{"worker":"maya"}`)
|
||||
|
||||
for name, other := range map[string]tools.Context{
|
||||
"a different person": caller("u2", "org1"),
|
||||
"a different tenant": caller("u1", "org2"),
|
||||
} {
|
||||
if _, ok := reg.DispatchApproved(context.Background(), other, approved.Token); ok {
|
||||
t.Errorf("%s redeemed an approval that was not theirs", name)
|
||||
}
|
||||
}
|
||||
if spy.runs.Load() != 0 {
|
||||
t.Fatalf("%d writes happened for callers who never approved anything", spy.runs.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExpiredApprovalCannotBeRedeemed(t *testing.T) {
|
||||
spy := &spyWrite{expiry: time.Now().Add(-time.Minute)}
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(spy.tool())
|
||||
|
||||
tc := caller("u1", "org1")
|
||||
approved := ask(t, reg, tc, `{"worker":"maya"}`)
|
||||
|
||||
if _, ok := reg.DispatchApproved(context.Background(), tc, approved.Token); ok {
|
||||
t.Fatal("an expired approval was redeemed")
|
||||
}
|
||||
if spy.runs.Load() != 0 {
|
||||
t.Fatal("an expired approval produced a write")
|
||||
}
|
||||
}
|
||||
36
go-api/internal/tools/dump_test.go
Normal file
36
go-api/internal/tools/dump_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// TestDumpToolOutput prints what each tool actually returns against the seeded
|
||||
// demo tenant. Run with -v when checking that a port produces real figures
|
||||
// rather than a silent zero.
|
||||
func TestDumpToolOutput(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
admin := authctx.Identity{
|
||||
UserID: "00000000-0000-0000-0000-000000000001",
|
||||
OrgID: h.OrgID, Role: "admin", Email: "admin@example.test",
|
||||
}
|
||||
reg := tools.NewRegistry()
|
||||
for _, tool := range everyTool(h.Pool) {
|
||||
reg.MustRegister(tool)
|
||||
}
|
||||
for _, name := range reg.Names() {
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: admin}, name, json.RawMessage(`{"limit":3}`))
|
||||
encoded, _ := json.Marshal(res.Data)
|
||||
out := string(encoded)
|
||||
if len(out) > 400 {
|
||||
out = out[:400] + "…"
|
||||
}
|
||||
t.Logf("%-24s %s", name, out)
|
||||
}
|
||||
}
|
||||
502
go-api/internal/tools/hiring.go
Normal file
502
go-api/internal/tools/hiring.go
Normal file
@@ -0,0 +1,502 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// The hiring tools: pipeline quality, recent hires, hire performance, roles at
|
||||
// risk, and the talent pool.
|
||||
//
|
||||
// Each reads one resource and goes through authorize(), so the talent scopes
|
||||
// differ meaningfully between them and are not restated here: applications
|
||||
// scope by the caller's email, postings scope to active roles only, worker
|
||||
// profiles scope by user id. That is the policy table's business, and the whole
|
||||
// reason these handlers are short.
|
||||
|
||||
/* ── Candidate quality ──────────────────────────────────────────────────── */
|
||||
|
||||
// CandidatesQuality reports the applicant pipeline and how strong it is.
|
||||
func CandidatesQuality(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "candidates_quality",
|
||||
Description: "Read the applicant pipeline: how many applications are at each stage, " +
|
||||
"the average AI match score, how many are strong versus weak, and how many are " +
|
||||
"waiting to be screened. Use for questions about candidate quality, pipeline " +
|
||||
"health, and whether there is a screening backlog.",
|
||||
InputSchema: periodSchema("How many stages to list. Defaults to all."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorize(tc, "job-applications")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
in, from, to, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
if !from.IsZero() {
|
||||
q.gte("created_date", from)
|
||||
q.lt("created_date", to)
|
||||
}
|
||||
|
||||
// ai_score 0 is the absence of a score, not a score of zero — the
|
||||
// same rule the product states in candidateIntelligence.js ("null
|
||||
// rather than zeros ... so an unscreened candidate shows '—' instead
|
||||
// of a confident-looking 0"). Counting zeros as scores reported 16
|
||||
// weak candidates averaging 28 where the truth was 1 weak and 76.
|
||||
var (
|
||||
total, strong, weak, unscreened, scored int64
|
||||
avgScore *float64
|
||||
)
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE ai_score >= 80),
|
||||
count(*) FILTER (WHERE ai_score > 0 AND ai_score < 50),
|
||||
count(*) FILTER (WHERE status = 'applied'),
|
||||
count(*) FILTER (WHERE ai_score > 0),
|
||||
avg(ai_score) FILTER (WHERE ai_score > 0)
|
||||
FROM job_applications
|
||||
WHERE `+q.clause(), q.args...,
|
||||
).Scan(&total, &strong, &weak, &unscreened, &scored, &avgScore)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the applications could not be read")
|
||||
}
|
||||
|
||||
stages, err := groupCount(ctx, db, "job_applications", "status::text", q, in.limitOr(20))
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the applications could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"period": periodOrAll(in.Period),
|
||||
"applications": total,
|
||||
"stages": stages,
|
||||
"strong": strong,
|
||||
"weak": weak,
|
||||
"unscreened": unscreened,
|
||||
"scored": scored,
|
||||
}
|
||||
// The average is over the scored ones only, so say how many that is.
|
||||
if avgScore != nil {
|
||||
data["averageMatchScore"] = int(*avgScore + 0.5)
|
||||
data["averageMatchScoreBasis"] = scored
|
||||
}
|
||||
if total == 0 {
|
||||
data["note"] = "No applications match that. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Recent hires ───────────────────────────────────────────────────────── */
|
||||
|
||||
// HiresRecent lists who was hired and for what.
|
||||
func HiresRecent(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "hires_recent",
|
||||
Description: "List recent hires: who was hired, for which role, their match score " +
|
||||
"and when. Use for questions about who has joined, hiring volume, and what has " +
|
||||
"been filled recently.",
|
||||
InputSchema: periodSchema("How many hires to list, most recent first. Defaults to 20."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorize(tc, "job-applications")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
in, from, to, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
// A hire is an application that reached one of the two terminal
|
||||
// positive states. `assigned` counts: a worker placed on an
|
||||
// assignment was hired, whatever the row was last labelled.
|
||||
q.raw("status IN ('hired', 'assigned')")
|
||||
if !from.IsZero() {
|
||||
q.gte("created_date", from)
|
||||
q.lt("created_date", to)
|
||||
}
|
||||
|
||||
args := append(append([]any{}, q.args...), in.limitOr(20))
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT applicant_name, coalesce(nullif(job_title, ''), 'unspecified'),
|
||||
nullif(ai_score, 0), created_date
|
||||
FROM job_applications
|
||||
WHERE `+q.clause()+`
|
||||
ORDER BY created_date DESC
|
||||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the applications could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type hire struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Score *int `json:"matchScore,omitempty"`
|
||||
When time.Time `json:"hiredOn"`
|
||||
}
|
||||
var hires []hire
|
||||
for rows.Next() {
|
||||
var h hire
|
||||
if err := rows.Scan(&h.Name, &h.Role, &h.Score, &h.When); err != nil {
|
||||
return Failf(CodeFailed, "the applications could not be read")
|
||||
}
|
||||
hires = append(hires, h)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the applications could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"period": periodOrAll(in.Period),
|
||||
"hires": hires,
|
||||
// Named for what it is. "count" would read as "hires in this
|
||||
// period", which it is not once a limit is applied.
|
||||
"listed": len(hires),
|
||||
}
|
||||
if len(hires) == 0 {
|
||||
data["note"] = "No hires match that. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Hire performance ───────────────────────────────────────────────────── */
|
||||
|
||||
// HiresPerformance reports how hired workers are performing since joining.
|
||||
func HiresPerformance(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "hires_performance",
|
||||
Description: "Read how hired workers are performing: average Krow score, " +
|
||||
"reliability, attendance and client rating across the workforce, plus the " +
|
||||
"strongest and weakest performers. Use for questions about whether hires are " +
|
||||
"working out and who needs support.",
|
||||
InputSchema: periodSchema("How many workers to list at each end. Defaults to 5."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorize(tc, "worker-profiles")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
in, _, _, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
|
||||
// Every one of these is 0 for a worker nobody has rated yet — the
|
||||
// product renders that as "Not yet scored" (dataResolver.js) and its
|
||||
// lowest band starts above 0 (TalentPool.jsx). Averaging the zeros in
|
||||
// reported a 1.6-of-5 client rating for a workforce rated 4.7.
|
||||
var (
|
||||
total, scored int64
|
||||
krow, reliability, attendance, perf, ratings *float64
|
||||
)
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE krow_score > 0),
|
||||
avg(krow_score) FILTER (WHERE krow_score > 0),
|
||||
avg(reliability_score) FILTER (WHERE reliability_score > 0),
|
||||
avg(attendance_score) FILTER (WHERE attendance_score > 0),
|
||||
avg(performance_score) FILTER (WHERE performance_score > 0),
|
||||
avg(client_rating) FILTER (WHERE client_rating > 0)
|
||||
FROM worker_profiles
|
||||
WHERE `+q.clause(), q.args...,
|
||||
).Scan(&total, &scored, &krow, &reliability, &attendance, &perf, &ratings)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
|
||||
top, err := workersByScore(ctx, db, q, in.limitOr(5), "DESC")
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
bottom, err := workersByScore(ctx, db, q, in.limitOr(5), "ASC")
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"workers": total,
|
||||
"scored": scored,
|
||||
"unscored": total - scored,
|
||||
"strongest": top,
|
||||
"weakest": bottom,
|
||||
}
|
||||
putAvg(data, "averageKrowScore", krow)
|
||||
putAvg(data, "averageReliability", reliability)
|
||||
putAvg(data, "averageAttendance", attendance)
|
||||
putAvg(data, "averagePerformance", perf)
|
||||
if ratings != nil {
|
||||
data["averageClientRating"] = round1(*ratings)
|
||||
}
|
||||
if total == 0 {
|
||||
data["note"] = "No worker profiles are visible. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type scoredWorker struct {
|
||||
Name string `json:"name"`
|
||||
KrowScore *int `json:"krowScore,omitempty"`
|
||||
Reliability *int `json:"reliability,omitempty"`
|
||||
Attendance *int `json:"attendance,omitempty"`
|
||||
}
|
||||
|
||||
func workersByScore(ctx context.Context, db repo.Querier, q *query, limit int, dir string) ([]scoredWorker, error) {
|
||||
// `dir` is never caller input — it is one of two literals chosen here, so
|
||||
// there is no path by which an identifier reaches the statement from
|
||||
// outside this file.
|
||||
if dir != "ASC" {
|
||||
dir = "DESC"
|
||||
}
|
||||
args := append(append([]any{}, q.args...), limit)
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT full_name, nullif(krow_score, 0), nullif(reliability_score, 0),
|
||||
nullif(attendance_score, 0)
|
||||
FROM worker_profiles
|
||||
WHERE `+q.clause()+` AND krow_score > 0
|
||||
ORDER BY krow_score `+dir+`, full_name ASC
|
||||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []scoredWorker
|
||||
for rows.Next() {
|
||||
var w scoredWorker
|
||||
if err := rows.Scan(&w.Name, &w.KrowScore, &w.Reliability, &w.Attendance); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
/* ── Positions at risk ──────────────────────────────────────────────────── */
|
||||
|
||||
// PositionsRisk reports roles that are struggling to fill.
|
||||
func PositionsRisk(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "positions_risk",
|
||||
Description: "Read which open roles are at risk: how many applicants each has, " +
|
||||
"how many are strong, how long each has been open, and its priority. Use for " +
|
||||
"questions about roles that are hard to fill, urgent openings, and where " +
|
||||
"attention is needed.",
|
||||
InputSchema: periodSchema("How many roles to list, most at risk first. Defaults to 10."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorizeAs(tc, "job-postings", "p")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
in, _, _, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
q.raw("p.status = 'active'")
|
||||
|
||||
// Counted before the limit is applied. Reporting len(roles) here
|
||||
// made "how many roles are open" mean "how many I chose to show",
|
||||
// so workspace_summary and this tool disagreed about the same
|
||||
// number on the same data — the exact contradiction a reader would
|
||||
// catch and a model would not.
|
||||
var openRoles int64
|
||||
if err := db.QueryRow(ctx,
|
||||
`SELECT count(*) FROM job_postings p WHERE `+q.clause(), q.args...).Scan(&openRoles); err != nil {
|
||||
return Failf(CodeFailed, "the job postings could not be read")
|
||||
}
|
||||
|
||||
// The applicant counts are a correlated subquery rather than a join
|
||||
// plus a Go-side tally: counting in SQL keeps the count behind the
|
||||
// same predicate as the row it belongs to.
|
||||
args := append(append([]any{}, q.args...), in.limitOr(10))
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT p.title, p.priority::text, p.headcount, p.created_date,
|
||||
(SELECT count(*) FROM job_applications a
|
||||
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id),
|
||||
(SELECT count(*) FROM job_applications a
|
||||
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id AND a.ai_score >= 80)
|
||||
FROM job_postings p
|
||||
WHERE `+q.clause()+`
|
||||
ORDER BY p.priority ASC, p.created_date ASC
|
||||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the job postings could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type role struct {
|
||||
Title string `json:"title"`
|
||||
Priority string `json:"priority"`
|
||||
Headcount *int `json:"headcount,omitempty"`
|
||||
DaysOpen int `json:"daysOpen"`
|
||||
Applicants int64 `json:"applicants"`
|
||||
Strong int64 `json:"strongApplicants"`
|
||||
Risk string `json:"risk"`
|
||||
}
|
||||
var roles []role
|
||||
now := time.Now()
|
||||
for rows.Next() {
|
||||
var r role
|
||||
var created time.Time
|
||||
if err := rows.Scan(&r.Title, &r.Priority, &r.Headcount, &created, &r.Applicants, &r.Strong); err != nil {
|
||||
return Failf(CodeFailed, "the job postings could not be read")
|
||||
}
|
||||
r.DaysOpen = int(now.Sub(created).Hours() / 24)
|
||||
r.Risk = riskFor(r.Strong, r.DaysOpen, r.Priority)
|
||||
roles = append(roles, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the job postings could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{"openRoles": openRoles, "roles": roles}
|
||||
if int64(len(roles)) < openRoles {
|
||||
data["omittedRoles"] = openRoles - int64(len(roles))
|
||||
}
|
||||
if openRoles == 0 {
|
||||
data["note"] = "No roles are open. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// riskFor labels a role.
|
||||
//
|
||||
// Stated as a rule rather than left to the model, because it is a judgement the
|
||||
// product makes consistently — two agents describing the same role differently
|
||||
// is worse than a label that is sometimes debatable. The model is free to
|
||||
// disagree in prose; the label is what makes lists sortable.
|
||||
func riskFor(strong int64, daysOpen int, priority string) string {
|
||||
switch {
|
||||
case strong == 0 && daysOpen > 14:
|
||||
return "high"
|
||||
case strong == 0 || (priority == "urgent" && strong < 2):
|
||||
return "medium"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Talent pool ────────────────────────────────────────────────────────── */
|
||||
|
||||
// TalentPool reports the bench: who is available and how ready.
|
||||
func TalentPool(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "talent_pool",
|
||||
Description: "Read the talent pool: how many workers are on the bench, their " +
|
||||
"readiness by score band, average experience, and the most job-ready. Use for " +
|
||||
"questions about available talent, bench depth, and who could be placed.",
|
||||
InputSchema: periodSchema("How many workers to list, most ready first. Defaults to 10."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorize(tc, "worker-profiles")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
in, _, _, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
|
||||
var (
|
||||
total, ready, developing, early, unscored int64
|
||||
experienced int64
|
||||
avgExperience *float64
|
||||
)
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE krow_score >= 80),
|
||||
count(*) FILTER (WHERE krow_score >= 60 AND krow_score < 80),
|
||||
count(*) FILTER (WHERE krow_score > 0 AND krow_score < 60),
|
||||
count(*) FILTER (WHERE krow_score = 0),
|
||||
count(*) FILTER (WHERE experience_years > 0),
|
||||
avg(experience_years) FILTER (WHERE experience_years > 0)
|
||||
FROM worker_profiles
|
||||
WHERE `+q.clause(), q.args...,
|
||||
).Scan(&total, &ready, &developing, &early, &unscored, &experienced, &avgExperience)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
|
||||
top, err := workersByScore(ctx, db, q, in.limitOr(10), "DESC")
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"workers": total,
|
||||
"jobReady": ready,
|
||||
"developing": developing,
|
||||
"early": early,
|
||||
"unscored": unscored,
|
||||
"mostReady": top,
|
||||
}
|
||||
// Averaged over the workers who state any experience, so say so
|
||||
// rather than letting an unstated 0 read as a first-year worker.
|
||||
if avgExperience != nil {
|
||||
data["averageExperienceYears"] = round1(*avgExperience)
|
||||
data["averageExperienceBasis"] = experienced
|
||||
}
|
||||
if total == 0 {
|
||||
data["note"] = "The talent pool is empty. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Shared helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
type counted struct {
|
||||
Value string `json:"value"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// groupCount is `GROUP BY one column` behind the caller's predicate.
|
||||
//
|
||||
// The column name is supplied by this package, never by input — every call site
|
||||
// below passes a literal.
|
||||
func groupCount(ctx context.Context, db repo.Querier, table, column string, q *query, limit int) ([]counted, error) {
|
||||
args := append(append([]any{}, q.args...), limit)
|
||||
rows, err := db.Query(ctx,
|
||||
`SELECT `+column+`, count(*) FROM `+table+` WHERE `+q.clause()+
|
||||
` GROUP BY 1 ORDER BY count(*) DESC, 1 ASC LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []counted
|
||||
for rows.Next() {
|
||||
var c counted
|
||||
if err := rows.Scan(&c.Value, &c.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func putAvg(data map[string]any, key string, v *float64) {
|
||||
if v != nil {
|
||||
data[key] = int(*v + 0.5)
|
||||
}
|
||||
}
|
||||
246
go-api/internal/tools/hiring_test.go
Normal file
246
go-api/internal/tools/hiring_test.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// An ai_score of 0 is the absence of a score, not a score of zero. The product
|
||||
// states this rule in candidateIntelligence.js: "null rather than zeros ... so
|
||||
// an unscreened candidate shows '—' instead of a confident-looking 0".
|
||||
//
|
||||
// candidates_quality shipped without a test and broke the rule three ways: it
|
||||
// counted every unscored candidate as weak, dragged the average down with their
|
||||
// zeros, and offered no way to tell how many the average was over. On the real
|
||||
// corpus that reported "16 weak, averaging 28" where the truth was "1 weak, 9
|
||||
// scored, averaging 76" — a number that would push a manager to reject a pool
|
||||
// that is in fact strong.
|
||||
func TestCandidateQualityTreatsAnAbsentScoreAsAbsentNotAsZero(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
f := seedFunnel(t, h, "quality-zero") // Dana: ai_screened, scored 88
|
||||
reg := liveRegistry(t, h)
|
||||
ctx := context.Background()
|
||||
|
||||
// Three more, sharing Dana's org and posting.
|
||||
for _, c := range []struct {
|
||||
name, email, status string
|
||||
score int
|
||||
}{
|
||||
{"Unscored Applicant", "u1-quality@example.test", "applied", 0},
|
||||
{"Advanced Unscored", "u2-quality@example.test", "interview", 0},
|
||||
{"Genuinely Weak", "u3-quality@example.test", "ai_screened", 30},
|
||||
} {
|
||||
if _, err := h.Pool.Exec(ctx, `
|
||||
INSERT INTO job_applications (org_id, job_posting_id, applicant_name, email, status, ai_score)
|
||||
SELECT org_id, job_posting_id, $2, $3, $4::application_status, $5
|
||||
FROM job_applications WHERE id = $1::uuid`,
|
||||
f.appID, c.name, c.email, c.status, c.score); err != nil {
|
||||
t.Fatalf("seed %s: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
res := reg.Dispatch(ctx,
|
||||
tools.Context{Principal: f.admin, RunID: "run_q1"},
|
||||
"candidates_quality", json.RawMessage(`{}`))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("candidates_quality failed: %+v", res.Error)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Applications int `json:"applications"`
|
||||
Strong int `json:"strong"`
|
||||
Weak int `json:"weak"`
|
||||
Unscreened int `json:"unscreened"`
|
||||
Scored int `json:"scored"`
|
||||
Average int `json:"averageMatchScore"`
|
||||
Basis int `json:"averageMatchScoreBasis"`
|
||||
}
|
||||
body, _ := json.Marshal(res.Data)
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode %s: %v", body, err)
|
||||
}
|
||||
|
||||
// Only the 30 is weak. The two zeros are unscored, not bad.
|
||||
for _, c := range []struct {
|
||||
field string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{"applications", got.Applications, 4},
|
||||
{"strong", got.Strong, 1}, // 88
|
||||
{"weak", got.Weak, 1}, // 30 only — not the two zeros
|
||||
{"unscreened", got.Unscreened, 1}, // the one at 'applied'
|
||||
{"scored", got.Scored, 2}, // 88 and 30
|
||||
{"averageMatchScore", got.Average, 59}, // (88+30)/2, not (88+30+0+0)/4
|
||||
{"averageMatchScoreBasis", got.Basis, 2},
|
||||
} {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %d, want %d — full payload: %s", c.field, c.got, c.want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedRatedWorkers creates an org with workers at the given krow scores. A score
|
||||
// of 0 is how the product records "not yet rated" — worker_profiles.krow_score is
|
||||
// NOT NULL, so there is no null to distinguish it, which is exactly the trap.
|
||||
func seedRatedWorkers(t *testing.T, h *testutil.Harness, slug string,
|
||||
workers []struct {
|
||||
name string
|
||||
krow int
|
||||
rated float64
|
||||
}) (string, authctx.Identity) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
org := freshOrg(t, h, slug)
|
||||
boss := fmt.Sprintf("boss-%s@example.test", slug)
|
||||
admin := authctx.Identity{
|
||||
UserID: seedUser(t, h, org, boss, "admin"),
|
||||
OrgID: org, Role: "admin", Email: boss,
|
||||
}
|
||||
for i, w := range workers {
|
||||
if _, err := h.Pool.Exec(ctx, `
|
||||
INSERT INTO worker_profiles
|
||||
(org_id, full_name, email, krow_score, reliability_score,
|
||||
attendance_score, performance_score, client_rating)
|
||||
VALUES ($1::uuid, $2, $3, $4, $4, $4, $4, $5)`,
|
||||
org, w.name, fmt.Sprintf("w%d-%s@example.test", i, slug), w.krow, w.rated); err != nil {
|
||||
t.Fatalf("seed worker %s: %v", w.name, err)
|
||||
}
|
||||
}
|
||||
return org, admin
|
||||
}
|
||||
|
||||
// A krow_score of 0 means "Not yet scored" — the product says so in as many
|
||||
// words (dataResolver.js) and its lowest band starts above 0 (TalentPool.jsx).
|
||||
// Reading it as a score of zero made hires_performance report the four unrated
|
||||
// workers as the four *weakest* performers by name, and dragged every average
|
||||
// down with them: a workforce rated 4.7 of 5 was reported at 1.6.
|
||||
func TestPerformanceNeverNamesAnUnratedWorkerAsWeakest(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
org, admin := seedRatedWorkers(t, h, "perf-unrated", []struct {
|
||||
name string
|
||||
krow int
|
||||
rated float64
|
||||
}{
|
||||
{"Scored High", 90, 5},
|
||||
{"Scored Low", 50, 4},
|
||||
{"Never Rated", 0, 0},
|
||||
{"Also Never Rated", 0, 0},
|
||||
})
|
||||
_ = org
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: admin, RunID: "run_p1"},
|
||||
"hires_performance", json.RawMessage(`{}`))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("hires_performance failed: %+v", res.Error)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Workers int `json:"workers"`
|
||||
Scored int `json:"scored"`
|
||||
Unscored int `json:"unscored"`
|
||||
AvgKrow int `json:"averageKrowScore"`
|
||||
AvgRated float64 `json:"averageClientRating"`
|
||||
Weakest []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"weakest"`
|
||||
Strongest []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"strongest"`
|
||||
}
|
||||
body, _ := json.Marshal(res.Data)
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode %s: %v", body, err)
|
||||
}
|
||||
|
||||
// The headline the operator acts on.
|
||||
for _, c := range []struct {
|
||||
field string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{"workers", got.Workers, 4},
|
||||
{"scored", got.Scored, 2},
|
||||
{"unscored", got.Unscored, 2},
|
||||
{"averageKrowScore", got.AvgKrow, 70}, // (90+50)/2, not (90+50+0+0)/4 = 35
|
||||
} {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %d, want %d — payload: %s", c.field, c.got, c.want, body)
|
||||
}
|
||||
}
|
||||
if got.AvgRated != 4.5 { // (5+4)/2, not (5+4+0+0)/4 = 2.25
|
||||
t.Errorf("averageClientRating = %v, want 4.5 — payload: %s", got.AvgRated, body)
|
||||
}
|
||||
|
||||
// The part that names real people.
|
||||
for _, w := range append(append([]struct {
|
||||
Name string `json:"name"`
|
||||
}{}, got.Weakest...), got.Strongest...) {
|
||||
if w.Name == "Never Rated" || w.Name == "Also Never Rated" {
|
||||
t.Errorf("%q has no rating but was ranked by score — payload: %s", w.Name, body)
|
||||
}
|
||||
}
|
||||
if len(got.Weakest) == 0 || got.Weakest[0].Name != "Scored Low" {
|
||||
t.Errorf("weakest should start at the lowest *rated* worker — payload: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TalentPool.jsx puts the lowest band at (krow_score || 0) > 0 && < 60, so an
|
||||
// unrated worker is counted separately rather than as the least ready.
|
||||
func TestTalentPoolCountsUnratedSeparatelyFromLowScoring(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
_, admin := seedRatedWorkers(t, h, "pool-bands", []struct {
|
||||
name string
|
||||
krow int
|
||||
rated float64
|
||||
}{
|
||||
{"Ready", 90, 5},
|
||||
{"Developing", 70, 4},
|
||||
{"Early", 30, 3},
|
||||
{"Unrated", 0, 0},
|
||||
})
|
||||
reg := liveRegistry(t, h)
|
||||
|
||||
res := reg.Dispatch(context.Background(),
|
||||
tools.Context{Principal: admin, RunID: "run_p2"},
|
||||
"talent_pool", json.RawMessage(`{}`))
|
||||
if res.Error != nil {
|
||||
t.Fatalf("talent_pool failed: %+v", res.Error)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Workers int `json:"workers"`
|
||||
JobReady int `json:"jobReady"`
|
||||
Developing int `json:"developing"`
|
||||
Early int `json:"early"`
|
||||
Unscored int `json:"unscored"`
|
||||
}
|
||||
body, _ := json.Marshal(res.Data)
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode %s: %v", body, err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
field string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{"workers", got.Workers, 4},
|
||||
{"jobReady", got.JobReady, 1},
|
||||
{"developing", got.Developing, 1},
|
||||
{"early", got.Early, 1}, // the 30 only — not the unrated worker
|
||||
{"unscored", got.Unscored, 1},
|
||||
} {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %d, want %d — payload: %s", c.field, c.got, c.want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
162
go-api/internal/tools/knowledge.go
Normal file
162
go-api/internal/tools/knowledge.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/knowledge"
|
||||
)
|
||||
|
||||
// knowledge_search: retrieval the model can drive.
|
||||
//
|
||||
// The loop already retrieves once, for the caller's opening question, and puts
|
||||
// the result in a <context> block. That covers the common case and covers it
|
||||
// cheaply — but it is one shot at one phrasing, and the phrasing is the user's.
|
||||
// A question like "am I allowed to leave early on Fridays?" retrieves a
|
||||
// paragraph about early departure and misses the one about shift-swap approvals
|
||||
// that actually answers it.
|
||||
//
|
||||
// So the model gets a second bite: it may search again, in its own words, once
|
||||
// it knows what it is looking for. That is worth a tool.
|
||||
//
|
||||
// What it emphatically does NOT get is a widening of scope. The sources come
|
||||
// from the agent's spec by way of ctx.KnowledgeSources, exactly as the
|
||||
// principal does; there is no `source` argument, because an argument is
|
||||
// something a model can choose and the set of corpora an agent may read is not
|
||||
// the model's to choose. All the model controls is the words.
|
||||
|
||||
type knowledgeSearchInput struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// KnowledgeSearch builds the search tool.
|
||||
//
|
||||
// One registered tool, not one per agent: the per-agent part is the source
|
||||
// list, and that travels on the Context beside the principal rather than being
|
||||
// closed over. Both are set by the loop from records the conversation cannot
|
||||
// touch, so two agents sharing this tool still cannot read each other's
|
||||
// corpora — and the registry stays a flat set of names, which is what §3's
|
||||
// publish-time validation resolves against.
|
||||
func KnowledgeSearch(r *knowledge.Retriever) Tool {
|
||||
return Tool{
|
||||
Name: "knowledge_search",
|
||||
Description: "Search the documents this agent has access to and get back passages " +
|
||||
"with source ids. Use it when the answer depends on what a written policy, " +
|
||||
"handbook or guide actually says — and search again in your own words if the " +
|
||||
"first passages are close but not quite right. Cite the source id of anything " +
|
||||
"you rely on.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "What to look for. Write it as the words you would expect to " +
|
||||
"find in the document, not as a question to a person.",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer", "minimum": 1, "maximum": 20,
|
||||
"description": "How many passages to return. Defaults to 8.",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
if r == nil {
|
||||
return Failf(CodeUnavailable, "there are no documents to search")
|
||||
}
|
||||
if len(tc.KnowledgeSources) == 0 {
|
||||
// This agent's spec named no knowledge. Refused rather than
|
||||
// widened: an empty source list is not permission to read
|
||||
// everything, and the tool should not have been offered.
|
||||
return Failf(CodeUnavailable, "this agent has no documents to search")
|
||||
}
|
||||
|
||||
var in knowledgeSearchInput
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
}
|
||||
if strings.TrimSpace(in.Query) == "" {
|
||||
return Failf(CodeInvalidInput, "a search needs something to search for")
|
||||
}
|
||||
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = knowledge.DefaultK
|
||||
}
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// The principal is the caller's. Not the agent's, not a service
|
||||
// account, and not anything the model supplied — I1 lives in this
|
||||
// one line as much as anywhere in the package.
|
||||
res, err := r.Retrieve(ctx, knowledge.Query{
|
||||
Text: in.Query,
|
||||
Principal: tc.Principal,
|
||||
Sources: tc.KnowledgeSources,
|
||||
K: limit,
|
||||
})
|
||||
if err != nil {
|
||||
var kErr *knowledge.Error
|
||||
if ok := asKnowledge(err, &kErr); ok && kErr.Code == knowledge.ErrNoPrincipal {
|
||||
// A caller retrieval will not serve is refused the same way
|
||||
// every other resource refuses one. Saying "you have no
|
||||
// tenant" would be a more useful error and a worse one.
|
||||
return Denied()
|
||||
}
|
||||
return Failf(CodeFailed, "the documents could not be searched")
|
||||
}
|
||||
|
||||
passages := make([]map[string]any, 0, len(res.Chunks))
|
||||
for _, c := range res.Chunks {
|
||||
p := map[string]any{
|
||||
// The id first, because citing it is the point. §5: a claim
|
||||
// without a retrievable citation is inference, not grounded
|
||||
// fact, and the model can only tell them apart if every
|
||||
// passage arrived with an address.
|
||||
"sourceId": c.ChunkID,
|
||||
"title": c.Title,
|
||||
"text": c.Text,
|
||||
}
|
||||
if c.Heading != "" {
|
||||
p["section"] = c.Heading
|
||||
}
|
||||
if c.URI != "" {
|
||||
p["uri"] = c.URI
|
||||
}
|
||||
passages = append(passages, p)
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"query": in.Query,
|
||||
"passages": passages,
|
||||
"count": len(passages),
|
||||
}
|
||||
if len(passages) == 0 {
|
||||
data["note"] = "Nothing in these documents matched. This is a real answer, " +
|
||||
"not a failure to look — say so rather than answering from general knowledge."
|
||||
}
|
||||
if res.DenseSkipped != "" {
|
||||
// Handed to the model because it changes what an absence means.
|
||||
// "I found nothing" is a weaker claim when half the index was
|
||||
// not searched, and the model should be able to say which.
|
||||
data["degraded"] = res.DenseSkipped
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// asKnowledge is errors.As for the knowledge package's error type.
|
||||
func asKnowledge(err error, target **knowledge.Error) bool {
|
||||
if e, ok := err.(*knowledge.Error); ok {
|
||||
*target = e
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
465
go-api/internal/tools/registry.go
Normal file
465
go-api/internal/tools/registry.go
Normal file
@@ -0,0 +1,465 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxToolsPerAgent is §8's cap.
|
||||
//
|
||||
// Not a technical limit. Past roughly twenty tools a model's choice degrades
|
||||
// faster than the extra capability helps, and an agent that needs more is an
|
||||
// agent that should be a parent with subagents. Enforced at resolution so a
|
||||
// spec cannot quietly exceed it.
|
||||
const MaxToolsPerAgent = 20
|
||||
|
||||
// Registry holds every tool this service can run.
|
||||
//
|
||||
// One registry, not one per agent: a spec selects from it by name, and a name
|
||||
// that resolves to nothing fails at publish rather than at run time. That is
|
||||
// the same rule the frontend registry already applies to skills.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
tools map[string]Tool
|
||||
|
||||
// confirmations is where a pending write waits for a person. Held by the
|
||||
// registry rather than passed to Dispatch so that no call site can supply
|
||||
// its own — a store is the thing that says a write was approved, and a
|
||||
// caller able to swap it is a caller able to approve on the user's behalf.
|
||||
confirmations Store
|
||||
}
|
||||
|
||||
// NewRegistry builds an empty registry with an in-process confirmation store.
|
||||
//
|
||||
// Fine for one instance and for tests. A deployment with more than one replica
|
||||
// must pass a shared store — see NewRegistryWithStore and the note on
|
||||
// MemoryStore.
|
||||
func NewRegistry() *Registry {
|
||||
return NewRegistryWithStore(NewMemoryStore())
|
||||
}
|
||||
|
||||
// NewRegistryWithStore builds a registry over a specific confirmation store.
|
||||
func NewRegistryWithStore(s Store) *Registry {
|
||||
if s == nil {
|
||||
s = NewMemoryStore()
|
||||
}
|
||||
return &Registry{tools: make(map[string]Tool), confirmations: s}
|
||||
}
|
||||
|
||||
// Register adds a tool, or returns why it cannot be added.
|
||||
//
|
||||
// Called at startup, so a malformed tool is a boot failure rather than a
|
||||
// surprise on the first run that reaches it.
|
||||
func (r *Registry) Register(t Tool) error {
|
||||
if t.Name == "" {
|
||||
return fmt.Errorf("tools: a tool needs a name")
|
||||
}
|
||||
if t.Handler == nil {
|
||||
return fmt.Errorf("tools: %s has no handler", t.Name)
|
||||
}
|
||||
if t.Description == "" {
|
||||
// The description is what the model reads instead of documentation. A
|
||||
// tool without one is a tool that will be called wrongly.
|
||||
return fmt.Errorf("tools: %s has no description", t.Name)
|
||||
}
|
||||
switch t.Effect {
|
||||
case EffectRead:
|
||||
case EffectWrite:
|
||||
// I4, and the reason RequiresConfirmation is not left to the author:
|
||||
// a write that forgot to set it would run unconfirmed forever, and
|
||||
// nothing downstream could tell it apart from a deliberate choice.
|
||||
t.RequiresConfirmation = true
|
||||
default:
|
||||
return fmt.Errorf("tools: %s declares effect %q, want read or write", t.Name, t.Effect)
|
||||
}
|
||||
// §8 step 3. A write with no renderer could only ever be confirmed by
|
||||
// showing someone its raw arguments, and nobody can meaningfully approve
|
||||
// a pair of uuids. Refused at registration, so it is a boot failure rather
|
||||
// than a bad dialog discovered in production.
|
||||
if t.RequiresConfirmation && t.Confirm == nil {
|
||||
return fmt.Errorf(
|
||||
"tools: %s needs confirmation but has no Confirm renderer; "+
|
||||
"a write must be able to say in plain language what it will do", t.Name)
|
||||
}
|
||||
if t.Effect == EffectRead && t.Confirm != nil && !t.RequiresConfirmation {
|
||||
// A renderer that will never run is a renderer nobody maintains, and
|
||||
// the day the tool becomes a write it will be wrong.
|
||||
return fmt.Errorf("tools: %s has a Confirm renderer but never asks for confirmation", t.Name)
|
||||
}
|
||||
if t.MaxResultBytes <= 0 {
|
||||
t.MaxResultBytes = DefaultMaxResultBytes
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, exists := r.tools[t.Name]; exists {
|
||||
return fmt.Errorf("tools: %s is already registered", t.Name)
|
||||
}
|
||||
r.tools[t.Name] = t
|
||||
return nil
|
||||
}
|
||||
|
||||
// MustRegister adds a tool or panics. For startup wiring, where the alternative
|
||||
// to a panic is a service that boots without a capability it claims to have.
|
||||
func (r *Registry) MustRegister(t Tool) {
|
||||
if err := r.Register(t); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a tool by name.
|
||||
func (r *Registry) Get(name string) (Tool, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
t, ok := r.tools[name]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// Names lists every registered tool, sorted.
|
||||
//
|
||||
// Sorted because this list is rendered into the prompt, and the prompt is a
|
||||
// cache prefix: an unstable order would invalidate the cache on every request
|
||||
// for no reason at all.
|
||||
func (r *Registry) Names() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(r.tools))
|
||||
for name := range r.tools {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// Resolve turns a spec's tool names into tools.
|
||||
//
|
||||
// Unknown names are returned rather than dropped: §3 says a spec naming a tool
|
||||
// that does not exist fails validation at publish, and this is the function
|
||||
// that lets publish say which one. At run time the caller decides — dropping a
|
||||
// missing tool is better than refusing the run, but only if someone is told.
|
||||
func (r *Registry) Resolve(names []string) (resolved []Tool, unknown []string, err error) {
|
||||
if len(names) > MaxToolsPerAgent {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"tools: %d tools requested, the cap is %d — split this agent into a parent with subagents",
|
||||
len(names), MaxToolsPerAgent)
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
seen := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
t, ok := r.tools[name]
|
||||
if !ok {
|
||||
unknown = append(unknown, name)
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, t)
|
||||
}
|
||||
return resolved, unknown, nil
|
||||
}
|
||||
|
||||
// Dispatch runs one tool call and returns its result.
|
||||
//
|
||||
// This is the one place a tool is invoked, and it holds the two gates a handler
|
||||
// must not be trusted to hold itself:
|
||||
//
|
||||
// 1. **The confirmation gate.** A write with no resolved confirmation never
|
||||
// reaches its handler. The model cannot argue its way past this because the
|
||||
// model is not consulted — the check is on the tool's declared effect and
|
||||
// the token in the context, both of which are set outside the conversation.
|
||||
// 2. **The truncation cap.** Applied to what the handler returned, with the
|
||||
// flag set. A handler that forgets to bound its own output cannot flood the
|
||||
// next turn.
|
||||
//
|
||||
// A panicking handler is contained here too. A tool is the least trusted code
|
||||
// in the runtime — it is where new integrations land — and one bad handler
|
||||
// must cost its own call, not the run.
|
||||
func (r *Registry) Dispatch(ctx context.Context, tc Context, name string, inputs json.RawMessage) (res Result) {
|
||||
t, ok := r.Get(name)
|
||||
if !ok {
|
||||
return Failf(CodeUnavailable, "there is no tool called %q", name)
|
||||
}
|
||||
|
||||
// The gate. Everything past this line has either no effect or an approval.
|
||||
if t.RequiresConfirmation {
|
||||
if pending, blocked := r.gate(ctx, tc, t, inputs); blocked {
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
res = Failf(CodeFailed, "%s failed unexpectedly", name)
|
||||
}
|
||||
}()
|
||||
|
||||
res = t.Handler(ctx, tc, inputs)
|
||||
return truncate(res, t.MaxResultBytes)
|
||||
}
|
||||
|
||||
// gate decides whether a confirmed tool may run now.
|
||||
//
|
||||
// Two outcomes, and the interesting thing is how few:
|
||||
//
|
||||
// - **A token bound to exactly this call.** Consumed, and the handler runs.
|
||||
// - **Anything else.** Render what the call would do, issue a token bound to
|
||||
// it, and return that as a pending Result. Nothing is written. The run ends
|
||||
// at ConfirmationPending and a person is asked.
|
||||
//
|
||||
// "Anything else" deliberately includes a token that does not match: expired,
|
||||
// already spent, issued to somebody else, or issued for a different worker on a
|
||||
// different day. None of those is an error to report — they all mean the same
|
||||
// thing, which is that nobody has approved THIS call, and the honest response
|
||||
// to that is to describe it and ask. Returning a failure instead would leave
|
||||
// the model holding an error it cannot act on, in a run whose token is fixed
|
||||
// for its whole duration.
|
||||
//
|
||||
// A non-matching token is NOT consumed. An earlier version spent it on any
|
||||
// attempt, reasoning that a mismatch was a replay or a guess. It is neither —
|
||||
// the token is 24 random bytes, so guessing is not on the table — and the cost
|
||||
// was real: a model that makes two write calls in one turn would destroy a
|
||||
// perfectly good approval with whichever call happened to be dispatched first.
|
||||
//
|
||||
// Returns (result, true) when the call must not proceed.
|
||||
func (r *Registry) gate(ctx context.Context, tc Context, t Tool, inputs json.RawMessage) (Result, bool) {
|
||||
b := bind(tc, t.Name, inputs)
|
||||
|
||||
if tc.Confirmation != "" && r.confirmations != nil &&
|
||||
r.confirmations.Resolve(ctx, tc.Confirmation, b) {
|
||||
return Result{}, false
|
||||
}
|
||||
|
||||
// Rendered under the same authorization as the write. A renderer that
|
||||
// resolved a name the caller may not see would have leaked it in the act
|
||||
// of asking permission not to.
|
||||
confirmation, denied := r.render(ctx, tc, t, inputs)
|
||||
if denied != nil {
|
||||
return *denied, true
|
||||
}
|
||||
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return Failf(CodeUnavailable, "%s could not be prepared for confirmation", t.Name), true
|
||||
}
|
||||
confirmation.Token = token
|
||||
confirmation.Tool = t.Name
|
||||
if confirmation.ExpiresAt.IsZero() {
|
||||
confirmation.ExpiresAt = time.Now().Add(ConfirmationTTL)
|
||||
}
|
||||
|
||||
if r.confirmations == nil {
|
||||
return Failf(CodeUnavailable,
|
||||
"%s cannot run: there is nowhere to record a confirmation", t.Name), true
|
||||
}
|
||||
if err := r.confirmations.Issue(ctx, b, confirmation); err != nil {
|
||||
// Failing closed. A confirmation that was shown but not recorded is one
|
||||
// that can never be honoured, and asking a person a question whose
|
||||
// answer will be discarded is worse than saying the tool is unavailable.
|
||||
return Failf(CodeUnavailable, "%s could not be prepared for confirmation", t.Name), true
|
||||
}
|
||||
|
||||
return Result{Confirmation: confirmation}, true
|
||||
}
|
||||
|
||||
// render runs a tool's Confirm renderer, containing its failures.
|
||||
//
|
||||
// A renderer is author-written code that runs before any approval exists, so it
|
||||
// gets the same panic containment a handler does — and a renderer that returns
|
||||
// nothing at all is treated as a refusal rather than as an empty dialog.
|
||||
func (r *Registry) render(ctx context.Context, tc Context, t Tool, inputs json.RawMessage) (c *Confirmation, denied *Result) {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
failed := Failf(CodeFailed, "%s could not describe what it would do", t.Name)
|
||||
c, denied = nil, &failed
|
||||
}
|
||||
}()
|
||||
|
||||
if t.Confirm == nil {
|
||||
// Register refuses this, so reaching it means a Tool was built by hand
|
||||
// and bypassed registration. Fail closed rather than trust it.
|
||||
failed := Failf(CodeUnavailable, "%s cannot describe what it would do", t.Name)
|
||||
return nil, &failed
|
||||
}
|
||||
|
||||
c, denied = t.Confirm(ctx, tc, inputs)
|
||||
if denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
if c == nil {
|
||||
failed := Failf(CodeUnavailable, "%s cannot describe what it would do", t.Name)
|
||||
return nil, &failed
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// truncate bounds a result, marking it when it had to.
|
||||
//
|
||||
// The data is replaced wholesale rather than cut mid-encoding: a JSON document
|
||||
// sliced at a byte offset is not a JSON document, and a model handed one will
|
||||
// either fail to parse it or — worse — parse the fragment and reason about it
|
||||
// as if it were the whole.
|
||||
func truncate(res Result, maxBytes int) Result {
|
||||
if res.Data == nil || maxBytes <= 0 {
|
||||
return res
|
||||
}
|
||||
encoded, err := json.Marshal(res.Data)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the result could not be encoded")
|
||||
}
|
||||
if len(encoded) <= maxBytes {
|
||||
return res
|
||||
}
|
||||
return Result{
|
||||
Data: map[string]any{
|
||||
"note": fmt.Sprintf(
|
||||
"This result was %d bytes, over the %d-byte limit, and has been withheld rather than cut. "+
|
||||
"Ask again with a narrower filter, a period, or a smaller limit.",
|
||||
len(encoded), maxBytes),
|
||||
},
|
||||
Truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Redeeming an approval ──────────────────────────────────────────────── */
|
||||
|
||||
// Redeemed is what an approved write did.
|
||||
type Redeemed struct {
|
||||
// Tool is the tool that ran, so the caller can record and report it.
|
||||
Tool string
|
||||
|
||||
// Inputs are the arguments a person approved, replayed verbatim.
|
||||
Inputs json.RawMessage
|
||||
|
||||
Result Result
|
||||
}
|
||||
|
||||
// DispatchApproved performs the call a token authorised.
|
||||
//
|
||||
// The other half of the confirmation flow, and the one that makes it reliable.
|
||||
//
|
||||
// The original design had only one path: the model, on a resumed turn, makes
|
||||
// the same tool call again, and the token is matched against it. That works
|
||||
// when it works and fails silently when it does not — a model is not
|
||||
// deterministic, and asked a second time it may reasonably seek clarification
|
||||
// instead of repeating itself. Observed: a person clicked Approve, the model
|
||||
// asked a follow-up question, the token was never presented, and nothing
|
||||
// happened. No error. No write. Nothing to tell the user why.
|
||||
//
|
||||
// So this path does not ask the model anything. It takes the token, gets back
|
||||
// the exact call that was described to the person, and performs THAT. What
|
||||
// somebody approved and what happens are the same thing by construction rather
|
||||
// than by the model's cooperation.
|
||||
//
|
||||
// The gate is not bypassed — this IS the gate. Redeem checks the caller, the
|
||||
// tenant and the expiry and consumes the token atomically, so an approval still
|
||||
// buys exactly one write and only for the person who was asked.
|
||||
//
|
||||
// Returns ok=false when the token authorises nothing: unknown, expired, spent,
|
||||
// or somebody else's. Indistinguishably, as everywhere else.
|
||||
func (r *Registry) DispatchApproved(ctx context.Context, tc Context, token string) (out Redeemed, ok bool) {
|
||||
if r.confirmations == nil || token == "" {
|
||||
return Redeemed{}, false
|
||||
}
|
||||
|
||||
approved, ok := r.confirmations.Redeem(ctx, token, Principal{
|
||||
UserID: tc.Principal.UserID,
|
||||
OrgID: tc.Principal.OrgID,
|
||||
})
|
||||
if !ok {
|
||||
return Redeemed{}, false
|
||||
}
|
||||
|
||||
t, exists := r.Get(approved.Tool)
|
||||
if !exists {
|
||||
// The tool was withdrawn between the asking and the answering. The
|
||||
// token is spent either way — it has been consumed by Redeem — which is
|
||||
// correct: re-offering it would let a person approve something this
|
||||
// service can no longer describe.
|
||||
return Redeemed{
|
||||
Tool: approved.Tool,
|
||||
Inputs: approved.Inputs,
|
||||
Result: Failf(CodeUnavailable, "%s is no longer available", approved.Tool),
|
||||
}, true
|
||||
}
|
||||
|
||||
res := func() (res Result) {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
res = Failf(CodeFailed, "%s failed unexpectedly", t.Name)
|
||||
}
|
||||
}()
|
||||
return t.Handler(ctx, tc, approved.Inputs)
|
||||
}()
|
||||
|
||||
return Redeemed{
|
||||
Tool: approved.Tool,
|
||||
Inputs: approved.Inputs,
|
||||
Result: truncate(res, t.MaxResultBytes),
|
||||
}, true
|
||||
}
|
||||
|
||||
// ToolInfo is what a tool looks like to somebody choosing one, rather than to
|
||||
// the model calling it.
|
||||
//
|
||||
// The InputSchema is deliberately absent: an author picks a capability, and the
|
||||
// schema is the model's business. Effect is present because it is the one thing
|
||||
// an author must understand — a write tool means their agent can propose
|
||||
// changes, which a person will then be asked to approve.
|
||||
type ToolInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Effect string `json:"effect"`
|
||||
RequiresConfirmation bool `json:"requiresConfirmation"`
|
||||
}
|
||||
|
||||
// Catalogue lists every registered tool, sorted, as choosable metadata.
|
||||
//
|
||||
// This exists so an agent author can be shown the real tool set rather than a
|
||||
// hand-maintained copy of it in the frontend. A second list would drift, and
|
||||
// the failure would be silent: an author picks a tool that no longer exists and
|
||||
// gets an agent that quietly cannot do the thing they picked.
|
||||
func (r *Registry) Catalogue() []ToolInfo {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
out := make([]ToolInfo, 0, len(r.tools))
|
||||
for _, t := range r.tools {
|
||||
out = append(out, ToolInfo{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Effect: string(t.Effect),
|
||||
RequiresConfirmation: t.RequiresConfirmation,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Known reports whether every name is a registered tool, returning the ones
|
||||
// that are not.
|
||||
//
|
||||
// §3 requires an unknown tool name to fail validation at PUBLISH. Without this
|
||||
// the runtime records and drops the name, so a typo becomes an agent that is
|
||||
// silently missing a capability its author believes it has.
|
||||
func (r *Registry) Known(names []string) (unknown []string) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, n := range names {
|
||||
if _, ok := r.tools[n]; !ok {
|
||||
unknown = append(unknown, n)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
180
go-api/internal/tools/registry_test.go
Normal file
180
go-api/internal/tools/registry_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
func stub(name string, effect tools.Effect, h tools.Handler) tools.Tool {
|
||||
if h == nil {
|
||||
h = func(context.Context, tools.Context, json.RawMessage) tools.Result {
|
||||
return tools.OK(map[string]any{"ok": true})
|
||||
}
|
||||
}
|
||||
t := tools.Tool{
|
||||
Name: name, Description: "A stub.", Effect: effect,
|
||||
InputSchema: map[string]any{"type": "object"}, Handler: h,
|
||||
}
|
||||
if effect == tools.EffectWrite {
|
||||
t.Confirm = describeStub
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// describeStub is the minimum a write must be able to say about itself.
|
||||
func describeStub(context.Context, tools.Context, json.RawMessage) (*tools.Confirmation, *tools.Result) {
|
||||
return &tools.Confirmation{Title: "Do the thing", Summary: "The thing will be done."}, nil
|
||||
}
|
||||
|
||||
func TestWriteToolsAlwaysRequireConfirmation(t *testing.T) {
|
||||
// I4, and the reason it is forced rather than trusted: a write that forgot
|
||||
// to set the flag would run unconfirmed forever, indistinguishable from a
|
||||
// deliberate choice.
|
||||
reg := tools.NewRegistry()
|
||||
w := stub("send_shift_offer", tools.EffectWrite, nil)
|
||||
w.RequiresConfirmation = false // an author trying to opt out
|
||||
reg.MustRegister(w)
|
||||
|
||||
got, _ := reg.Get("send_shift_offer")
|
||||
if !got.RequiresConfirmation {
|
||||
t.Fatal("a write tool must require confirmation regardless of what its author declared")
|
||||
}
|
||||
|
||||
res := reg.Dispatch(context.Background(), tools.Context{}, "send_shift_offer", nil)
|
||||
if res.Confirmation == nil {
|
||||
t.Fatal("an unconfirmed write must come back as a question, not run")
|
||||
}
|
||||
if res.Data != nil {
|
||||
t.Fatal("an unconfirmed write must not produce a result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWriteWithNoRendererIsRefusedAtRegistration(t *testing.T) {
|
||||
// §8 step 3. A confirmation nobody can read is a click, not a confirmation,
|
||||
// and the only honest dialog for a tool with no renderer would show raw
|
||||
// arguments. Caught at boot rather than in production.
|
||||
reg := tools.NewRegistry()
|
||||
w := stub("silent_write", tools.EffectWrite, nil)
|
||||
w.Confirm = nil
|
||||
if err := reg.Register(w); err == nil {
|
||||
t.Fatal("a write with no Confirm renderer must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsMalformedTools(t *testing.T) {
|
||||
reg := tools.NewRegistry()
|
||||
cases := map[string]tools.Tool{
|
||||
"no name": {Description: "x", Effect: tools.EffectRead, Handler: stub("x", tools.EffectRead, nil).Handler},
|
||||
"no handler": {Name: "a", Description: "x", Effect: tools.EffectRead},
|
||||
"no description": {Name: "b", Effect: tools.EffectRead, Handler: stub("b", tools.EffectRead, nil).Handler},
|
||||
"bad effect": {Name: "c", Description: "x", Effect: "maybe", Handler: stub("c", tools.EffectRead, nil).Handler},
|
||||
}
|
||||
for name, tool := range cases {
|
||||
if err := reg.Register(tool); err == nil {
|
||||
t.Errorf("%s: should have been refused", name)
|
||||
}
|
||||
}
|
||||
if err := reg.Register(stub("ok", tools.EffectRead, nil)); err != nil {
|
||||
t.Errorf("a well-formed tool was refused: %v", err)
|
||||
}
|
||||
if err := reg.Register(stub("ok", tools.EffectRead, nil)); err == nil {
|
||||
t.Error("a duplicate name should be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEnforcesThePerAgentCap(t *testing.T) {
|
||||
reg := tools.NewRegistry()
|
||||
names := make([]string, 0, tools.MaxToolsPerAgent+1)
|
||||
for i := 0; i <= tools.MaxToolsPerAgent; i++ {
|
||||
n := string(rune('a'+i%26)) + strings.Repeat("x", i)
|
||||
reg.MustRegister(stub(n, tools.EffectRead, nil))
|
||||
names = append(names, n)
|
||||
}
|
||||
if _, _, err := reg.Resolve(names); err == nil {
|
||||
t.Errorf("%d tools should exceed the cap of %d", len(names), tools.MaxToolsPerAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportsUnknownNames(t *testing.T) {
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(stub("real", tools.EffectRead, nil))
|
||||
|
||||
resolved, unknown, err := reg.Resolve([]string{"real", "imaginary", "real"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if len(resolved) != 1 {
|
||||
t.Errorf("resolved %d tools, want 1 — a repeated name is not two tools", len(resolved))
|
||||
}
|
||||
if len(unknown) != 1 || unknown[0] != "imaginary" {
|
||||
t.Errorf("unknown = %v, want [imaginary] — a missing name must be reported, not dropped", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchContainsAPanickingHandler(t *testing.T) {
|
||||
// A tool is the least trusted code in the runtime. One bad handler costs
|
||||
// its own call, not the run.
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(stub("explodes", tools.EffectRead,
|
||||
func(context.Context, tools.Context, json.RawMessage) tools.Result {
|
||||
panic("boom")
|
||||
}))
|
||||
|
||||
res := reg.Dispatch(context.Background(), tools.Context{}, "explodes", nil)
|
||||
if res.Error == nil || res.Error.Code != tools.CodeFailed {
|
||||
t.Fatalf("a panicking handler should return a failure, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchWithholdsOversizedResults(t *testing.T) {
|
||||
// Cut JSON is not JSON, and a model handed a fragment reasons about it as
|
||||
// if it were whole. The result is withheld and the fact declared.
|
||||
reg := tools.NewRegistry()
|
||||
big := strings.Repeat("x", 2000)
|
||||
tool := stub("huge", tools.EffectRead,
|
||||
func(context.Context, tools.Context, json.RawMessage) tools.Result {
|
||||
return tools.OK(map[string]any{"blob": big})
|
||||
})
|
||||
tool.MaxResultBytes = 500
|
||||
reg.MustRegister(tool)
|
||||
|
||||
res := reg.Dispatch(context.Background(), tools.Context{}, "huge", nil)
|
||||
if !res.Truncated {
|
||||
t.Fatal("an oversized result must be marked truncated")
|
||||
}
|
||||
encoded, _ := json.Marshal(res.Data)
|
||||
if strings.Contains(string(encoded), big) {
|
||||
t.Error("the oversized payload was returned anyway")
|
||||
}
|
||||
if !strings.Contains(string(encoded), "narrower") {
|
||||
t.Error("the model was not told how to ask again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchUnknownToolIsAResultNotAPanic(t *testing.T) {
|
||||
reg := tools.NewRegistry()
|
||||
res := reg.Dispatch(context.Background(), tools.Context{}, "nope", nil)
|
||||
if res.Error == nil || res.Error.Code != tools.CodeUnavailable {
|
||||
t.Fatalf("want unavailable, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesAreSortedForCacheStability(t *testing.T) {
|
||||
// The tool list is part of the cached prefix. An unstable order would
|
||||
// invalidate the cache on every request for no reason at all.
|
||||
reg := tools.NewRegistry()
|
||||
for _, n := range []string{"zulu", "alpha", "mike"} {
|
||||
reg.MustRegister(stub(n, tools.EffectRead, nil))
|
||||
}
|
||||
got := reg.Names()
|
||||
want := []string{"alpha", "mike", "zulu"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("Names() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
164
go-api/internal/tools/scope.go
Normal file
164
go-api/internal/tools/scope.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
)
|
||||
|
||||
// query is a WHERE clause being assembled, with its bind parameters.
|
||||
//
|
||||
// Every tool builds its predicate through this rather than by hand. §13 names
|
||||
// "passing the tenant id as a plain function argument through five layers" as
|
||||
// an anti-pattern, and a hand-written `WHERE org_id = ?` at twenty call sites
|
||||
// is the same failure wearing a different hat: twenty chances to forget, and
|
||||
// the one that forgets is a cross-tenant read nobody notices until someone
|
||||
// reports seeing another company's numbers.
|
||||
//
|
||||
// The only way to obtain one is authorize(), which cannot return a query
|
||||
// without having first checked the policy and pinned the tenant.
|
||||
type query struct {
|
||||
where []string
|
||||
args []any
|
||||
|
||||
// alias qualifies every column this query names, for the one handler that
|
||||
// joins. Set through withAlias before any predicate is added, so a column
|
||||
// cannot be added unqualified and then become ambiguous when a second
|
||||
// table arrives.
|
||||
alias string
|
||||
}
|
||||
|
||||
// authorize resolves a caller against a resource's policy and returns the
|
||||
// predicate their rows are behind.
|
||||
//
|
||||
// This is the first line of every handler body. It answers both of §2's
|
||||
// retrieval questions at once:
|
||||
//
|
||||
// - **May this caller list this resource at all?** From the policy table,
|
||||
// deny-by-default. An unlisted role, an unknown resource and a caller with
|
||||
// no tenant all refuse.
|
||||
// - **Which rows are theirs?** The talent scope, rendered as SQL. A
|
||||
// pre-filter, per I2 — pushed into the query so counts, shares and
|
||||
// rankings are all computed over exactly the caller's own rows.
|
||||
//
|
||||
// The returned Result is non-nil exactly when the caller is refused, and it is
|
||||
// always the same opaque Denied(): a handler must return it unchanged rather
|
||||
// than explaining, because two distinguishable refusals are an oracle.
|
||||
func authorize(tc Context, resourcePath string) (*query, *Result) {
|
||||
return authorizeOp(tc, resourcePath, domain.OpList, "")
|
||||
}
|
||||
|
||||
// authorizeAs is authorize for a query whose table carries an alias.
|
||||
func authorizeAs(tc Context, resourcePath, alias string) (*query, *Result) {
|
||||
return authorizeOp(tc, resourcePath, domain.OpList, alias)
|
||||
}
|
||||
|
||||
// authorizeOp is authorize for an operation other than listing.
|
||||
//
|
||||
// A write tool asks for OpCreate here, and the answer is a different set of
|
||||
// roles: `assignments` lists to everyone and creates for operators only, so a
|
||||
// talent caller who may perfectly well read their own roster is refused when
|
||||
// they try to put themselves on one. Reusing the read check for a write would
|
||||
// have granted exactly that — the most common way an authorization table gets
|
||||
// quietly bypassed is by asking it the wrong question.
|
||||
//
|
||||
// The returned query still carries the caller's READ predicate. A write tool
|
||||
// uses it to check that the rows it is about to reference are ones this caller
|
||||
// could have seen: creating an assignment against a posting you cannot read is
|
||||
// a write that confirms the posting exists.
|
||||
func authorizeOp(tc Context, resourcePath string, op domain.Op, alias string) (*query, *Result) {
|
||||
res, ok := domain.ResourceByPath[resourcePath]
|
||||
if !ok || res.Policy == nil {
|
||||
denied := Denied()
|
||||
return nil, &denied
|
||||
}
|
||||
|
||||
role, ok := domain.ParseRole(tc.Principal.Role)
|
||||
if !ok || !res.Policy.Allows(op, role) {
|
||||
denied := Denied()
|
||||
return nil, &denied
|
||||
}
|
||||
|
||||
// I5. No tenant means no query — there is no "all organizations" read, and
|
||||
// a missing org is a bug upstream rather than a wildcard.
|
||||
if tc.OrgID() == "" {
|
||||
denied := Denied()
|
||||
return nil, &denied
|
||||
}
|
||||
|
||||
q := &query{args: []any{tc.OrgID()}, alias: alias}
|
||||
q.where = []string{q.col("org_id") + " = $1::uuid"}
|
||||
|
||||
switch scope := res.Policy.ScopeFor(role); scope.Kind {
|
||||
case domain.ScopeNone:
|
||||
// Operators see the whole tenant. That is what an operator console is.
|
||||
case domain.ScopeUserID:
|
||||
q.eq(scope.Column+"::text", tc.Principal.UserID)
|
||||
case domain.ScopeEmail:
|
||||
q.eq(scope.Column, tc.Principal.Email)
|
||||
case domain.ScopeActivePostings:
|
||||
// Visibility rather than ownership: a talent caller sees the roles they
|
||||
// could apply to, not the drafts, the paused roles or the closed
|
||||
// history.
|
||||
// "active" — the value repo.go renders for this scope. The enum has no
|
||||
// "open" member, and a status literal that does not exist matches
|
||||
// nothing, which fails safe and silently.
|
||||
q.eq(scope.Column+"::text", "active")
|
||||
case domain.ScopeOwnApplications:
|
||||
// Ownership by reference. The subquery is the pre-filter — resolving
|
||||
// the ids in Go first and filtering afterwards would be the
|
||||
// post-filter I2 forbids.
|
||||
q.args = append(q.args, tc.Principal.Email)
|
||||
q.where = append(q.where, fmt.Sprintf(
|
||||
"%s IN (SELECT id FROM job_applications WHERE org_id = $1::uuid AND email = $%d)",
|
||||
q.col(scope.Column), len(q.args)))
|
||||
default:
|
||||
// An unrecognised scope kind matches nothing rather than everything.
|
||||
// The safe direction to fail, and loud enough to find.
|
||||
q.where = append(q.where, "false")
|
||||
}
|
||||
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// withAlias qualifies this query's columns with a table alias.
|
||||
//
|
||||
// Called before any predicate is added — including the ones authorize() itself
|
||||
// adds — so it is threaded through authorizeAs rather than applied afterwards.
|
||||
func (q *query) col(name string) string {
|
||||
if q.alias == "" {
|
||||
return name
|
||||
}
|
||||
return q.alias + "." + name
|
||||
}
|
||||
|
||||
// eq adds `column = value`.
|
||||
func (q *query) eq(column string, value any) {
|
||||
q.args = append(q.args, value)
|
||||
q.where = append(q.where, fmt.Sprintf("%s = $%d", q.col(column), len(q.args)))
|
||||
}
|
||||
|
||||
// gte adds `column >= value`.
|
||||
func (q *query) gte(column string, value any) {
|
||||
q.args = append(q.args, value)
|
||||
q.where = append(q.where, fmt.Sprintf("%s >= $%d", q.col(column), len(q.args)))
|
||||
}
|
||||
|
||||
// lt adds `column < value`.
|
||||
func (q *query) lt(column string, value any) {
|
||||
q.args = append(q.args, value)
|
||||
q.where = append(q.where, fmt.Sprintf("%s < $%d", q.col(column), len(q.args)))
|
||||
}
|
||||
|
||||
// raw adds a predicate with no bind parameters.
|
||||
//
|
||||
// For constant conditions only — a status literal, a NOT NULL. Never for
|
||||
// anything derived from input: the whole point of eq/gte/lt is that a value
|
||||
// cannot reach the statement except as a parameter.
|
||||
func (q *query) raw(predicate string) {
|
||||
q.where = append(q.where, predicate)
|
||||
}
|
||||
|
||||
// clause renders the WHERE body.
|
||||
func (q *query) clause() string { return strings.Join(q.where, " AND ") }
|
||||
187
go-api/internal/tools/tools.go
Normal file
187
go-api/internal/tools/tools.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Package tools is the tool layer: everything an agent can do that is not
|
||||
// talking.
|
||||
//
|
||||
// A tool is a named, schema'd function the model may call. The contract below
|
||||
// is §4's, and three parts of it are load-bearing rather than stylistic:
|
||||
//
|
||||
// - **Every handler authorizes on ctx.Principal, first line.** A handler that
|
||||
// reads ctx for anything except authorization is wrong. This is where I1
|
||||
// lives: an agent may read exactly what its caller could read directly, and
|
||||
// the only way to guarantee that is for the tool — not the model, not the
|
||||
// prompt — to apply the caller's own permissions.
|
||||
// - **Filters are pre-filters.** A handler narrows in SQL, never in Go over a
|
||||
// fetched result set. I2: post-filtering leaks through counts and ranking
|
||||
// positions even when no forbidden row is ever printed.
|
||||
// - **Errors are returned, not raised.** A tool that panics or returns a bare
|
||||
// error takes the whole run with it. The runtime decides whether the model
|
||||
// sees a failure and retries, and it can only decide that if the failure
|
||||
// arrives as data.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
)
|
||||
|
||||
// Effect is whether a tool changes anything.
|
||||
type Effect string
|
||||
|
||||
const (
|
||||
// EffectRead observes. Safe to call without asking anyone.
|
||||
EffectRead Effect = "read"
|
||||
// EffectWrite writes, sends, deletes, charges or notifies. Never runs
|
||||
// without a resolved confirmation — see Tool.RequiresConfirmation.
|
||||
EffectWrite Effect = "write"
|
||||
)
|
||||
|
||||
// DefaultMaxResultBytes caps a tool result.
|
||||
//
|
||||
// Not a performance guard. An unbounded result is an unbounded prompt on the
|
||||
// next turn, which is an unbounded bill and eventually a context overflow that
|
||||
// presents as the model ignoring the middle of its own evidence.
|
||||
const DefaultMaxResultBytes = 262_144
|
||||
|
||||
// Context is what a handler is given about its caller.
|
||||
//
|
||||
// Carries the principal, the tenant, the run and what budget is left, per §4.
|
||||
// Deliberately a struct and not a context.Context value: a handler must not be
|
||||
// able to *forget* to read it, and a compile error is a better reminder than a
|
||||
// convention.
|
||||
type Context struct {
|
||||
// Principal is the caller the agent is acting for. Never the agent.
|
||||
Principal authctx.Identity
|
||||
|
||||
// RunID addresses the trajectory this call is recorded in.
|
||||
RunID string
|
||||
|
||||
// RemainingTokens is what the run has left to spend. A handler may use it
|
||||
// to decide how much to return; it must not use it to decide whether the
|
||||
// caller is allowed something.
|
||||
RemainingTokens int64
|
||||
|
||||
// Confirmation is the resolved token for a write. Empty on a read, and
|
||||
// empty on a write that has not been confirmed yet — which the dispatcher
|
||||
// refuses before a handler is ever reached.
|
||||
Confirmation string
|
||||
|
||||
// AgentID is the running agent, for the record. Never for authorization —
|
||||
// what a caller may do is decided by their principal, and an agent that
|
||||
// could widen that by being named would be an agent that expands access.
|
||||
AgentID string
|
||||
|
||||
// KnowledgeSources are the corpora the running agent's SPEC declares.
|
||||
//
|
||||
// Here rather than in a tool argument, and the difference is the whole
|
||||
// security property: an argument is something a model can choose, and which
|
||||
// documents an agent may read is not the model's to choose. The loop sets
|
||||
// this from the agent record; nothing in the conversation can reach it.
|
||||
//
|
||||
// Empty means this agent has no knowledge. It does not mean "all of it" —
|
||||
// retrieval refuses an empty source list for exactly that reason.
|
||||
KnowledgeSources []string
|
||||
}
|
||||
|
||||
// OrgID is the tenant this call runs inside. I5 — every handler's query
|
||||
// narrows by it, and there is no path that produces a call without one.
|
||||
func (c Context) OrgID() string { return c.Principal.OrgID }
|
||||
|
||||
// Handler runs one tool.
|
||||
//
|
||||
// The signature is `(inputs, ctx)` in §4's terms, with the Go context first by
|
||||
// convention so cancellation and the deadline reach the query. A handler
|
||||
// returns a Result and never an error: a failure is a value the runtime routes,
|
||||
// not a panic that ends a run.
|
||||
type Handler func(ctx context.Context, tc Context, inputs json.RawMessage) Result
|
||||
|
||||
// Tool is one callable capability.
|
||||
type Tool struct {
|
||||
Name string
|
||||
Description string
|
||||
|
||||
// InputSchema is JSON Schema. Every field described, because the
|
||||
// description is what the model reads instead of documentation — and a
|
||||
// tool whose schema demands an id the model was never given is a design
|
||||
// bug, not a prompt problem. Add a lookup tool instead.
|
||||
InputSchema map[string]any
|
||||
|
||||
Effect Effect
|
||||
|
||||
// RequiresConfirmation is forced true for a write by Register. It is a
|
||||
// field rather than a method so a read tool may opt in — some reads are
|
||||
// expensive enough to be worth asking about — but a write can never opt
|
||||
// out. The model does not get a say either way.
|
||||
RequiresConfirmation bool
|
||||
|
||||
// Confirm renders, in plain language, what this tool will do if approved.
|
||||
// Mandatory when RequiresConfirmation is set: Register refuses a write
|
||||
// without one, because a confirmation a person cannot read is not a
|
||||
// confirmation, it is a click. See confirm.go.
|
||||
Confirm Confirmer
|
||||
|
||||
MaxResultBytes int
|
||||
|
||||
Handler Handler
|
||||
}
|
||||
|
||||
// Result is what a tool returns.
|
||||
//
|
||||
// Structured data, never prose: formatting is the model's job, and a handler
|
||||
// that returns a sentence has decided how the answer reads before the model has
|
||||
// seen the question.
|
||||
type Result struct {
|
||||
Data any `json:"data,omitempty"`
|
||||
Error *ToolError `json:"error,omitempty"`
|
||||
|
||||
// Truncated says the result was cut at MaxResultBytes. Set alongside the
|
||||
// data that survived — never as a silent drop, because a model given a
|
||||
// truncated list with no marker will reason about it as if it were whole.
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
|
||||
// Confirmation is set when a write was described but not performed. It is
|
||||
// neither success nor error: nothing happened, and something must now be
|
||||
// approved by a person before anything can. The loop reads this and ends
|
||||
// the run at ConfirmationPending rather than handing it to the model —
|
||||
// see I4, and the note on Registry.Dispatch.
|
||||
Confirmation *Confirmation `json:"confirmation,omitempty"`
|
||||
}
|
||||
|
||||
// ToolError is a failure a handler chose to report.
|
||||
type ToolError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Failf builds an error result.
|
||||
func Failf(code, format string, args ...any) Result {
|
||||
return Result{Error: &ToolError{Code: code, Message: fmt.Sprintf(format, args...)}}
|
||||
}
|
||||
|
||||
// OK builds a success result.
|
||||
func OK(data any) Result { return Result{Data: data} }
|
||||
|
||||
// Standard tool error codes. A denial is deliberately one code with one
|
||||
// wording — see Denied.
|
||||
const (
|
||||
CodeDenied = "tool.denied"
|
||||
CodeInvalidInput = "tool.invalid_input"
|
||||
CodeUnavailable = "tool.unavailable"
|
||||
CodeFailed = "tool.failed"
|
||||
)
|
||||
|
||||
// Denied is the single refusal every handler returns when a caller may not do
|
||||
// something.
|
||||
//
|
||||
// One code, one message, no detail. §8: a denial must not reveal that the
|
||||
// resource exists. Two different refusals — "no such venue" and "not your
|
||||
// venue" — are an oracle: a caller who can tell them apart can enumerate what
|
||||
// they cannot see, and the agent will happily run that enumeration for them one
|
||||
// question at a time.
|
||||
func Denied() Result {
|
||||
return Result{Error: &ToolError{
|
||||
Code: CodeDenied,
|
||||
Message: "the caller does not have access to this",
|
||||
}}
|
||||
}
|
||||
383
go-api/internal/tools/workforce.go
Normal file
383
go-api/internal/tools/workforce.go
Normal file
@@ -0,0 +1,383 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// The workforce tools: attendance, overtime and shift coverage.
|
||||
//
|
||||
// Ports of `workforce.attendance`, `workforce.overtime` and
|
||||
// `workforce.coverage`, which between them are named by eighteen of the shipped
|
||||
// skills — the densest cluster in the registry.
|
||||
//
|
||||
// All three read shift_records, and all three go through authorize(), so a
|
||||
// talent caller sees their own shifts and an operator sees the tenant's. The
|
||||
// aggregate is computed after the predicate, never before: "the workforce
|
||||
// averaged 4% late" computed over rows a caller may not read is a leak even
|
||||
// though no row is printed.
|
||||
//
|
||||
// shift_status is `present | late | absent | no_show` — there is no
|
||||
// "completed", "scheduled" or "cancelled" member. A status literal that is not
|
||||
// in the enum matches no rows and raises nothing, so a wrong guess here reads
|
||||
// as a quiet zero rather than an error. The values below are the enum's own.
|
||||
|
||||
type periodInput struct {
|
||||
Period string `json:"period"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (p periodInput) limitOr(n int) int {
|
||||
if p.Limit <= 0 {
|
||||
return n
|
||||
}
|
||||
if p.Limit > 100 {
|
||||
return 100
|
||||
}
|
||||
return p.Limit
|
||||
}
|
||||
|
||||
// decodePeriod reads the shared period/limit arguments.
|
||||
func decodePeriod(inputs json.RawMessage) (periodInput, time.Time, time.Time, *Result) {
|
||||
var in periodInput
|
||||
if len(inputs) > 0 {
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
r := Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
return in, time.Time{}, time.Time{}, &r
|
||||
}
|
||||
}
|
||||
from, to, err := windowFor(in.Period, time.Now())
|
||||
if err != nil {
|
||||
r := Failf(CodeInvalidInput, "%s", err.Error())
|
||||
return in, time.Time{}, time.Time{}, &r
|
||||
}
|
||||
return in, from, to, nil
|
||||
}
|
||||
|
||||
// periodSchema is the shared argument shape. One definition so three tools
|
||||
// cannot describe the same argument three slightly different ways — the model
|
||||
// reads these as documentation, and inconsistent documentation is worse than
|
||||
// terse documentation.
|
||||
func periodSchema(limitHelp string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"period": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"today", "last-7-days", "last-30-days", "this-month", "previous-month"},
|
||||
"description": "The window to read. Omit for all recorded history. " +
|
||||
"Windows are computed from the current date; do not pass a date.",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer", "minimum": 1, "maximum": 100,
|
||||
"description": limitHelp,
|
||||
},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Attendance ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// WorkforceAttendance reports shift attendance: completion, lateness, no-shows.
|
||||
func WorkforceAttendance(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "workforce_attendance",
|
||||
Description: "Read shift attendance: how many shifts were scheduled, completed, " +
|
||||
"missed or started late, the average minutes late, and the workers with the " +
|
||||
"weakest attendance. Use for questions about reliability, no-shows, lateness " +
|
||||
"and whether shifts are being worked.",
|
||||
InputSchema: periodSchema("How many workers to list, worst attendance first. Defaults to 10."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorize(tc, "shift-records")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
in, from, to, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
if !from.IsZero() {
|
||||
q.gte("shift_date", from)
|
||||
q.lt("shift_date", to)
|
||||
}
|
||||
|
||||
var (
|
||||
total, completed, missed, noShow, late int64
|
||||
avgLate *float64
|
||||
)
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE status IN ('present', 'late')),
|
||||
count(*) FILTER (WHERE status IN ('no_show', 'absent')),
|
||||
count(*) FILTER (WHERE status = 'no_show'),
|
||||
count(*) FILTER (WHERE minutes_late > 0),
|
||||
avg(minutes_late) FILTER (WHERE minutes_late > 0)
|
||||
FROM shift_records
|
||||
WHERE `+q.clause(), q.args...,
|
||||
).Scan(&total, &completed, &missed, &noShow, &late, &avgLate)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the shift records could not be read")
|
||||
}
|
||||
|
||||
workers, err := weakestAttendance(ctx, db, q, in.limitOr(10))
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the shift records could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"period": periodOrAll(in.Period),
|
||||
"shiftsScheduled": total,
|
||||
"shiftsWorked": completed,
|
||||
"shiftsMissed": missed,
|
||||
"noShows": noShow,
|
||||
"lateStarts": late,
|
||||
"workers": workers,
|
||||
}
|
||||
if avgLate != nil {
|
||||
data["averageMinutesLateWhenLate"] = int(*avgLate + 0.5)
|
||||
}
|
||||
if total > 0 {
|
||||
data["completionRatePercent"] = int(float64(completed)/float64(total)*100 + 0.5)
|
||||
} else {
|
||||
data["note"] = "No shifts match that. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type workerAttendance struct {
|
||||
Worker string `json:"worker"`
|
||||
Shifts int64 `json:"shifts"`
|
||||
Missed int64 `json:"missed"`
|
||||
NoShows int64 `json:"noShows"`
|
||||
Late int64 `json:"lateStarts"`
|
||||
Reliable int `json:"reliabilityPercent"`
|
||||
AvgMinute int `json:"averageMinutesLate,omitempty"`
|
||||
}
|
||||
|
||||
func weakestAttendance(ctx context.Context, db repo.Querier, q *query, limit int) ([]workerAttendance, error) {
|
||||
args := append(append([]any{}, q.args...), limit)
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT worker_name,
|
||||
count(*),
|
||||
count(*) FILTER (WHERE status IN ('no_show', 'absent')),
|
||||
count(*) FILTER (WHERE status = 'no_show'),
|
||||
count(*) FILTER (WHERE minutes_late > 0),
|
||||
coalesce(avg(minutes_late) FILTER (WHERE minutes_late > 0), 0)
|
||||
FROM shift_records
|
||||
WHERE `+q.clause()+`
|
||||
GROUP BY worker_name
|
||||
ORDER BY count(*) FILTER (WHERE status IN ('no_show', 'absent')) DESC,
|
||||
count(*) FILTER (WHERE minutes_late > 0) DESC,
|
||||
worker_name ASC
|
||||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []workerAttendance
|
||||
for rows.Next() {
|
||||
var w workerAttendance
|
||||
var avg float64
|
||||
if err := rows.Scan(&w.Worker, &w.Shifts, &w.Missed, &w.NoShows, &w.Late, &avg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.AvgMinute = int(avg + 0.5)
|
||||
if w.Shifts > 0 {
|
||||
w.Reliable = int(float64(w.Shifts-w.Missed)/float64(w.Shifts)*100 + 0.5)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
/* ── Overtime ───────────────────────────────────────────────────────────── */
|
||||
|
||||
// WorkforceOvertime reports overtime hours and who is accruing them.
|
||||
func WorkforceOvertime(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "workforce_overtime",
|
||||
Description: "Read overtime: total overtime hours, how many shifts ran over, and " +
|
||||
"the workers accruing the most. Use for questions about overtime cost, who is " +
|
||||
"working beyond their scheduled hours, and whether overtime is concentrated.",
|
||||
InputSchema: periodSchema("How many workers to list, most overtime first. Defaults to 10."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorize(tc, "shift-records")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
in, from, to, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
if !from.IsZero() {
|
||||
q.gte("shift_date", from)
|
||||
q.lt("shift_date", to)
|
||||
}
|
||||
|
||||
var (
|
||||
shiftsWithOT int64
|
||||
totalOT float64
|
||||
totalActual float64
|
||||
)
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT count(*) FILTER (WHERE overtime_hours > 0),
|
||||
coalesce(sum(overtime_hours), 0),
|
||||
coalesce(sum(actual_hours), 0)
|
||||
FROM shift_records
|
||||
WHERE `+q.clause(), q.args...,
|
||||
).Scan(&shiftsWithOT, &totalOT, &totalActual)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the shift records could not be read")
|
||||
}
|
||||
|
||||
args := append(append([]any{}, q.args...), in.limitOr(10))
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT worker_name, coalesce(sum(overtime_hours), 0), count(*) FILTER (WHERE overtime_hours > 0)
|
||||
FROM shift_records
|
||||
WHERE `+q.clause()+`
|
||||
GROUP BY worker_name
|
||||
HAVING sum(overtime_hours) > 0
|
||||
ORDER BY sum(overtime_hours) DESC, worker_name ASC
|
||||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the shift records could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type worker struct {
|
||||
Worker string `json:"worker"`
|
||||
Hours float64 `json:"overtimeHours"`
|
||||
Shifts int64 `json:"shiftsWithOvertime"`
|
||||
}
|
||||
var workers []worker
|
||||
for rows.Next() {
|
||||
var w worker
|
||||
if err := rows.Scan(&w.Worker, &w.Hours, &w.Shifts); err != nil {
|
||||
return Failf(CodeFailed, "the shift records could not be read")
|
||||
}
|
||||
workers = append(workers, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the shift records could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"period": periodOrAll(in.Period),
|
||||
"totalOvertimeHours": round1(totalOT),
|
||||
"shiftsWithOvertime": shiftsWithOT,
|
||||
"workers": workers,
|
||||
}
|
||||
if totalActual > 0 {
|
||||
data["overtimeSharePercent"] = int(totalOT/totalActual*100 + 0.5)
|
||||
}
|
||||
if totalOT == 0 {
|
||||
data["note"] = "No overtime was recorded in that window. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Coverage ───────────────────────────────────────────────────────────── */
|
||||
|
||||
// WorkforceCoverage reports whether shifts are covered and what is unfilled.
|
||||
func WorkforceCoverage(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "workforce_coverage",
|
||||
Description: "Read shift coverage: how many shifts are scheduled, cancelled or " +
|
||||
"unworked, and which roles have the most uncovered shifts. Use for questions " +
|
||||
"about gaps in the rota, roles that are hard to staff, and what is at risk of " +
|
||||
"going unworked.",
|
||||
InputSchema: periodSchema("How many roles to list, most uncovered first. Defaults to 10."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorize(tc, "shift-records")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
in, from, to, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
if !from.IsZero() {
|
||||
q.gte("shift_date", from)
|
||||
q.lt("shift_date", to)
|
||||
}
|
||||
|
||||
args := append(append([]any{}, q.args...), in.limitOr(10))
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT coalesce(nullif(role, ''), 'unspecified'),
|
||||
count(*),
|
||||
count(*) FILTER (WHERE status IN ('no_show', 'absent')),
|
||||
count(*) FILTER (WHERE status = 'late')
|
||||
FROM shift_records
|
||||
WHERE `+q.clause()+`
|
||||
GROUP BY 1
|
||||
ORDER BY count(*) FILTER (WHERE status IN ('no_show', 'absent')) DESC, 1 ASC
|
||||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the shift records could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type roleCoverage struct {
|
||||
Role string `json:"role"`
|
||||
Shifts int64 `json:"shifts"`
|
||||
Uncovered int64 `json:"uncovered"`
|
||||
Late int64 `json:"lateStarts"`
|
||||
Covered int `json:"coveragePercent"`
|
||||
}
|
||||
var (
|
||||
roles []roleCoverage
|
||||
allShifts, allUncovered, sch int64
|
||||
)
|
||||
for rows.Next() {
|
||||
var r roleCoverage
|
||||
if err := rows.Scan(&r.Role, &r.Shifts, &r.Uncovered, &r.Late); err != nil {
|
||||
return Failf(CodeFailed, "the shift records could not be read")
|
||||
}
|
||||
if r.Shifts > 0 {
|
||||
r.Covered = int(float64(r.Shifts-r.Uncovered)/float64(r.Shifts)*100 + 0.5)
|
||||
}
|
||||
allShifts += r.Shifts
|
||||
allUncovered += r.Uncovered
|
||||
sch += r.Late
|
||||
roles = append(roles, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the shift records could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"period": periodOrAll(in.Period),
|
||||
"shifts": allShifts,
|
||||
"uncovered": allUncovered,
|
||||
"lateStarts": sch,
|
||||
"roles": roles,
|
||||
}
|
||||
if allShifts > 0 {
|
||||
data["coveragePercent"] = int(float64(allShifts-allUncovered)/float64(allShifts)*100 + 0.5)
|
||||
} else {
|
||||
data["note"] = "No shifts match that. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func round1(f float64) float64 {
|
||||
return float64(int(f*10+0.5)) / 10
|
||||
}
|
||||
388
go-api/internal/tools/workspace.go
Normal file
388
go-api/internal/tools/workspace.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// The cross-domain tools: the workspace summary, activity signals, training,
|
||||
// and operational risk.
|
||||
//
|
||||
// These are the ones that read more than one resource, and each one authorizes
|
||||
// **per resource** rather than once at the top. That distinction is the whole
|
||||
// of I1 here: a caller who may read shifts but not applications gets the shift
|
||||
// half of the answer and a stated gap, never a blended figure computed over
|
||||
// rows they cannot see. A single check at the entrance would have to pick one
|
||||
// resource to check against, and whichever it picked would be wrong for the
|
||||
// others.
|
||||
|
||||
/* ── Workspace summary ──────────────────────────────────────────────────── */
|
||||
|
||||
// WorkspaceSummary is the one-screen state of the workspace.
|
||||
func WorkspaceSummary(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "workspace_summary",
|
||||
Description: "Read the overall state of the workspace: open roles, applications in " +
|
||||
"flight, workers on the books, shifts recorded and recent activity volume. Use " +
|
||||
"for broad questions about how things are going, and to decide which narrower " +
|
||||
"tool to reach for next.",
|
||||
InputSchema: periodSchema("Unused by this tool."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
in, from, to, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
|
||||
data := map[string]any{"period": periodOrAll(in.Period)}
|
||||
var withheld []string
|
||||
|
||||
// Each count is behind its own resource's policy. A resource the
|
||||
// caller may not list is reported as withheld rather than omitted:
|
||||
// a missing number reads as a zero, and a zero is a claim.
|
||||
counts := []struct {
|
||||
key, resource, table, extra string
|
||||
}{
|
||||
{"openRoles", "job-postings", "job_postings", "status = 'active'"},
|
||||
{"applications", "job-applications", "job_applications", ""},
|
||||
{"workers", "worker-profiles", "worker_profiles", ""},
|
||||
{"shiftsRecorded", "shift-records", "shift_records", ""},
|
||||
{"activityEvents", "user-activity", "user_activity", ""},
|
||||
}
|
||||
for _, c := range counts {
|
||||
q, denied := authorize(tc, c.resource)
|
||||
if denied != nil {
|
||||
withheld = append(withheld, c.key)
|
||||
continue
|
||||
}
|
||||
if c.extra != "" {
|
||||
q.raw(c.extra)
|
||||
}
|
||||
if !from.IsZero() {
|
||||
q.gte(dateColumnFor(c.table), from)
|
||||
q.lt(dateColumnFor(c.table), to)
|
||||
}
|
||||
var n int64
|
||||
if err := db.QueryRow(ctx,
|
||||
`SELECT count(*) FROM `+c.table+` WHERE `+q.clause(), q.args...).Scan(&n); err != nil {
|
||||
return Failf(CodeFailed, "the workspace could not be read")
|
||||
}
|
||||
data[c.key] = n
|
||||
}
|
||||
|
||||
if len(withheld) > 0 {
|
||||
data["withheld"] = withheld
|
||||
data["withheldNote"] = "These figures are not available to this caller and are " +
|
||||
"absent rather than zero. Do not describe them as zero or as empty."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// dateColumnFor names the column a period filters on.
|
||||
//
|
||||
// shift_records is dated by when the shift happened, not by when the row was
|
||||
// written — a shift entered late would otherwise land in the wrong week, which
|
||||
// is exactly the kind of quiet wrongness a rota question cannot tolerate.
|
||||
func dateColumnFor(table string) string {
|
||||
if table == "shift_records" {
|
||||
return "shift_date"
|
||||
}
|
||||
return "created_date"
|
||||
}
|
||||
|
||||
/* ── Activity signals ───────────────────────────────────────────────────── */
|
||||
|
||||
// ActivitySignals surfaces activity that departs from the pattern.
|
||||
func ActivitySignals(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "activity_signals",
|
||||
Description: "Find activity that departs from the usual pattern: days with unusual " +
|
||||
"volume, accounts acting far more than others, and event kinds that appeared " +
|
||||
"for the first time recently. Use only for questions about what looks unusual — " +
|
||||
"for plain counts use activity_breakdown instead.",
|
||||
InputSchema: periodSchema("How many signals to return. Defaults to 10."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorize(tc, "user-activity")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
in, from, to, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
if !from.IsZero() {
|
||||
q.gte("created_date", from)
|
||||
q.lt("created_date", to)
|
||||
}
|
||||
|
||||
// Daily volume, so "unusual" is measured against this workspace's
|
||||
// own baseline rather than a number chosen here. A workspace that
|
||||
// logs 4 events a day and one that logs 4,000 both get a threshold
|
||||
// that means something.
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT date_trunc('day', created_date)::date, count(*)
|
||||
FROM user_activity
|
||||
WHERE `+q.clause()+`
|
||||
GROUP BY 1 ORDER BY 1 ASC`, q.args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the activity log could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type day struct {
|
||||
Day time.Time `json:"day"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var (
|
||||
days []day
|
||||
total int64
|
||||
)
|
||||
for rows.Next() {
|
||||
var d day
|
||||
if err := rows.Scan(&d.Day, &d.Count); err != nil {
|
||||
return Failf(CodeFailed, "the activity log could not be read")
|
||||
}
|
||||
total += d.Count
|
||||
days = append(days, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the activity log could not be read")
|
||||
}
|
||||
|
||||
if len(days) < 3 {
|
||||
// Below three days there is no pattern to depart from. Saying so
|
||||
// is the honest answer; inventing a threshold would produce
|
||||
// confident nonsense on a new workspace.
|
||||
return OK(map[string]any{
|
||||
"period": periodOrAll(in.Period),
|
||||
"days": len(days),
|
||||
"signals": []any{},
|
||||
"note": "There is not enough history to say what is unusual. " +
|
||||
"At least three days of activity are needed before a departure from " +
|
||||
"the pattern means anything.",
|
||||
})
|
||||
}
|
||||
|
||||
mean := float64(total) / float64(len(days))
|
||||
var variance float64
|
||||
for _, d := range days {
|
||||
diff := float64(d.Count) - mean
|
||||
variance += diff * diff
|
||||
}
|
||||
stddev := sqrt(variance / float64(len(days)))
|
||||
|
||||
type signal struct {
|
||||
Kind string `json:"kind"`
|
||||
Day time.Time `json:"day,omitempty"`
|
||||
Detail string `json:"detail"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var signals []signal
|
||||
// Two standard deviations. Flagging ordinary activity trains the
|
||||
// reader to ignore the flag, which is the Activity Agent's own
|
||||
// stated instruction.
|
||||
for _, d := range days {
|
||||
if stddev > 0 && float64(d.Count) > mean+2*stddev {
|
||||
signals = append(signals, signal{
|
||||
Kind: "unusual-volume", Day: d.Day, Count: d.Count,
|
||||
Detail: fmt.Sprintf("%d events against a daily average of %.0f", d.Count, mean),
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(signals) > in.limitOr(10) {
|
||||
signals = signals[:in.limitOr(10)]
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"period": periodOrAll(in.Period),
|
||||
"days": len(days),
|
||||
"averagePerDay": int(mean + 0.5),
|
||||
"signals": signals,
|
||||
"thresholdExplained": "A day is flagged when it exceeds the average by more " +
|
||||
"than two standard deviations of this workspace's own daily volume.",
|
||||
}
|
||||
if len(signals) == 0 {
|
||||
data["note"] = "Nothing departs from the pattern. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// sqrt without importing math for one call.
|
||||
func sqrt(f float64) float64 {
|
||||
if f <= 0 {
|
||||
return 0
|
||||
}
|
||||
x := f
|
||||
for i := 0; i < 24; i++ {
|
||||
x = (x + f/x) / 2
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
/* ── Training ───────────────────────────────────────────────────────────── */
|
||||
|
||||
// WorkforceTraining reports learning progress across the workforce.
|
||||
func WorkforceTraining(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "workforce_training",
|
||||
Description: "Read training and development: how many courses are available, how " +
|
||||
"far the workforce has progressed, average profile completion and experience " +
|
||||
"level. Use for questions about upskilling, course uptake and readiness.",
|
||||
InputSchema: periodSchema("How many courses to list. Defaults to 20."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
in, _, _, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
|
||||
data := map[string]any{}
|
||||
var withheld []string
|
||||
|
||||
if q, denied := authorize(tc, "courses"); denied == nil {
|
||||
var total, active int64
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT count(*), count(*) FILTER (WHERE status = 'active')
|
||||
FROM courses WHERE `+q.clause(), q.args...).Scan(&total, &active); err != nil {
|
||||
return Failf(CodeFailed, "the courses could not be read")
|
||||
}
|
||||
data["courses"] = total
|
||||
data["activeCourses"] = active
|
||||
} else {
|
||||
withheld = append(withheld, "courses")
|
||||
}
|
||||
|
||||
if q, denied := authorize(tc, "worker-profiles"); denied == nil {
|
||||
var (
|
||||
workers int64
|
||||
completion, xp *float64
|
||||
)
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT count(*), avg(profile_completion), avg(xp)
|
||||
FROM worker_profiles WHERE `+q.clause(), q.args...,
|
||||
).Scan(&workers, &completion, &xp); err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
data["workers"] = workers
|
||||
putAvg(data, "averageProfileCompletion", completion)
|
||||
putAvg(data, "averageXP", xp)
|
||||
} else {
|
||||
withheld = append(withheld, "workers")
|
||||
}
|
||||
|
||||
_ = in
|
||||
if len(withheld) > 0 {
|
||||
data["withheld"] = withheld
|
||||
data["withheldNote"] = "These figures are not available to this caller and are " +
|
||||
"absent rather than zero. Do not describe them as zero or as empty."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Operational risk ───────────────────────────────────────────────────── */
|
||||
|
||||
// OperationsRisk finds what is going wrong across domains.
|
||||
func OperationsRisk(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "operations_risk",
|
||||
Description: "Find operational problems across hiring and the workforce at once: " +
|
||||
"strong candidates waiting on a decision, applications nobody has screened, " +
|
||||
"roles open a long time with no strong applicant, and shifts going unworked. " +
|
||||
"Use for questions about what needs attention.",
|
||||
InputSchema: periodSchema("How many findings per category. Defaults to 10."),
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
in, _, _, bad := decodePeriod(inputs)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
limit := in.limitOr(10)
|
||||
|
||||
type finding struct {
|
||||
Kind string `json:"kind"`
|
||||
Detail string `json:"detail"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var (
|
||||
findings []finding
|
||||
withheld []string
|
||||
)
|
||||
|
||||
if q, denied := authorize(tc, "job-applications"); denied == nil {
|
||||
var waiting, unscreened int64
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT count(*) FILTER (WHERE ai_score >= 80 AND status IN ('applied', 'ai_screened', 'shortlisted')),
|
||||
count(*) FILTER (WHERE status = 'applied')
|
||||
FROM job_applications WHERE `+q.clause(), q.args...,
|
||||
).Scan(&waiting, &unscreened); err != nil {
|
||||
return Failf(CodeFailed, "the applications could not be read")
|
||||
}
|
||||
if waiting > 0 {
|
||||
findings = append(findings, finding{
|
||||
Kind: "decision-owed", Count: waiting,
|
||||
Detail: "strong candidates are waiting on a decision",
|
||||
})
|
||||
}
|
||||
if unscreened > 0 {
|
||||
findings = append(findings, finding{
|
||||
Kind: "screening-backlog", Count: unscreened,
|
||||
Detail: "applications have not been screened",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
withheld = append(withheld, "applications")
|
||||
}
|
||||
|
||||
if q, denied := authorize(tc, "shift-records"); denied == nil {
|
||||
var unworked int64
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT count(*) FROM shift_records
|
||||
WHERE `+q.clause()+` AND status IN ('no_show', 'absent')`, q.args...,
|
||||
).Scan(&unworked); err != nil {
|
||||
return Failf(CodeFailed, "the shift records could not be read")
|
||||
}
|
||||
if unworked > 0 {
|
||||
findings = append(findings, finding{
|
||||
Kind: "shifts-unworked", Count: unworked,
|
||||
Detail: "shifts were not worked",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
withheld = append(withheld, "shifts")
|
||||
}
|
||||
|
||||
if len(findings) > limit {
|
||||
findings = findings[:limit]
|
||||
}
|
||||
|
||||
data := map[string]any{"findings": findings}
|
||||
// An empty list means the operation is running, not that the check
|
||||
// did not run. Said explicitly, because those read identically.
|
||||
if len(findings) == 0 && len(withheld) == 0 {
|
||||
data["note"] = "Nothing is flagged. The checks ran and found no problems — " +
|
||||
"this is not a failure to look."
|
||||
}
|
||||
if len(withheld) > 0 {
|
||||
data["withheld"] = withheld
|
||||
data["withheldNote"] = "These areas could not be checked for this caller. " +
|
||||
"Do not describe them as having no problems."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user