Merge branch 'fix/g10' into fix/integrated
This commit is contained in:
@@ -307,9 +307,13 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
||||
return "", false
|
||||
}
|
||||
if h.home == nil {
|
||||
// Claim the turn rather than fall through: "дом не подключён" is true,
|
||||
// and letting general knowledge answer would be an invented house.
|
||||
return "дом не подключён — я его не вижу.", true
|
||||
// Fall through rather than claim the turn. A capability that is off
|
||||
// must not change what an unconfigured box answers: "какая температура
|
||||
// в доме?" on a Maven with no smarthome block reached recall before
|
||||
// this source existed, and a stored fact is a better answer than
|
||||
// "дом не подключён" from a house that was never configured. The
|
||||
// unreachable case is different and homeSummary covers it.
|
||||
return "", false
|
||||
}
|
||||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -325,9 +329,9 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
// Claim the turn: "сканирование не настроено" is true, and general
|
||||
// knowledge would answer with an invented list of devices.
|
||||
return "сканирование сети не настроено.", true
|
||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
||||
// swallow "сколько устройств в сети?" before recall has looked.
|
||||
return "", false
|
||||
}
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
|
||||
+79
-4
@@ -40,6 +40,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -54,7 +55,12 @@ import (
|
||||
// off (a negative config.intake_journal). nil is the "behave exactly as before"
|
||||
// value all the way down: no decorator, no ring, no /events rows.
|
||||
func newEventBus(cfg *config.Config) *event.Bus {
|
||||
if cfg == nil || cfg.IntakeJournal < 0 {
|
||||
if cfg == nil {
|
||||
// No config at all is a test, not an operator decision. Saying "off"
|
||||
// here was noise in every suite that passes nil.
|
||||
return nil
|
||||
}
|
||||
if cfg.IntakeJournal < 0 {
|
||||
log.Printf("intake journal: off (intake_journal < 0)")
|
||||
return nil
|
||||
}
|
||||
@@ -85,6 +91,7 @@ func intakeEventsFn(bus *event.Bus) func(n int) []ipc.IntakeEvent {
|
||||
Body: e.Body,
|
||||
Priority: e.Priority,
|
||||
OccurredAt: e.OccurredAt,
|
||||
NoticedAt: e.NoticedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -120,18 +127,34 @@ func (a *intakeAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64,
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
if selfWrite(req) {
|
||||
// Maven's own bookkeeping is not something that arrived. The feed
|
||||
// watermark, the crawl hash, the praxis trace of an act she performed
|
||||
// and a quiet-hours toggle he pressed all used to sit on a page headed
|
||||
// "everything that arrived", and on a cold start a handful of feeds
|
||||
// could evict real intake behind their marks.
|
||||
return id, nil
|
||||
}
|
||||
// OccurredAt is req.Ts, not now: mavpoll's wg read carries the handshake
|
||||
// instant and the ambient path carries the meeting's start. Flattening
|
||||
// those to notice-time would make the journal lie about when things
|
||||
// happened, which is the one thing it is for.
|
||||
title := req.Key
|
||||
if req.VoidsID != nil {
|
||||
// A retraction is not a reading. Without this it published an envelope
|
||||
// indistinguishable from a fresh value for the same key, on a page
|
||||
// whose whole job is "what came in".
|
||||
title = "отмена: " + req.Key
|
||||
}
|
||||
a.bus.Publish(event.Event{
|
||||
Source: req.Source,
|
||||
Kind: event.SourceKind(req.Source, event.KindFact),
|
||||
Title: req.Key,
|
||||
Title: title,
|
||||
Body: req.Value,
|
||||
Priority: factPriority(req),
|
||||
OccurredAt: req.Ts,
|
||||
EntityIDs: entityIDs(req.Subject),
|
||||
Payload: factPayload(req),
|
||||
}, a.now())
|
||||
return id, nil
|
||||
}
|
||||
@@ -201,17 +224,69 @@ func publishableTask(t store.Task, now time.Time) event.Event {
|
||||
}
|
||||
}
|
||||
|
||||
// selfWrite reports whether a fact write is Maven describing her own state
|
||||
// rather than something arriving from outside. The store's fact kinds are
|
||||
// 'self', 'env' and 'config'; 'config' is where every watermark and toggle
|
||||
// lands, and the praxis trace is an audit record of an act she performed, which
|
||||
// is the same class of thing under an 'env' kind.
|
||||
func selfWrite(req ipc.WriteFactReq) bool {
|
||||
switch req.Kind {
|
||||
case "config", "system":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(req.Source, "praxis:trace")
|
||||
}
|
||||
|
||||
// factPriority is the attention hint for a fact write. Deliberately crude:
|
||||
// a low-confidence inference (the ambient notification path writes below 1.0)
|
||||
// is worth less attention than a read he or a credentialled poller made, and
|
||||
// nothing else is distinguishable from here.
|
||||
// is worth less attention than a read he or a credentialled poller made, and a
|
||||
// retraction is a correction rather than news.
|
||||
//
|
||||
// Confidence is NOT recoverable from this, which is why the number itself goes
|
||||
// into Payload: three display buckets must not be the only surviving trace of
|
||||
// the distinction internal/calendar went out of its way to keep.
|
||||
func factPriority(req ipc.WriteFactReq) string {
|
||||
if req.VoidsID != nil {
|
||||
return event.PriorityLow
|
||||
}
|
||||
if req.Confidence > 0 && req.Confidence < 1.0 {
|
||||
return event.PriorityLow
|
||||
}
|
||||
return event.PriorityNormal
|
||||
}
|
||||
|
||||
// factDetail is the fact-shaped Payload: the fields the envelope's own flat
|
||||
// shape cannot carry, kept so a reader can tell an inference from a
|
||||
// credentialled read, and "nobody said" from "certain".
|
||||
type factDetail struct {
|
||||
// FactKind — the fact's own kind ('self', 'env', 'config'), a different
|
||||
// taxonomy from Event.Kind.
|
||||
FactKind string `json:"fact_kind,omitempty"`
|
||||
// Confidence — the number itself, so an inference stays distinguishable
|
||||
// from a credentialled read. nil when the writer set none, which the ipc
|
||||
// layer rejects today; the pointer keeps "nobody said" and "certain" from
|
||||
// collapsing into each other the way the priority bucket does.
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
// VoidsID — the fact this one retracts.
|
||||
VoidsID *int64 `json:"voids_id,omitempty"`
|
||||
}
|
||||
|
||||
func factPayload(req ipc.WriteFactReq) json.RawMessage {
|
||||
d := factDetail{FactKind: req.Kind, VoidsID: req.VoidsID}
|
||||
if req.Confidence != 0 {
|
||||
c := req.Confidence
|
||||
d.Confidence = &c
|
||||
}
|
||||
if d.FactKind == "" && d.Confidence == nil && d.VoidsID == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// entityIDs turns a fact's free-text Subject into the EntityIDs slot when it
|
||||
// already looks resolved. Intake runs BEFORE the fact enrichment worker
|
||||
// resolves a subject against Nexus, so this is almost always empty — the slot
|
||||
|
||||
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -176,3 +178,110 @@ func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) {
|
||||
t.Errorf("got %d events, want none", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Maven's own bookkeeping is not intake. The feed watermark, the crawl hash,
|
||||
// the praxis trace of an act she performed and the quiet-hours toggle he
|
||||
// pressed all landed on a page headed "everything that arrived", and on a cold
|
||||
// start a handful of feeds could evict real intake behind their marks.
|
||||
func TestIntakeSkipsHerOwnBookkeeping(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
for _, req := range []ipc.WriteFactReq{
|
||||
{Ts: intakeNow, Kind: "config", Key: "rss:latest:tech", Value: "2026-08-01T09:00:00Z", Source: "poll:rss", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "config", Key: "crawl:hash:kernel", Value: "deadbeef", Source: "poll:crawl", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "config", Key: "quiet_hours", Value: "true", Source: "tap:voice", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "env", Key: "praxis:list_attention", Value: "ok", Source: "praxis:trace", Confidence: 1.0},
|
||||
} {
|
||||
if _, err := api.WriteFact(ctx, req); err != nil {
|
||||
t.Fatalf("WriteFact(%s): %v", req.Key, err)
|
||||
}
|
||||
}
|
||||
if n := bus.Len(); n != 0 {
|
||||
t.Fatalf("journalled %d bookkeeping writes, want 0: %+v", n, bus.Recent(0))
|
||||
}
|
||||
// A real arrival under the same decorator still lands.
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "spend_today", Value: "1200",
|
||||
Source: "poll:zenmoney", Confidence: 1.0,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bus.Len() != 1 {
|
||||
t.Fatalf("a real intake write was dropped: %+v", bus.Recent(0))
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence is the distinction between an inference and a credentialled read,
|
||||
// and the three-value priority bucket cannot carry it: unset and 1.0 land in
|
||||
// the same bucket, and 0.6 is gone entirely once mapped. Payload keeps it.
|
||||
func TestIntakeCarriesConfidenceAndFactKind(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "calendar_event_x", Value: "18:00 планёрка",
|
||||
Source: "ambient:notif", Confidence: 0.6,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "self", Key: "mood", Value: "ok", Source: "tap:web", Confidence: 1.0,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := bus.Recent(0)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("journal has %d entries, want 2", len(got))
|
||||
}
|
||||
var relayed, stated factDetail
|
||||
if err := json.Unmarshal(got[1].Payload, &relayed); err != nil {
|
||||
t.Fatalf("payload: %v", err)
|
||||
}
|
||||
if relayed.Confidence == nil || *relayed.Confidence != 0.6 {
|
||||
t.Errorf("confidence = %v, want 0.6 recoverable from the payload", relayed.Confidence)
|
||||
}
|
||||
if relayed.FactKind != "env" {
|
||||
t.Errorf("fact_kind = %q, want env", relayed.FactKind)
|
||||
}
|
||||
// Both writes land in PriorityNormal or PriorityLow buckets that cannot be
|
||||
// told apart from the outside; the payload is where the two numbers stay
|
||||
// distinguishable.
|
||||
if err := json.Unmarshal(got[0].Payload, &stated); err != nil {
|
||||
t.Fatalf("payload: %v", err)
|
||||
}
|
||||
if stated.Confidence == nil || *stated.Confidence != 1.0 || stated.FactKind != "self" {
|
||||
t.Errorf("payload = %+v, want confidence 1.0 and fact_kind self", stated)
|
||||
}
|
||||
}
|
||||
|
||||
// A retraction is not an observation. It used to publish an envelope
|
||||
// indistinguishable from a fresh reading of the same key.
|
||||
func TestIntakeMarksARetraction(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
id, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "weight", Value: "82", Source: "tap:web", Confidence: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "weight", Value: "81", Source: "tap:web",
|
||||
Confidence: 1.0, VoidsID: &id,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := bus.Recent(1)[0]
|
||||
if e.Priority != event.PriorityLow {
|
||||
t.Errorf("priority = %q, want low for a correction", e.Priority)
|
||||
}
|
||||
if !strings.HasPrefix(e.Title, "отмена:") {
|
||||
t.Errorf("title = %q, want it marked as a retraction", e.Title)
|
||||
}
|
||||
var d factDetail
|
||||
if err := json.Unmarshal(e.Payload, &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.VoidsID == nil || *d.VoidsID != id {
|
||||
t.Errorf("voids_id = %v, want %d", d.VoidsID, id)
|
||||
}
|
||||
}
|
||||
|
||||
+157
-21
@@ -5,20 +5,33 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
)
|
||||
|
||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||
// longer than this has already failed as a turn, so the scan returns whatever
|
||||
// it found rather than keeping him waiting.
|
||||
const scanBudget = 20 * time.Second
|
||||
//
|
||||
// It has to be consistent with the shipped defaults or every scan is truncated:
|
||||
// a /24 at four ports is 1016 probes, which at netscan.DefaultRate of 100 a
|
||||
// second is a little over ten seconds plus the tail dials. 30s leaves room for
|
||||
// that without pretending a slower rate would fit.
|
||||
const scanBudget = 30 * time.Second
|
||||
|
||||
// scanReadOut — how many hosts she names out loud. The rest are a count: a
|
||||
// spoken list of twenty IP addresses is not an answer.
|
||||
const scanReadOut = 6
|
||||
// scanCacheTTL — how long a scan answer is reused. Two questions in a row used
|
||||
// to be two full sweeps of the LAN, up to a thousand connections each. The
|
||||
// network does not change on the scale of a follow-up question, and the cheapest
|
||||
// packet is the one not sent.
|
||||
const scanCacheTTL = 2 * time.Minute
|
||||
|
||||
// scanReadOut — how many hosts go into the written record's first lines before
|
||||
// it says "и ещё N". Nothing reads addresses out loud; see scanSummary.
|
||||
const scanReadOut = 20
|
||||
|
||||
// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
|
||||
// never puts a discovery packet on the network.
|
||||
@@ -30,10 +43,21 @@ const scanReadOut = 6
|
||||
type netWiring struct {
|
||||
scanner *netscan.Scanner
|
||||
subnets []string
|
||||
// api — where the address list is WRITTEN. The spoken answer is a count
|
||||
// and a shape, so the detail has to land somewhere readable; a note under
|
||||
// source "scan:lan" puts it on /history and, through the intake decorator,
|
||||
// on /events. It is also the only record that Maven put packets on the LAN
|
||||
// at all. nil ⇒ nothing is written, which is what the tests use.
|
||||
api ipc.CoreAPI
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cached netscan.Result
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
|
||||
func wireNetScan(cfg *config.Config) *netWiring {
|
||||
func wireNetScan(cfg *config.Config, api ipc.CoreAPI) *netWiring {
|
||||
nc, ok := cfg.NetScanner()
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -45,29 +69,125 @@ func wireNetScan(cfg *config.Config) *netWiring {
|
||||
log.Printf("netscan: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets}
|
||||
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets, api: api, now: time.Now}
|
||||
}
|
||||
|
||||
// scanSummary answers "какие устройства в сети?" in one line.
|
||||
// scan runs a scan, or reuses one younger than scanCacheTTL.
|
||||
func (w *netWiring) scan(ctx context.Context) (netscan.Result, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
now := w.now()
|
||||
if !w.cachedAt.IsZero() && now.Sub(w.cachedAt) < scanCacheTTL {
|
||||
return w.cached, nil
|
||||
}
|
||||
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
defer cancel()
|
||||
res, err := w.scanner.Scan(scanCtx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
w.cached, w.cachedAt = res, now
|
||||
// Written on a fresh scan only: the record is a trace of packets going out,
|
||||
// so a cached answer must not forge a second one.
|
||||
w.writeScanRecord(ctx, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// scanSummary answers "какие устройства в сети?" in one spoken line.
|
||||
//
|
||||
// It does NOT read addresses out. This is the query path, so the reply goes to
|
||||
// piper as well as to /chat, and "192.168.1.1 (80, 443); 192.168.1.14 (22)" is
|
||||
// a digit stream nobody can follow through a speaker. She says how many and
|
||||
// what shape they are; the addresses go into a note (see writeScanRecord).
|
||||
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
if w == nil {
|
||||
return "", false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
defer cancel()
|
||||
hosts, err := w.scanner.Scan(ctx)
|
||||
res, err := w.scan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("netscan: scan: %v", err)
|
||||
return "не получилось просканировать сеть.", true
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return "в сети никого не нашла.", true
|
||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||
// устройств" after stopping two thirds of the way through the range is a
|
||||
// false claim, and the addresses at the end are the ones that go missing.
|
||||
tail := ""
|
||||
if res.Truncated {
|
||||
tail = ", но успела посмотреть не всю сеть"
|
||||
}
|
||||
shown := hosts
|
||||
if len(res.Hosts) == 0 {
|
||||
return "в сети никого не нашла" + tail + ".", true
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
out += ", " + shape
|
||||
}
|
||||
out += tail
|
||||
if w.api != nil {
|
||||
out += ". список записала"
|
||||
}
|
||||
return out + ".", true
|
||||
}
|
||||
|
||||
// scanShape describes the hosts by what they answer on, which is the part of
|
||||
// the answer that carries meaning out loud: "два с вебом" says more about the
|
||||
// flat than four octets do.
|
||||
func scanShape(hosts []netscan.Host) string {
|
||||
var web, ssh, quiet int
|
||||
for _, h := range hosts {
|
||||
hasWeb, hasSSH := false, false
|
||||
for _, p := range h.Ports {
|
||||
switch p {
|
||||
case 80, 443, 8080:
|
||||
hasWeb = true
|
||||
case 22:
|
||||
hasSSH = true
|
||||
}
|
||||
}
|
||||
if hasWeb {
|
||||
web++
|
||||
}
|
||||
if hasSSH {
|
||||
ssh++
|
||||
}
|
||||
// No open port at all: seen only through the ARP cache.
|
||||
if len(h.Ports) == 0 {
|
||||
quiet++
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
if web > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d с вебом", web))
|
||||
}
|
||||
if ssh > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d с ssh", ssh))
|
||||
}
|
||||
if quiet > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d молча", quiet))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "из них " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// writeScanRecord stores the address list as a note. This is both where the
|
||||
// detail becomes readable and the only trace that a scan happened at all: a
|
||||
// scan is a read, but "when did she last put packets on the LAN" deserves an
|
||||
// answer.
|
||||
func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
||||
if w.api == nil {
|
||||
return
|
||||
}
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if res.Truncated {
|
||||
head += " (не вся сеть)"
|
||||
}
|
||||
lines := []string{head, "подсети: " + strings.Join(w.subnets, ", ")}
|
||||
shown := res.Hosts
|
||||
if len(shown) > scanReadOut {
|
||||
shown = shown[:scanReadOut]
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for _, h := range shown {
|
||||
s := h.Addr
|
||||
if len(h.Ports) > 0 {
|
||||
@@ -77,13 +197,17 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
}
|
||||
s += " (" + strings.Join(ps, ", ") + ")"
|
||||
}
|
||||
parts = append(parts, s)
|
||||
if h.MAC != "" {
|
||||
s += " " + h.MAC
|
||||
}
|
||||
lines = append(lines, s)
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s: %s", len(hosts), hostWord(len(hosts)), strings.Join(parts, "; "))
|
||||
if len(hosts) > len(shown) {
|
||||
out += fmt.Sprintf(" и ещё %d", len(hosts)-len(shown))
|
||||
if len(res.Hosts) > len(shown) {
|
||||
lines = append(lines, fmt.Sprintf("и ещё %d", len(res.Hosts)-len(shown)))
|
||||
}
|
||||
if _, err := w.api.WriteNote(ctx, w.now(), strings.Join(lines, "\n"), nil, "scan:lan"); err != nil {
|
||||
log.Printf("netscan: write scan note: %v", err)
|
||||
}
|
||||
return out + ".", true
|
||||
}
|
||||
|
||||
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
||||
@@ -111,13 +235,25 @@ func isNetworkQuery(u string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// Whole tokens for the network nouns: the bare substring "сети" is inside
|
||||
// "посетил", so "сколько машин я посетил?" used to read as a request to
|
||||
// scan the LAN. The prefix forms below are stems that have no such
|
||||
// collisions.
|
||||
network := false
|
||||
for _, w := range []string{"в сети", "в сетке", "сеть", "сети", "локальн", "wifi", "wi-fi", "вайфай"} {
|
||||
if strings.Contains(s, w) {
|
||||
for _, w := range []string{"сеть", "сети", "сетке", "сетку"} {
|
||||
if homeWord(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
for _, w := range []string{"локальн", "wifi", "wi-fi", "вайфай"} {
|
||||
if strings.Contains(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
@@ -23,7 +27,7 @@ func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireNetScan(cfg); w != nil {
|
||||
if w := wireNetScan(cfg, nil); w != nil {
|
||||
t.Fatal("the scanner must not wire for this config")
|
||||
}
|
||||
})
|
||||
@@ -36,7 +40,7 @@ func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
|
||||
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
|
||||
}})
|
||||
}}, nil)
|
||||
if ok == nil {
|
||||
t.Fatal("a valid enabled block should wire")
|
||||
}
|
||||
@@ -50,7 +54,7 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||
// Port 1 on loopback: nothing listens and the connection is refused
|
||||
// immediately, so the scan is fast and touches only this machine.
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
|
||||
}})
|
||||
}}, nil)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
@@ -109,3 +113,59 @@ func TestIsNetworkQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notingAPI counts the notes a scan writes, and remembers the last one.
|
||||
type notingAPI struct {
|
||||
ipc.CoreAPI
|
||||
n int
|
||||
last string
|
||||
}
|
||||
|
||||
func (a *notingAPI) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, _ string) (int64, error) {
|
||||
a.n++
|
||||
a.last = text
|
||||
return int64(a.n), nil
|
||||
}
|
||||
|
||||
// The spoken answer must not be a list of IP addresses. It goes to piper as
|
||||
// well as to /chat, and six dotted quads read out as a digit stream is not an
|
||||
// answer anybody can use. The addresses belong in the written record.
|
||||
func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
api := ¬ingAPI{}
|
||||
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{port}, Rate: 1000, Enabled: true,
|
||||
}}, api)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
out, claimed := w.scanSummary(context.Background())
|
||||
if !claimed {
|
||||
t.Fatal("the summary did not claim the turn")
|
||||
}
|
||||
if strings.Contains(out, "127.0.0.1") || strings.Contains(out, portStr) {
|
||||
t.Errorf("the spoken reply reads addresses out loud: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "нашла 1 устройство") {
|
||||
t.Errorf("reply = %q, want a count", out)
|
||||
}
|
||||
if api.n != 1 {
|
||||
t.Fatalf("wrote %d notes, want 1", api.n)
|
||||
}
|
||||
if !strings.Contains(api.last, "127.0.0.1") {
|
||||
t.Errorf("the written record has no addresses: %q", api.last)
|
||||
}
|
||||
|
||||
// A follow-up question inside the TTL reuses the answer: two questions in
|
||||
// a row must not be two sweeps of the LAN.
|
||||
if _, _ = w.scanSummary(context.Background()); api.n != 1 {
|
||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||
}
|
||||
}
|
||||
|
||||
+291
-47
@@ -55,8 +55,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -102,9 +104,22 @@ type scenario struct {
|
||||
Nexus string `json:"nexus_resolve,omitempty"`
|
||||
Hexis string `json:"hexis_capabilities,omitempty"`
|
||||
|
||||
// Tools — allowlist rows to enable before the first step. An act is only
|
||||
// dispatched when its verb is on the enabled allowlist, so a scenario that
|
||||
// wants to exercise one has to say which rows Kami had enabled.
|
||||
Tools []toolRow `json:"tools,omitempty"`
|
||||
|
||||
Steps []step `json:"steps"`
|
||||
}
|
||||
|
||||
// toolRow — one enabled allowlist row. Cmd is empty for an ecosystem verb,
|
||||
// which is intercepted before the process executor is ever reached.
|
||||
type toolRow struct {
|
||||
Name string `json:"name"`
|
||||
Cmd []string `json:"cmd,omitempty"`
|
||||
Destructive bool `json:"destructive,omitempty"`
|
||||
}
|
||||
|
||||
// scriptEntry — one canned model answer. Match is a substring of the user
|
||||
// message; the first entry whose Match is contained in it wins, and an entry
|
||||
// with an empty Match is the catch-all.
|
||||
@@ -125,6 +140,16 @@ type scriptEntry struct {
|
||||
// and then asserts. Assertions are evaluated against everything recorded since
|
||||
// the run began, except expect_no_send and expect_no_call, which are scoped to
|
||||
// this step — "nothing was sent because of THIS" is the useful question.
|
||||
//
|
||||
// The asymmetry is worth stating plainly, because it changes what a scenario
|
||||
// author is writing. expect_sent_contains, expect_called and expect_events are
|
||||
// RUN-scoped: they pass if the thing ever happened, at any earlier step. So
|
||||
// repeating expect_events: ["rss:tech"] on a later step asserts nothing new,
|
||||
// it just re-checks the earlier arrival. The negatives — expect_no_send,
|
||||
// expect_not_called, expect_no_events — are STEP-scoped, and are the ones that
|
||||
// say something about this moment. expect_reply_contains and
|
||||
// expect_reply_lacks read the most recent reply only, so a step with no
|
||||
// utterance re-checks the previous one.
|
||||
type step struct {
|
||||
At string `json:"at"`
|
||||
Note string `json:"note,omitempty"`
|
||||
@@ -176,6 +201,13 @@ type signalStep struct {
|
||||
Value string `json:"value"`
|
||||
Source string `json:"source"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
|
||||
// Confidence — 0 ⇒ 1.0, an observation Maven made herself. A relayed
|
||||
// notification is not that: the ambient path writes
|
||||
// calendar.AmbientConfidence, 0.6, and factPriority in intake.go branches
|
||||
// on exactly that difference. The field exists so a replay can reach the
|
||||
// low branch, which it could not while write() hardcoded 1.0.
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
type arriveStep struct {
|
||||
@@ -225,6 +257,28 @@ type simWorld struct {
|
||||
// broken scenario is diagnosable without a debugger.
|
||||
transcript []string
|
||||
replies []string
|
||||
|
||||
// fatalf — the abort seam. Defaults to t.Fatalf. It exists so a test can
|
||||
// reach the harness's own refusals (a backwards step, a missing WAV) and
|
||||
// assert on them instead of dying with the scenario.
|
||||
fatalf func(format string, args ...any)
|
||||
|
||||
// published — how many events the bus accepted, counted through a
|
||||
// subscriber. bus.Len() saturates at the ring capacity and cannot answer
|
||||
// "did anything arrive during this step" once a long scenario has filled
|
||||
// it.
|
||||
mu sync.Mutex
|
||||
published int
|
||||
|
||||
// audio — golden_v1.json, parsed once. A scenario with twenty audio steps
|
||||
// used to read and parse the manifest twenty times.
|
||||
audio map[string]string
|
||||
}
|
||||
|
||||
func (w *simWorld) publishCount() int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.published
|
||||
}
|
||||
|
||||
// recordingSink captures every send, mutex-guarded (the tick loop dispatches
|
||||
@@ -324,6 +378,25 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
t: t, clock: clock, loc: start.Location(), start: start,
|
||||
store: st, api: api, bus: bus, tick: tl, sink: sink, llm: scripted,
|
||||
}
|
||||
w.fatalf = t.Fatalf
|
||||
bus.Subscribe(func(event.Event) {
|
||||
w.mu.Lock()
|
||||
w.published++
|
||||
w.mu.Unlock()
|
||||
})
|
||||
|
||||
// Allowlist rows the scenario asked for, enabled before the first step. An
|
||||
// act only reaches a dispatch if a verb is on the enabled list, so without
|
||||
// this a scenario cannot script one at all.
|
||||
for _, tr := range sc.Tools {
|
||||
cmd := tr.Cmd
|
||||
if len(cmd) == 0 {
|
||||
cmd = []string{"true"}
|
||||
}
|
||||
if err := st.EnableTool(context.Background(), tr.Name, cmd, tr.Destructive, "sim", start); err != nil {
|
||||
t.Fatalf("scenario %q: enabling tool %q: %v", sc.Name, tr.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ecosystem fakes, wired only when the scenario supplies a body — a box
|
||||
// with no praxis block has no praxis client, and a scenario must be able to
|
||||
@@ -345,7 +418,10 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
// classifier in is deliberate; it is the failure floor, and a scenario that
|
||||
// scripts no route for an utterance exercises it.
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
matcher := tool.NewMatcher(nil)
|
||||
// The matcher reads the live enabled allowlist, same as the daemon's. It
|
||||
// used to be built on a nil API, which meant any scenario that produced an
|
||||
// act panicked the moment the matcher was consulted.
|
||||
matcher := tool.NewMatcher(api)
|
||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
||||
|
||||
w.handler = &reactiveHandler{
|
||||
@@ -355,6 +431,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
embedder: emb,
|
||||
api: api,
|
||||
matcher: matcher,
|
||||
tools: tool.NewExecutor(api, 5*time.Second),
|
||||
phraser: phraser.NewStub(),
|
||||
replier: newLLMReplier(scripted, nil),
|
||||
now: clock.Now,
|
||||
@@ -416,8 +493,9 @@ func (w *simWorld) advanceTo(at string) {
|
||||
target := w.timeOf(at)
|
||||
now := w.clock.Now()
|
||||
if target.Before(now) {
|
||||
w.t.Fatalf("step at %s goes backwards from %s — scenario steps must be in order",
|
||||
w.fatalf("step at %s goes backwards from %s — scenario steps must be in order",
|
||||
at, now.In(w.loc).Format("15:04:05"))
|
||||
return
|
||||
}
|
||||
w.clock.Advance(target.Sub(now))
|
||||
}
|
||||
@@ -446,8 +524,8 @@ func (w *simWorld) run(sc scenario) {
|
||||
w.logf("# %s", s.Note)
|
||||
}
|
||||
sendsBefore := w.sink.count()
|
||||
callsBefore := w.callCount()
|
||||
eventsBefore := w.bus.Len()
|
||||
callsBefore := w.callMark()
|
||||
eventsBefore := w.publishCount()
|
||||
|
||||
w.stimulate(ctx, s)
|
||||
w.assert(i, s, sendsBefore, callsBefore, eventsBefore)
|
||||
@@ -503,10 +581,14 @@ func (w *simWorld) write(ctx context.Context, sig signalStep, ts time.Time) {
|
||||
if kind == "" {
|
||||
kind = "env"
|
||||
}
|
||||
conf := sig.Confidence
|
||||
if conf == 0 {
|
||||
conf = 1.0
|
||||
}
|
||||
if _, err := w.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: ts, Kind: kind, Key: sig.Key, Value: sig.Value, Source: sig.Source, Confidence: 1.0,
|
||||
Ts: ts, Kind: kind, Key: sig.Key, Value: sig.Value, Source: sig.Source, Confidence: conf,
|
||||
}); err != nil {
|
||||
w.t.Fatalf("write fact %s: %v", sig.Key, err)
|
||||
w.fatalf("write fact %s: %v", sig.Key, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,29 +634,39 @@ func (w *simWorld) arrive(ctx context.Context, a arriveStep) {
|
||||
// known to contain, by reading cmd/mavsttd's golden manifest (#288's format,
|
||||
// reused rather than duplicated). An unknown reference fails the scenario
|
||||
// rather than quietly transcribing to "".
|
||||
//
|
||||
// The manifest is read and parsed once per world, not once per step: a
|
||||
// scenario with twenty audio steps should cost one file read.
|
||||
func (w *simWorld) audioText(ref string) string {
|
||||
w.t.Helper()
|
||||
manifest := filepath.Join("..", "mavsttd", "testdata", "golden_v1.json")
|
||||
raw, err := os.ReadFile(manifest)
|
||||
if err != nil {
|
||||
w.t.Fatalf("audio step %q: reading %s: %v", ref, manifest, err)
|
||||
}
|
||||
var m struct {
|
||||
Cases []struct {
|
||||
Name string `json:"name"`
|
||||
WAV string `json:"wav"`
|
||||
Text string `json:"text"`
|
||||
} `json:"cases"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
w.t.Fatalf("audio step %q: parsing %s: %v", ref, manifest, err)
|
||||
}
|
||||
for _, c := range m.Cases {
|
||||
if c.Name == ref || c.WAV == ref {
|
||||
return c.Text
|
||||
if w.audio == nil {
|
||||
raw, err := os.ReadFile(manifest)
|
||||
if err != nil {
|
||||
w.fatalf("audio step %q: reading %s: %v", ref, manifest, err)
|
||||
return ""
|
||||
}
|
||||
var m struct {
|
||||
Cases []struct {
|
||||
Name string `json:"name"`
|
||||
WAV string `json:"wav"`
|
||||
Text string `json:"text"`
|
||||
} `json:"cases"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
w.fatalf("audio step %q: parsing %s: %v", ref, manifest, err)
|
||||
return ""
|
||||
}
|
||||
w.audio = make(map[string]string, len(m.Cases)*2)
|
||||
for _, c := range m.Cases {
|
||||
w.audio[c.Name] = c.Text
|
||||
w.audio[c.WAV] = c.Text
|
||||
}
|
||||
}
|
||||
w.t.Fatalf("audio step %q: no such case in %s", ref, manifest)
|
||||
if text, ok := w.audio[ref]; ok && ref != "" {
|
||||
return text
|
||||
}
|
||||
w.fatalf("audio step %q: no such case in %s", ref, manifest)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -582,35 +674,62 @@ func voicePTT() voice.PushToTalkReq {
|
||||
return voice.PushToTalkReq{Audio: audio.Audio{Format: audio.PCM16kMono}}
|
||||
}
|
||||
|
||||
// callCount — how many requests every wired ecosystem fake has seen.
|
||||
func (w *simWorld) callCount() int {
|
||||
n := 0
|
||||
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
|
||||
// fakes — every ecosystem fake, in a fixed order. Both the mark and the paths
|
||||
// walk this same order, which is the whole point: they have to agree.
|
||||
func (w *simWorld) fakes() []*fakeServer { return []*fakeServer{w.praxis, w.nexus, w.hexis} }
|
||||
|
||||
// callMark takes a PER-SERVER snapshot of how many requests each fake has
|
||||
// seen. It is not a total.
|
||||
//
|
||||
// A total cannot be used to slice the concatenated path list, and the harness
|
||||
// used to do exactly that. callPaths concatenates praxis, then nexus, then
|
||||
// hexis; a total counts arrivals across all three. With praxis on 3 requests
|
||||
// and nexus on 1, the total is 4 and the list is [p1 p2 p3 n1]. A step that
|
||||
// calls praxis once makes the list [p1 p2 p3 p4 n1], and paths[4:] is [n1].
|
||||
// The new praxis call sits at index 3 and is never looked at, so
|
||||
// expect_not_called on praxis passed on a step that called praxis. The same
|
||||
// slice reported the stale nexus call as new, so expect_not_called on
|
||||
// "/resolve" failed on a step that resolved nothing.
|
||||
func (w *simWorld) callMark() []int {
|
||||
mark := make([]int, len(w.fakes()))
|
||||
for i, fs := range w.fakes() {
|
||||
if fs != nil {
|
||||
n += len(fs.Requests())
|
||||
mark[i] = len(fs.Requests())
|
||||
}
|
||||
}
|
||||
return n
|
||||
return mark
|
||||
}
|
||||
|
||||
func (w *simWorld) callPaths() []string {
|
||||
// callPathsSince returns the calls each fake took after its own mark. A nil
|
||||
// mark means "everything, from the beginning of the run".
|
||||
func (w *simWorld) callPathsSince(mark []int) []string {
|
||||
var out []string
|
||||
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
|
||||
for i, fs := range w.fakes() {
|
||||
if fs == nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range fs.Requests() {
|
||||
reqs := fs.Requests()
|
||||
from := 0
|
||||
if mark != nil && i < len(mark) {
|
||||
from = mark[i]
|
||||
}
|
||||
if from > len(reqs) {
|
||||
from = len(reqs)
|
||||
}
|
||||
for _, r := range reqs[from:] {
|
||||
out = append(out, r.Method+" "+r.Path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *simWorld) callPaths() []string { return w.callPathsSince(nil) }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assertions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore int) {
|
||||
func (w *simWorld) assert(i int, s step, sendsBefore int, callsBefore []int, eventsBefore int) {
|
||||
w.t.Helper()
|
||||
where := fmt.Sprintf("step %d (%s)", i+1, s.At)
|
||||
if s.Note != "" {
|
||||
@@ -654,9 +773,10 @@ func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore
|
||||
fail("no ecosystem call matches %q; calls so far: %v", want, paths)
|
||||
}
|
||||
}
|
||||
since := w.callPathsSince(callsBefore)
|
||||
for _, unwanted := range s.ExpectNotCalled {
|
||||
if anyContains(paths[callsBefore:], unwanted) {
|
||||
fail("an ecosystem call matched %q and must not have: %v", unwanted, paths[callsBefore:])
|
||||
if anyContains(since, unwanted) {
|
||||
fail("an ecosystem call matched %q and must not have: %v", unwanted, since)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,8 +786,12 @@ func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore
|
||||
fail("no intake event matches %q; journal: %v", want, eventLines(evs))
|
||||
}
|
||||
}
|
||||
if s.ExpectNoEvents && w.bus.Len() > eventsBefore {
|
||||
fail("expected nothing to arrive, journal grew to %d", w.bus.Len())
|
||||
// Counted publishes, not bus.Len(): the ring saturates at its capacity, so
|
||||
// a long scenario that filled it made every later expect_no_events pass
|
||||
// unconditionally.
|
||||
if s.ExpectNoEvents && w.publishCount() > eventsBefore {
|
||||
fail("expected nothing to arrive, %d event(s) were published",
|
||||
w.publishCount()-eventsBefore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,7 +806,10 @@ func sendableTexts(sends []delivery.Sendable) []string {
|
||||
func eventLines(evs []event.Event) []string {
|
||||
out := make([]string, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
out = append(out, fmt.Sprintf("%s/%s %s %s", e.Source, e.Kind, e.Title, e.Body))
|
||||
// Priority is in the line so a scenario can assert on it. It is the one
|
||||
// field factPriority derives from confidence, and without it a replay
|
||||
// could set a confidence but never see what the journal did with it.
|
||||
out = append(out, fmt.Sprintf("%s/%s pri=%s %s %s", e.Source, e.Kind, e.Priority, e.Title, e.Body))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -773,26 +900,143 @@ func TestSimulatorIsDeterministic(t *testing.T) {
|
||||
if first != second {
|
||||
t.Errorf("two replays of the same scenario diverged:\n--- first ---\n%s\n--- second ---\n%s", first, second)
|
||||
}
|
||||
// And the transcript's own timestamps must be the scenario's, not today's.
|
||||
if strings.Contains(first, time.Now().Format("15:04")) && !strings.Contains(sc.Start, time.Now().Format("15:04")) {
|
||||
t.Error("transcript carries the wall clock — something in the replay path read time.Now()")
|
||||
// And every transcript timestamp must lie inside the scenario's own span.
|
||||
//
|
||||
// This used to compare the transcript against time.Now().Format("15:04"),
|
||||
// which failed whenever the suite happened to run during the half hour the
|
||||
// scenario covers: morning_missed logs 08:30 through 09:00, sc.Start
|
||||
// contains only 08:30, so a run at 08:35 reported a wall-clock read that
|
||||
// had not happened. A determinism test that depends on the time of day is
|
||||
// the bug it is looking for.
|
||||
start, err := time.Parse(time.RFC3339, sc.Start)
|
||||
if err != nil {
|
||||
t.Fatalf("bad start: %v", err)
|
||||
}
|
||||
last := start
|
||||
for _, s := range sc.Steps {
|
||||
if at := stepInstant(t, start, s.At); at.After(last) {
|
||||
last = at
|
||||
}
|
||||
}
|
||||
// Only the lines logf stamped. A note or a feed item can carry its own
|
||||
// newlines, and those continuation lines have no timestamp.
|
||||
stamp := regexp.MustCompile(`^(\d\d:\d\d:\d\d) `)
|
||||
for _, line := range strings.Split(first, "\n") {
|
||||
m := stamp.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
at := stepInstant(t, start, m[1])
|
||||
if at.Before(start) || at.After(last) {
|
||||
t.Errorf("transcript line %q is stamped outside the scenario span %s..%s — "+
|
||||
"something in the replay path read time.Now()",
|
||||
line, start.Format("15:04:05"), last.Format("15:04:05"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallsSinceAreScopedPerServer pins the ordering bug that made
|
||||
// expect_not_called unsound. The mark is per server; a total cannot slice a
|
||||
// list that is concatenated per server.
|
||||
func TestCallsSinceAreScopedPerServer(t *testing.T) {
|
||||
sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00",
|
||||
Praxis: fixturePraxisAttentionItems(), Nexus: fixtureNexusResolved("ent_1", "thing", "device"),
|
||||
Steps: []step{{At: "08:30"}}}
|
||||
w := newSimWorld(t, sc)
|
||||
|
||||
hit := func(fs *fakeServer, path string) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(fs.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("hitting %s: %v", path, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
// Praxis runs ahead of nexus, so the concatenated list already has a nexus
|
||||
// call sitting after three praxis ones.
|
||||
hit(w.praxis, "/api/v1/tools/attention")
|
||||
hit(w.praxis, "/api/v1/tools/attention")
|
||||
hit(w.praxis, "/api/v1/tools/attention")
|
||||
hit(w.nexus, "/api/v1/resolve")
|
||||
|
||||
mark := w.callMark()
|
||||
hit(w.praxis, "/api/v1/tools/attention")
|
||||
|
||||
since := w.callPathsSince(mark)
|
||||
if !anyContains(since, "attention") {
|
||||
t.Errorf("the praxis call made after the mark is missing from %v", since)
|
||||
}
|
||||
if anyContains(since, "resolve") {
|
||||
t.Errorf("a nexus call from before the mark was reported as new: %v", since)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublishCountDoesNotSaturateWithTheRing pins expect_no_events on a bus
|
||||
// that has already wrapped. bus.Len() stops at the capacity, so it can no
|
||||
// longer answer "did anything arrive".
|
||||
func TestPublishCountDoesNotSaturateWithTheRing(t *testing.T) {
|
||||
sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00",
|
||||
Steps: []step{{At: "08:30"}}}
|
||||
w := newSimWorld(t, sc)
|
||||
|
||||
for i := 0; i < event.DefaultCapacity+5; i++ {
|
||||
w.bus.Publish(event.Event{
|
||||
Source: "sim:test", Kind: event.KindFact, Title: fmt.Sprintf("f%d", i),
|
||||
}, w.clock.Now())
|
||||
}
|
||||
if got := w.bus.Len(); got != event.DefaultCapacity {
|
||||
t.Fatalf("ring holds %d, expected it to be saturated at %d", got, event.DefaultCapacity)
|
||||
}
|
||||
before := w.publishCount()
|
||||
w.bus.Publish(event.Event{Source: "sim:test", Kind: event.KindFact, Title: "one more"}, w.clock.Now())
|
||||
if w.publishCount() != before+1 {
|
||||
t.Errorf("publish count went %d → %d on a full ring, expected it to keep counting",
|
||||
before, w.publishCount())
|
||||
}
|
||||
}
|
||||
|
||||
// stepInstant resolves "HH:MM" or "HH:MM:SS" against the scenario's start day.
|
||||
func stepInstant(t *testing.T, start time.Time, at string) time.Time {
|
||||
t.Helper()
|
||||
layout := "15:04"
|
||||
if strings.Count(at, ":") == 2 {
|
||||
layout = "15:04:05"
|
||||
}
|
||||
hm, err := time.Parse(layout, at)
|
||||
if err != nil {
|
||||
t.Fatalf("bad step time %q: %v", at, err)
|
||||
}
|
||||
return time.Date(start.Year(), start.Month(), start.Day(),
|
||||
hm.Hour(), hm.Minute(), hm.Second(), 0, start.Location())
|
||||
}
|
||||
|
||||
// TestSimulatorRefusesBackwardsSteps guards the one scenario-authoring mistake
|
||||
// that would silently produce a meaningless run.
|
||||
//
|
||||
// It used to advance to 09:00 and then to 09:30 and assert the clock had
|
||||
// moved, which is the forwards case: the backwards branch it is named after
|
||||
// was never reached, because reaching it ended the test. The fatalf seam is
|
||||
// what makes it testable.
|
||||
func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
|
||||
// Not table-driven through run() because advanceTo calls t.Fatalf; this
|
||||
// checks the ordering arithmetic directly.
|
||||
sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00",
|
||||
Steps: []step{{At: "09:00"}}}
|
||||
w := newSimWorld(t, sc)
|
||||
var refusal string
|
||||
w.fatalf = func(format string, args ...any) { refusal = fmt.Sprintf(format, args...) }
|
||||
|
||||
w.advanceTo("09:00")
|
||||
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:00" {
|
||||
t.Fatalf("clock at %s after advancing to 09:00", got)
|
||||
}
|
||||
w.advanceTo("09:30")
|
||||
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:30" {
|
||||
t.Fatalf("clock at %s after advancing to 09:30", got)
|
||||
if refusal != "" {
|
||||
t.Fatalf("a forwards step was refused: %s", refusal)
|
||||
}
|
||||
|
||||
w.advanceTo("08:45")
|
||||
if refusal == "" {
|
||||
t.Fatal("a step going backwards to 08:45 was accepted")
|
||||
}
|
||||
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:00" {
|
||||
t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got)
|
||||
}
|
||||
}
|
||||
|
||||
+42
-9
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -44,9 +45,12 @@ func wireSmartHome(cfg *config.Config, st *store.Store) *homeWiring {
|
||||
st: st,
|
||||
refresh: time.Duration(cfg.SmartHome.Refresh),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
w.propose(ctx)
|
||||
// No first propose here. This runs inside wireVoice, inside run, before the
|
||||
// IPC socket is serving, and on the locked path inside the passkey unlock
|
||||
// handler. A Home Assistant box that is powered off but still on a routed
|
||||
// subnet black-holes the connection rather than refusing it, so a
|
||||
// synchronous enumeration held the daemon's start for the per-call timeout.
|
||||
// run does the first propose off the ticker instead.
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -113,6 +117,9 @@ func (w *homeWiring) run(ctx context.Context) {
|
||||
}
|
||||
t := time.NewTicker(iv)
|
||||
defer t.Stop()
|
||||
// The first enumeration, off the daemon's start path. wireSmartHome used to
|
||||
// do it synchronously and a dead house delayed the socket coming up.
|
||||
w.propose(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -140,28 +147,54 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||
}
|
||||
var on []string
|
||||
var sensors []string
|
||||
dark := 0
|
||||
for _, e := range ents {
|
||||
switch {
|
||||
case e.Domain == "sensor" || e.Domain == "binary_sensor":
|
||||
if len(sensors) < 3 && e.State != "" && e.State != "unavailable" {
|
||||
if e.State == "" || e.State == "unavailable" {
|
||||
dark++
|
||||
continue
|
||||
}
|
||||
if len(sensors) < 3 {
|
||||
sensors = append(sensors, e.Name+" "+e.State+e.Unit)
|
||||
}
|
||||
case e.State == "unavailable" || e.State == "unknown" || e.State == "":
|
||||
// A lamp that is not reachable is not a lamp that is off. Counting
|
||||
// it as neither used to make "всё выключено" and "one device is
|
||||
// unreachable" read identically.
|
||||
dark++
|
||||
case e.State == "on" || e.State == "open" || e.State == "unlocked":
|
||||
on = append(on, e.Name)
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
if len(on) > 0 {
|
||||
if len(on) > 5 {
|
||||
on = on[:5]
|
||||
switch {
|
||||
case len(on) > 0:
|
||||
shown, rest := on, 0
|
||||
if len(shown) > 5 {
|
||||
rest = len(shown) - 5
|
||||
shown = shown[:5]
|
||||
}
|
||||
parts = append(parts, "включено: "+strings.Join(on, ", "))
|
||||
} else {
|
||||
// Silent truncation on a status read is the same failure as the cap
|
||||
// one layer up: she has to say the list is not the whole list.
|
||||
line := "включено: " + strings.Join(shown, ", ")
|
||||
if rest > 0 {
|
||||
line += fmt.Sprintf(" и ещё %d", rest)
|
||||
}
|
||||
parts = append(parts, line)
|
||||
case dark > 0 && len(sensors) == 0:
|
||||
// Nothing is on and everything she can see is unreachable. "всё
|
||||
// выключено" would be a claim about the house she cannot make.
|
||||
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
|
||||
default:
|
||||
parts = append(parts, "всё выключено")
|
||||
}
|
||||
if len(sensors) > 0 {
|
||||
parts = append(parts, strings.Join(sensors, ", "))
|
||||
}
|
||||
if dark > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
|
||||
}
|
||||
return strings.Join(parts, "; ") + ".", true
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -77,6 +78,12 @@ func TestProposeOnlyProposesControllableDevices(t *testing.T) {
|
||||
if w == nil {
|
||||
t.Fatal("wireSmartHome returned nil for an enabled, reachable house")
|
||||
}
|
||||
// Wiring alone must not have touched the house: enumeration happens off
|
||||
// the ticker, not on the daemon's start path.
|
||||
if pre, err := st.ListTools(context.Background(), ""); err != nil || len(pre) != 0 {
|
||||
t.Fatalf("wireSmartHome enumerated the house synchronously: %+v (%v)", pre, err)
|
||||
}
|
||||
w.propose(context.Background())
|
||||
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
@@ -188,3 +195,82 @@ func TestIsHomeQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A house that black-holes the connection must not hold the daemon's start.
|
||||
// wireSmartHome used to enumerate synchronously with a 30s context, inside
|
||||
// wireVoice, inside run, before the IPC socket was serving — and on the locked
|
||||
// path, inside the passkey unlock handler.
|
||||
func TestWireSmartHomeDoesNotBlockOnTheHouse(t *testing.T) {
|
||||
// A handler that never answers: the client's own timeout is the only way
|
||||
// out, and it is ten seconds.
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-block
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
done := make(chan *homeWiring, 1)
|
||||
go func() {
|
||||
done <- wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
}()
|
||||
select {
|
||||
case w := <-done:
|
||||
if w == nil {
|
||||
t.Fatal("a configured house should still wire")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("wireSmartHome waited on the house")
|
||||
}
|
||||
}
|
||||
|
||||
// A lamp that is unreachable is not a lamp that is off, and a list she cut
|
||||
// short has to say so. Both used to read as plain statements about the house.
|
||||
func TestHomeSummaryDoesNotCallUnreachableDevicesOff(t *testing.T) {
|
||||
const fixture = `[
|
||||
{"entity_id":"light.a","state":"unavailable","attributes":{"friendly_name":"Прихожая"}},
|
||||
{"entity_id":"light.b","state":"unavailable","attributes":{"friendly_name":"Кухня"}}
|
||||
]`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(fixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, ok := w.homeSummary(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("summary did not claim the turn")
|
||||
}
|
||||
if strings.Contains(out, "всё выключено") {
|
||||
t.Errorf("two unreachable lamps were reported as off: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "не отвечают") {
|
||||
t.Errorf("the unreachable devices are not mentioned: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeSummarySaysWhenTheListIsCutShort(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i := 0; i < 8; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"entity_id":"light.l%d","state":"on","attributes":{"friendly_name":"лампа%d"}}`, i, i)
|
||||
}
|
||||
b.WriteString("]")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, _ := w.homeSummary(context.Background())
|
||||
if !strings.Contains(out, "и ещё 3") {
|
||||
t.Errorf("eight lamps on, five named, and nothing said about the rest: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "act_degraded",
|
||||
"description": "The act path against a Praxis that goes down and comes back. This is the case the harness promised and did not have: the other two scenarios never produce an act, so the ecosystem fakes saw zero requests and the fault lever was inert. Here a scripted act reaches an enabled allowlist row, the row is a Praxis verb, and the same utterance runs healthy, then at 503, then healthy again. The degraded turn must say she cannot reach it and must not send anything at him off the back of it.",
|
||||
"start": "2026-08-01T09:00:00+03:00",
|
||||
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
|
||||
"tools": [{ "name": "list_attention" }],
|
||||
"script": [
|
||||
{
|
||||
"match": "требует внимания",
|
||||
"route": "[{\"intent\":\"act\",\"verb\":\"list_attention\"}]"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "09:00",
|
||||
"note": "a healthy act reaches Praxis and speaks what it found",
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["medicine not taken"],
|
||||
"expect_called": ["/api/v1/tools/attention"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "09:05",
|
||||
"note": "the ecosystem goes down",
|
||||
"fault": 503
|
||||
},
|
||||
{
|
||||
"at": "09:10",
|
||||
"note": "the same act against a 503. She says she cannot reach it. She does not invent an answer and she does not push anything at him.",
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["не могу сейчас узнать"],
|
||||
"expect_reply_lacks": ["medicine not taken"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "09:15",
|
||||
"note": "a tick while the ecosystem is down touches nothing out there — the proactive loop has no business calling Praxis",
|
||||
"tick": true,
|
||||
"expect_not_called": ["/api/v1"],
|
||||
"expect_no_send": true,
|
||||
"expect_no_events": true
|
||||
},
|
||||
{
|
||||
"at": "09:20",
|
||||
"note": "recovery: the same act works again, so the degraded turn left no sticky state",
|
||||
"clear_fault": true,
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["medicine not taken"],
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
|
||||
"audio": "ru_fact",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал,", "милый", "ваш"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
|
||||
+7
-6
@@ -54,15 +54,16 @@
|
||||
},
|
||||
{
|
||||
"at": "08:40",
|
||||
"note": "the work calendar signal — a relayed notification, below full confidence",
|
||||
"note": "the work calendar signal — a relayed notification, at the ambient path's own 0.6 rather than an observation she made herself. That is the branch factPriority takes, so the journal must file it low.",
|
||||
"arrive": {
|
||||
"source": "ambient:notif",
|
||||
"fact": {
|
||||
"key": "calendar_event_20260801_планёрка",
|
||||
"value": "10:00-11:00 планёрка"
|
||||
"value": "10:00-11:00 планёрка",
|
||||
"confidence": 0.6
|
||||
}
|
||||
},
|
||||
"expect_events": ["ambient:notif", "планёрка"],
|
||||
"expect_events": ["планёрка", "ambient:notif/fact pri=low"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
@@ -73,17 +74,17 @@
|
||||
},
|
||||
{
|
||||
"at": "08:50",
|
||||
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it.",
|
||||
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
|
||||
"say": "что я пропустил?",
|
||||
"expect_reply_contains": ["не знаю"],
|
||||
"expect_reply_lacks": ["рад ", "милый", "ваш"]
|
||||
},
|
||||
{
|
||||
"at": "08:55",
|
||||
"note": "stating a fact writes it and says so, in the feminine",
|
||||
"note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".",
|
||||
"say": "я выпил воды",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал,", "милый"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -166,7 +166,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
}
|
||||
// The LAN scanner (Vikunja #257): a read, bounded to the configured
|
||||
// subnets and rate-limited. Off unless the `netscan` block is enabled.
|
||||
w.netscan = wireNetScan(cfg)
|
||||
w.netscan = wireNetScan(cfg, coreAPI)
|
||||
matcher := tool.NewMatcher(coreAPI)
|
||||
|
||||
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
{{template "shellTop" "events"}}
|
||||
<h1>Intake</h1>
|
||||
<div class=hint>Everything that arrived, newest first — a relayed notification, a mail candidate, a feed
|
||||
item, a changed page, a spend, a presence probe. One envelope per write; the durable row is still the
|
||||
fact, note or task itself. Held in memory only, so a restart empties this.</div>
|
||||
<div class=hint>Everything that arrived, most recently noticed first — a relayed notification, a mail
|
||||
candidate, a feed item, a changed page, a spend, a presence probe. Maven's own bookkeeping writes (feed
|
||||
watermarks, crawl hashes, act traces, settings he toggled) are not here: nothing arrived. <b>noticed</b>
|
||||
is when the journal saw it, <b>happened</b> is when the thing itself did, and those differ by days on a
|
||||
cold feed read. One envelope per write; the durable row is still the fact, note or task itself. Held in
|
||||
memory only, so a restart empties this.</div>
|
||||
{{if .Err}}<div class=hint>journal unavailable: {{.Err}}</div>{{end}}
|
||||
{{if and (not .Events) (not .Err)}}
|
||||
<div class=hint>nothing has arrived yet</div>
|
||||
{{end}}
|
||||
{{if .Events}}
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>when<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
||||
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
||||
{{range .Events}}<tr>
|
||||
<td>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
|
||||
<td>{{.NoticedAt.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.Source}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td class=gray>{{.Priority}}</td>
|
||||
|
||||
+4
-4
@@ -319,14 +319,14 @@ var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shel
|
||||
// human surface is here (they ship no web UI of their own).
|
||||
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
||||
|
||||
// morningTmpl — read-only view of today's checklist state per configured
|
||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||
// checklist state changes on the scale of minutes, not seconds.
|
||||
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
||||
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
||||
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
|
||||
|
||||
// morningTmpl — read-only view of today's checklist state per configured
|
||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||
// checklist state changes on the scale of minutes, not seconds.
|
||||
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
||||
|
||||
func noCache(h http.Handler) http.Handler {
|
||||
|
||||
Reference in New Issue
Block a user