P2-02 (server side): schedule reconciliation push + ack handling

Server is now the source of truth for the agent's cron set.

* Helpers in schedule_push.go:
  - loadScheduleSetPayload reads the host's schedules + canonical
    version into the wire shape.
  - pushScheduleSetOnConn writes directly to a just-handshaken conn
    (avoids racing against Hub.Register on a brand-new connection).
  - pushScheduleSetAsync is the post-CRUD flavour — no-op when the
    host is offline (the next reconnect's on-hello path catches it
    up, so a missed push is non-fatal).
  - applyScheduleAck records what version the agent has confirmed.

* onAgentHello restructured: was returning early when the host had
  no repo credentials, which made the schedule push unreachable for
  fresh hosts. Split into pushRepoCredsOnHello (silent no-op on
  ErrNotFound) + pushScheduleSetOnConn (always runs). Empty schedule
  list is a valid push: tells the agent to drop stale cron entries.

* WS dispatcher gains an OnScheduleAck hook on HandlerDeps; the
  http server wires it to applyScheduleAck. MsgScheduleAck moves
  out of the "TODO(P2)" group into a real case that decodes the
  payload and forwards to the callback.

* Schedule CRUD handlers each fire pushScheduleSetAsync after the
  audit-log write so the agent picks up changes within seconds.

Tests cover:
  - On-hello push of an already-created schedule, agent acks,
    applied_schedule_version flips on the host row.
  - Connect-then-CRUD: empty initial push (version 0), then a
    follow-on push at version 1 after the operator creates a
    schedule via REST.

Agent-side `schedule.set` handler (parse, replace local cron,
emit `schedule.ack`) is the remainder of P2-02 and lands with
P2-03's local scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 11:22:06 +01:00
parent aa9fc330fc
commit a086b0eb75
7 changed files with 400 additions and 6 deletions
+13
View File
@@ -192,6 +192,19 @@ func (s *Server) pushRepoCredsToAgent(ctx context.Context, hostID string, blob r
// The conn argument is used directly (rather than via the hub) so we
// don't race a brand-new register against an old still-closing conn.
func (s *Server) onAgentHello(ctx context.Context, hostID string, conn *ws.Conn) {
s.pushRepoCredsOnHello(ctx, hostID, conn)
// Push the current schedule set in the same on-hello window so
// the agent's local cron is in sync before any command.run lands.
// An empty schedule list is a valid push: it tells the agent to
// drop any cron entries left over from a previous deployment.
// Always runs, even when the host has no repo credentials yet.
s.pushScheduleSetOnConn(ctx, hostID, conn)
}
// pushRepoCredsOnHello loads + decrypts + sends the host's repo
// credentials. Silent no-op when the host has nothing on file
// (the operator hasn't bound creds to it yet).
func (s *Server) pushRepoCredsOnHello(ctx context.Context, hostID string, conn *ws.Conn) {
enc, err := s.deps.Store.GetHostCredentials(ctx, hostID)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {