feat: publish standalone worker

Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
Gleb Tv
2026-07-13 17:55:14 +03:00
Коммит 2c7a0236da
309 изменённых файлов: 44004 добавлений и 0 удалений

319
internal/wire/types.go Обычный файл
Просмотреть файл

@@ -0,0 +1,319 @@
// Package wire contains shared wire format types for control plane <-> worker communication
package wire
import "encoding/json"
// RegisterRequest is sent to the control plane registration API.
type RegisterRequest struct {
WorkerID string `json:"worker_id" binding:"required"`
RegionCode string `json:"region_code" binding:"required"`
Version string `json:"version"`
URL string `json:"url,omitempty"`
Capabilities []string `json:"capabilities"`
TaskEnvelope bool `json:"task_envelope"`
Concurrency int `json:"concurrency"`
}
// RegisterResponse contains the generated auth token.
type RegisterResponse struct {
AuthToken string `json:"auth_token"`
WorkerID string `json:"worker_id"`
}
// HeartbeatRequest is sent periodically by workers to indicate liveness.
type HeartbeatRequest struct {
ActiveChecks int `json:"active_checks"`
QueueDepth int `json:"queue_depth"`
ActiveNotifications int `json:"active_notifications,omitempty"`
NotificationQueueDepth int `json:"notification_queue_depth,omitempty"`
}
// CheckJob represents a check assigned to a worker
type CheckJob struct {
JobID string `json:"job_id"` // unique job identifier
LeaseToken string `json:"lease_token,omitempty"`
CheckID int64 `json:"check_id"`
MonitorID int64 `json:"monitor_id"`
Kind string `json:"kind"` // "http", "ssl", etc.
Host string `json:"host"`
URL *string `json:"url"`
Interval int `json:"interval"`
Settings json.RawMessage `json:"settings"` // CheckSettings JSON
}
// JobsResponse contains a batch of check jobs for a worker
type JobsResponse struct {
Jobs []CheckJob `json:"jobs"`
}
// CheckResultReport is sent by workers to report check results
type CheckResultReport struct {
JobID string `json:"job_id"`
LeaseToken string `json:"lease_token,omitempty"`
CheckID int64 `json:"check_id"`
MonitorID int64 `json:"monitor_id"`
State string `json:"state"` // "OK", "ERR", "WARN", "FAIL"
Error *string `json:"error"`
Warnings []string `json:"warnings"`
Infos []string `json:"infos"`
DurationMs int64 `json:"duration_ms"`
ExpiresAt *string `json:"expires_at"` // RFC3339 if set
Metrics []MetricPoint `json:"metrics,omitempty"`
}
// MetricPoint is a TSDB point reported by a worker for control-plane persistence.
type MetricPoint struct {
Metric string `json:"metric"`
Tags map[string]string `json:"tags"`
Fields map[string]interface{} `json:"fields"`
}
// ServerMetricReport is a worker-local host snapshot. Workers never receive
// database credentials: the control plane validates worker/server ownership
// then persists this cache and its TSDB points.
type ServerMetricReport struct {
ServerID int64 `json:"server_id"`
CPUPercent *float64 `json:"cpu_percent,omitempty"`
MemUsed *int64 `json:"mem_used,omitempty"`
MemTotal *int64 `json:"mem_total,omitempty"`
DiskUsed *int64 `json:"disk_used,omitempty"`
DiskTotal *int64 `json:"disk_total,omitempty"`
NetRx *int64 `json:"net_rx,omitempty"`
NetTx *int64 `json:"net_tx,omitempty"`
HostUptimeSec *int64 `json:"host_uptime_sec,omitempty"`
Load1 *float64 `json:"load1,omitempty"`
Load5 *float64 `json:"load5,omitempty"`
Load15 *float64 `json:"load15,omitempty"`
ProcessCount *int `json:"process_count,omitempty"`
Processes []ProcessMetric `json:"processes,omitempty"`
Networks []NetworkMetric `json:"networks,omitempty"`
}
// ProcessMetric and NetworkMetric are bounded diagnostic snapshots, not a
// second time series. The worker enforces their limits before sending them.
type ProcessMetric struct {
PID int `json:"pid"`
Name string `json:"name"`
CPUPercent float64 `json:"cpu_percent"`
MemoryRSS int64 `json:"memory_rss"`
}
type NetworkMetric struct {
Interface string `json:"interface"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
}
// ResultsRequest contains multiple check results being reported
type ResultsRequest struct {
Results []CheckResultReport `json:"results"`
}
// LLMConfig is an LLM endpoint configuration sent to workers.
type LLMConfig struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Model string `json:"model"`
APIKey string `json:"api_key,omitempty"`
Kind string `json:"kind"`
}
// SMTPCredential is the wire form of an SMTP notification credential pushed
// to workers via the init/config refresh. See docs/worker-protocol.md
// "Credentials Push".
type SMTPCredential struct {
ID int64 `json:"id"`
Name string `json:"name"`
Server string `json:"server"`
Port int `json:"port"`
Login string `json:"login"`
Password string `json:"password"`
FromName string `json:"from_name"`
FromAddress string `json:"from_address"`
InsecureSkipVerify bool `json:"insecure_skip_verify"`
}
// TelegramCredential is the wire form of a Telegram bot credential pushed
// to workers via the init/config refresh.
type TelegramCredential struct {
ID int64 `json:"id"`
Name string `json:"name"`
BotName string `json:"bot_name"`
Token string `json:"token"`
APIURL string `json:"api_url"`
}
// WebhookCredential is the wire form of the shared webhook signing secret
// (one global secret per environment; the contact URL is per-task). Phase 4
// will move to per-account secrets.
type WebhookCredential struct {
SigningSecret string `json:"signing_secret"`
}
// MattermostCredential is the wire form of the shared Mattermost default
// webhook. Per-account credentials (Phase 4) override this when present.
type MattermostCredential struct {
DefaultUsername string `json:"default_username,omitempty"`
DefaultIconURL string `json:"default_icon_url,omitempty"`
}
// NotificationCredentials groups all notification credentials the worker
// is allowed to use, pushed via init.credentials (and every 5m config refresh).
// The shape matches the per-method arrays shown in docs/worker-protocol.md
// "Credentials Push" so the worker can resolve a credential by method directly.
type NotificationCredentials struct {
SMTP []SMTPCredential `json:"smtp,omitempty"`
Telegram []TelegramCredential `json:"telegram,omitempty"`
Webhook *WebhookCredential `json:"webhook,omitempty"`
Mattermost *MattermostCredential `json:"mattermost,omitempty"`
}
// SystemContact is the wire form of a Contact flagged is_system=true.
// Workers notify these contacts directly when the main API is
// unreachable, bypassing the central tasks queue. See
// docs/distributed/notifications-from-worker.md "System Selfcheck".
type SystemContact struct {
ID int64 `json:"id"`
Kind string `json:"kind"` // "email", "telegram_private", "telegram_group"
Value string `json:"value"` // email address or numeric telegram chat id
Name string `json:"name"`
}
// PeerInfo is the wire form of a peer worker this worker can reach
// over HTTP for cross-worker confirmation (see
// docs/distributed/worker-to-worker-raft.md and
// docs/plans/network-diagnostics-partial.md §4.1). The control plane
// pushes one entry per other active worker via WorkerInit.Peers; the
// current worker is excluded so a worker never dials itself.
//
// Login/Password are populated only when the peer has basic auth
// configured (WORKER_LOGIN / WORKER_PASSWORD). They are the same
// shared secret every worker is configured with locally, so pushing
// them per-peer is safe inside a trusted control-plane / worker fleet
// and avoids the per-peer registration ceremony. The fields are
// omitted from JSON when empty so a legacy control plane that does
// not yet fill them in still produces a wire-compatible payload.
type PeerInfo struct {
WorkerID string `json:"worker_id"`
URL string `json:"url"`
RegionCode string `json:"region_code,omitempty"`
Login string `json:"login,omitempty"`
Password string `json:"password,omitempty"`
}
// WorkerInit is sent by the control plane after websocket authentication.
type WorkerInit struct {
WorkerID string `json:"worker_id"`
RegionCode string `json:"region_code"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
NotificationMethods []string `json:"notification_methods,omitempty"`
NotificationAccounts []int64 `json:"notification_accounts,omitempty"`
Concurrency int `json:"concurrency"`
URL string `json:"url,omitempty"`
ServerID *int64 `json:"server_id,omitempty"`
LLMs []LLMConfig `json:"llms,omitempty"`
Credentials *NotificationCredentials `json:"credentials,omitempty"`
SystemContacts []SystemContact `json:"system_contacts,omitempty"`
// Peers lists the other active workers this node can reach over
// HTTP for cross-worker confirmation / Raft-style consensus on
// the master API selfcheck. The current worker is excluded by
// the control plane; a worker that receives an empty list treats
// the selfcheck as a single-node decision (no peer polling).
Peers []PeerInfo `json:"peers,omitempty"`
}
// TaskEnvelope is the task frame the control plane sends on the worker
// websocket. It is a tagged union over (check | notification); the existing
// check shape is preserved so workers built against the original CheckJob
// envelope keep working until they are rebuilt.
//
// Phase 1 of docs/plans/worker-notifier-mvp.md only emits NotificationTask
// envelopes; the check kind remains the legacy Task CheckJob path.
type TaskEnvelope struct {
Type string `json:"type"` // "check" | "notification"
JobID string `json:"job_id"` // mirrors the inner Job.ID for dispatch
Job *CheckJob `json:"check,omitempty"` // check payload (existing path)
Notify *NotificationTask `json:"notification,omitempty"` // notification payload (new path)
}
const (
TaskTypeCheck = "check"
TaskTypeNotification = "notification"
)
// NotificationTask is the wire shape of one notification delivery attempt.
// It carries everything the worker needs to render and send one message
// without going back to the database: pre-rendered subject/body, the contact
// endpoint, and the notification/monitor context for audit logging.
type NotificationTask struct {
JobID string `json:"job_id"`
LeaseToken string `json:"lease_token,omitempty"`
AccountID int64 `json:"account_id"`
MessageID int64 `json:"message_id"`
NotificationID int64 `json:"notification_id"`
EventIDs []int64 `json:"event_ids"`
CheckID *int64 `json:"check_id,omitempty"`
MonitorID *int64 `json:"monitor_id,omitempty"`
CredentialID *int64 `json:"credential_id,omitempty"`
Method string `json:"method"` // "email" | "telegram" | "webhook" | "mattermost" | "sms" | "voice"
Contact NotificationContact `json:"contact"`
Subject string `json:"subject"`
BodyText string `json:"body_text"`
BodyMarkdown string `json:"body_markdown"`
BodyHTML string `json:"body_html"`
Language string `json:"language,omitempty"`
MessageKind string `json:"message_kind"` // "down" | "up" | "exp" | "test"
Deadline *string `json:"deadline,omitempty"`
}
// NotificationContact is the wire form of Contact. Kept minimal so the
// worker can deliver without pulling additional DB rows.
type NotificationContact struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Value string `json:"value"`
Name string `json:"name"`
}
// NotificationResultReport is the wire shape returned by the worker after
// attempting one NotificationTask. Status drives the control-plane result
// handler (see docs/plans/worker-notifier-mvp.md section 5.3).
type NotificationResultReport struct {
JobID string `json:"job_id"`
LeaseToken string `json:"lease_token,omitempty"`
MessageID int64 `json:"message_id"`
Status string `json:"status"` // "delivered" | "retryable" | "permanent" | "partial"
ProviderResponse *string `json:"provider_response,omitempty"`
DurationMs int `json:"duration_ms"`
Error *string `json:"error,omitempty"`
RetryAfterSeconds *int `json:"retry_after_seconds,omitempty"`
}
// Notification result status enums. Mirror the wire JSON values so the
// control-plane handler and the worker executor can speak the same language
// without translating strings.
const (
NotificationResultDelivered = "delivered"
NotificationResultRetryable = "retryable"
NotificationResultPermanent = "permanent"
NotificationResultPartial = "partial"
)
// WorkerMessage is the websocket envelope used by control plane and workers.
// TaskEnvelope carries normal generic tasks. The legacy Task and
// NotificationTask fields remain accepted by workers during rollout.
type WorkerMessage struct {
Kind string `json:"kind"`
Init *WorkerInit `json:"init,omitempty"`
Task *CheckJob `json:"task,omitempty"`
TaskEnvelope *TaskEnvelope `json:"task_envelope,omitempty"`
NotificationTask *NotificationTask `json:"notification_task,omitempty"`
Event *json.RawMessage `json:"event,omitempty"`
Result *CheckResultReport `json:"result,omitempty"`
NotificationResult *NotificationResultReport `json:"notification_result,omitempty"`
ServerMetric *ServerMetricReport `json:"server_metric,omitempty"`
Heartbeat *HeartbeatRequest `json:"heartbeat,omitempty"`
Error string `json:"error,omitempty"`
}

305
internal/wire/types_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,305 @@
package wire
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestWorkerInit_CredentialsJSON verifies that WorkerInit with Credentials
// round-trips through JSON with the expected nested structure.
func TestWorkerInit_CredentialsJSON(t *testing.T) {
init := WorkerInit{
WorkerID: "worker-1",
RegionCode: "ru",
Version: "v1",
Concurrency: 4,
Credentials: &NotificationCredentials{
SMTP: []SMTPCredential{{
ID: 11,
Name: "primary",
Server: "smtp.example.com",
Port: 587,
Login: "alerts@example.com",
Password: "smtp-password-xyz",
FromName: "RSMon Alerts",
FromAddress: "alerts@example.com",
}},
Telegram: []TelegramCredential{{
ID: 22,
Name: "main-bot",
BotName: "rsmon_alerts_bot",
Token: "bot-token-9876543210:ABCDEFG",
APIURL: "https://api.telegram.org",
}},
},
}
data, err := json.Marshal(init)
require.NoError(t, err)
out := string(data)
assert.Contains(t, out, `"credentials":{`,
"credentials must serialize as a top-level object, got %s", out)
assert.Contains(t, out, `"smtp":[`,
"credentials.smtp must be an array, got %s", out)
assert.Contains(t, out, `"telegram":[`,
"credentials.telegram must be an array, got %s", out)
assert.Contains(t, out, `"smtp-password-xyz"`,
"smtp password must round-trip, got %s", out)
assert.Contains(t, out, `"bot-token-9876543210:ABCDEFG"`,
"telegram token must round-trip, got %s", out)
var decoded WorkerInit
require.NoError(t, json.Unmarshal(data, &decoded))
require.NotNil(t, decoded.Credentials,
"Credentials must unmarshal back into a non-nil pointer")
require.Len(t, decoded.Credentials.SMTP, 1)
require.Len(t, decoded.Credentials.Telegram, 1)
assert.Equal(t, "primary", decoded.Credentials.SMTP[0].Name)
assert.Equal(t, "smtp-password-xyz", decoded.Credentials.SMTP[0].Password)
assert.Equal(t, 587, decoded.Credentials.SMTP[0].Port)
assert.Equal(t, "main-bot", decoded.Credentials.Telegram[0].Name)
assert.Equal(t, "bot-token-9876543210:ABCDEFG", decoded.Credentials.Telegram[0].Token)
}
// TestWorkerInit_CredentialsOmittedWhenNil verifies the omitempty contract:
// a WorkerInit with no credentials must not serialize the credentials key.
func TestWorkerInit_CredentialsOmittedWhenNil(t *testing.T) {
init := WorkerInit{WorkerID: "w-1", Concurrency: 1}
data, err := json.Marshal(init)
require.NoError(t, err)
assert.False(t, strings.Contains(string(data), `"credentials"`),
"credentials key must be omitted when nil, got %s", string(data))
}
// TestNotificationCredentials_EmptySlicesOmitted ensures both inner slices
// honor omitempty so an empty config is a compact object.
func TestNotificationCredentials_EmptySlicesOmitted(t *testing.T) {
creds := NotificationCredentials{}
data, err := json.Marshal(creds)
require.NoError(t, err)
out := string(data)
assert.False(t, strings.Contains(out, `"smtp"`),
"empty smtp slice must be omitted, got %s", out)
assert.False(t, strings.Contains(out, `"telegram"`),
"empty telegram slice must be omitted, got %s", out)
}
// TestWorkerInit_URLRoundTrip verifies that the URL field added in Task 2
// round-trips through JSON in both directions and honors omitempty when
// empty so legacy workers that do not push a URL stay wire-compatible.
func TestWorkerInit_URLRoundTrip(t *testing.T) {
init := WorkerInit{
WorkerID: "worker-1",
RegionCode: "ru",
Version: "v1",
Concurrency: 4,
URL: "https://worker-eu.example.com",
}
data, err := json.Marshal(init)
require.NoError(t, err)
assert.Contains(t, string(data), `"url":"https://worker-eu.example.com"`,
"URL must serialize as a top-level url field, got %s", string(data))
var decoded WorkerInit
require.NoError(t, json.Unmarshal(data, &decoded))
assert.Equal(t, "https://worker-eu.example.com", decoded.URL,
"URL must round-trip back into the WorkerInit struct")
}
// TestWorkerInit_URLOmittedWhenEmpty ensures the omitempty contract for
// the URL field: a worker that does not yet advertise a URL must not
// push an empty url key to the control plane.
func TestWorkerInit_URLOmittedWhenEmpty(t *testing.T) {
init := WorkerInit{WorkerID: "w-1", Concurrency: 1}
data, err := json.Marshal(init)
require.NoError(t, err)
assert.False(t, strings.Contains(string(data), `"url"`),
"empty URL must be omitted from the JSON envelope, got %s", string(data))
}
// TestRegisterRequest_URLRoundTrip mirrors the init test for the
// registration payload sent at POST /api/internal/workers/register.
func TestRegisterRequest_URLRoundTrip(t *testing.T) {
req := RegisterRequest{
WorkerID: "worker-eu-1",
RegionCode: "eu",
Version: "v1",
URL: "https://worker-eu.example.com",
}
data, err := json.Marshal(req)
require.NoError(t, err)
assert.Contains(t, string(data), `"url":"https://worker-eu.example.com"`,
"URL must serialize into the register payload, got %s", string(data))
var decoded RegisterRequest
require.NoError(t, json.Unmarshal(data, &decoded))
assert.Equal(t, "https://worker-eu.example.com", decoded.URL)
}
// TestRegisterRequest_URLOmittedWhenEmpty guards the same wire
// compatibility for the register payload.
func TestRegisterRequest_URLOmittedWhenEmpty(t *testing.T) {
req := RegisterRequest{WorkerID: "w-1", RegionCode: "ru"}
data, err := json.Marshal(req)
require.NoError(t, err)
assert.False(t, strings.Contains(string(data), `"url"`),
"empty URL must be omitted from the register payload, got %s", string(data))
}
// TestWorkerInit_NotificationCapabilitiesRoundTrip ensures the
// notification_methods + notification_accounts fields added by
// docs/plans/worker-notifier-mvp.md section 5.4 round-trip through JSON so
// the worker can read them after the init push.
func TestWorkerInit_NotificationCapabilitiesRoundTrip(t *testing.T) {
init := WorkerInit{
WorkerID: "w-1",
RegionCode: "production",
Concurrency: 8,
NotificationMethods: []string{"email", "telegram", "webhook", "mattermost"},
NotificationAccounts: []int64{7, 8, 9},
}
data, err := json.Marshal(init)
require.NoError(t, err)
body := string(data)
assert.Contains(t, body, `"notification_methods":["email","telegram","webhook","mattermost"]`)
assert.Contains(t, body, `"notification_accounts":[7,8,9]`)
var decoded WorkerInit
require.NoError(t, json.Unmarshal(data, &decoded))
assert.Equal(t, init.NotificationMethods, decoded.NotificationMethods)
assert.Equal(t, init.NotificationAccounts, decoded.NotificationAccounts)
}
// TestWorkerInit_NotificationCapabilitiesOmittedWhenEmpty guards the
// omitempty contract for the new fields so a worker that does not advertise
// notification capabilities stays wire-compatible.
func TestWorkerInit_NotificationCapabilitiesOmittedWhenEmpty(t *testing.T) {
init := WorkerInit{WorkerID: "w-1", RegionCode: "ru", Concurrency: 1}
data, err := json.Marshal(init)
require.NoError(t, err)
assert.False(t, strings.Contains(string(data), `"notification_methods"`))
assert.False(t, strings.Contains(string(data), `"notification_accounts"`))
}
// TestNotificationTask_RoundTrip verifies the new notification task payload
// carries the full envelope (contact, method, pre-rendered body) so the
// worker can deliver without touching the database.
func TestNotificationTask_RoundTrip(t *testing.T) {
task := NotificationTask{
JobID: "550e8400-e29b-41d4-a716-446655440000",
AccountID: 42,
MessageID: 12345,
NotificationID: 678,
EventIDs: []int64{987, 988},
CredentialID: int64Ptr(77),
Method: "email",
Contact: NotificationContact{
ID: 1,
Kind: "email",
Value: "ops@example.com",
Name: "Ops on-call",
},
Subject: "[rsmon] example.com is down",
BodyText: "Down since 2026-06-24T12:00Z.",
BodyMarkdown: "**Down** since 2026-06-24T12:00Z.",
BodyHTML: "<p><strong>Down</strong> since 2026-06-24T12:00Z.</p>",
Language: "en",
MessageKind: "down",
}
data, err := json.Marshal(task)
require.NoError(t, err)
var decoded NotificationTask
require.NoError(t, json.Unmarshal(data, &decoded))
assert.Equal(t, task.JobID, decoded.JobID)
assert.Equal(t, task.AccountID, decoded.AccountID)
assert.Equal(t, task.MessageID, decoded.MessageID)
assert.Equal(t, task.Method, decoded.Method)
assert.Equal(t, task.CredentialID, decoded.CredentialID)
assert.Equal(t, task.Contact, decoded.Contact)
assert.Equal(t, task.Subject, decoded.Subject)
assert.Equal(t, task.BodyHTML, decoded.BodyHTML)
assert.Equal(t, task.EventIDs, decoded.EventIDs)
assert.Equal(t, task.MessageKind, decoded.MessageKind)
}
func int64Ptr(v int64) *int64 {
return &v
}
// TestNotificationResultReport_RoundTrip ensures the result report carries
// every field the control-plane result handler relies on (status,
// retry_after, provider_response).
func TestNotificationResultReport_RoundTrip(t *testing.T) {
respStr := "250 OK id=1234"
errStr := "smtp 421 retry-after: 60"
retry := 60
report := NotificationResultReport{
JobID: "550e8400-e29b-41d4-a716-446655440000",
MessageID: 12345,
Status: "retryable",
ProviderResponse: &respStr,
DurationMs: 240,
Error: &errStr,
RetryAfterSeconds: &retry,
}
data, err := json.Marshal(report)
require.NoError(t, err)
body := string(data)
assert.Contains(t, body, `"status":"retryable"`)
assert.Contains(t, body, `"retry_after_seconds":60`)
assert.Contains(t, body, `"duration_ms":240`)
var decoded NotificationResultReport
require.NoError(t, json.Unmarshal(data, &decoded))
assert.Equal(t, report.JobID, decoded.JobID)
assert.Equal(t, report.Status, decoded.Status)
assert.Equal(t, "250 OK id=1234", *decoded.ProviderResponse)
require.NotNil(t, decoded.RetryAfterSeconds)
assert.Equal(t, 60, *decoded.RetryAfterSeconds)
require.NotNil(t, decoded.Error)
assert.Equal(t, errStr, *decoded.Error)
}
// TestWorkerMessage_TaskEnvelopeBranches confirms both the rollout-compatible
// legacy CheckJob frame and the generic task envelope.
func TestWorkerMessage_TaskEnvelopeBranches(t *testing.T) {
legacy := WorkerMessage{
Kind: "task",
Task: &CheckJob{JobID: "job-1", CheckID: 11, Kind: "http", Host: "example.com"},
}
data, err := json.Marshal(legacy)
require.NoError(t, err)
body := string(data)
assert.Contains(t, body, `"task":{"job_id":"job-1","check_id":11`,
"legacy frame must carry the task payload, got %s", body)
assert.False(t, strings.Contains(body, `"notification_task"`),
"legacy check frame must not surface the notification fields")
assert.False(t, strings.Contains(body, `"notification_result"`),
"legacy check frame must not surface the notification fields")
notification := WorkerMessage{
Kind: "task",
TaskEnvelope: &TaskEnvelope{
Type: TaskTypeNotification, JobID: "job-2", Notify: &NotificationTask{
JobID: "job-2", Method: "email", MessageKind: "down", Subject: "down",
Contact: NotificationContact{ID: 1, Kind: "email", Value: "ops@example.com"},
},
},
}
data2, err := json.Marshal(notification)
require.NoError(t, err)
assert.Contains(t, string(data2), `"task_envelope":{"type":"notification","job_id":"job-2"`)
assert.False(t, strings.Contains(string(data2), `"check":`),
"notification frame must not surface the legacy check field")
}