Adds @Parameter description so the generated OpenAPI spec / Swagger UI
explains what 'staged' vs 'live' means instead of just surfacing the
bare param name. Follow-up: run `cd ui && npm run generate-api:live`
against a live backend to refresh openapi.json + schema.d.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Existing IT only exercises the startContainer-throws path, where the
exception bypasses the entire try block. Add a test where startContainer
succeeds but getContainerStatus never returns healthy — this covers the
early-exit at the HEALTH_CHECK stage, which is the common real-world
failure shape and closest to the snapshot-write point.
Shortens healthchecktimeout to 2s via @TestPropertySource so the test
completes in a few seconds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snapshot is written by DeploymentExecutor before the RUNNING/DEGRADED
split, so DEGRADED rows already carry a deployed_config_snapshot. Treat
them as checkpoints — partial-healthy deploys still produced a working
config worth restoring. Aligns repo query with UI filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Issue 1: add List<String> sensitiveKeys as 4th field to DeploymentConfigSnapshot; populate
from agentConfig.getSensitiveKeys() in DeploymentExecutor; handleRestore hydrates from
snap.sensitiveKeys directly; Deployment type in apps.ts gains sensitiveKeys field
- Issue 2: after createApp succeeds, refetchQueries(['apps', envSlug]) before navigate so the
new app is in cache before the router renders the deployed view (eliminates transient Save-
disabled flash)
- Issue 3: useDeploymentPageState useEffect now uses prevServerStateRef to detect local edits;
background refetches only overwrite form when no local changes are present
- Issue 5: handleRedeploy invalidates dirty-state + versions queries after createDeployment
resolves; handleSave invalidates dirty-state after staged save
- Issue 10: DirtyStateCalculator strips volatile agentConfig keys (version, updatedAt, updatedBy,
environment, application) before JSON comparison via scrubAgentConfig(); adds
versionBumpDoesNotMarkDirty test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wires DirtyStateCalculator behind an HTTP endpoint on AppController.
Adds findLatestSuccessfulByAppAndEnv to PostgresDeploymentRepository,
registers DirtyStateCalculator as a Spring bean (with ObjectMapper for
JavaTimeModule support), and covers all three scenarios with IT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add @DirtiesContext(AFTER_CLASS) so the SpyBean-forked context is torn
down after the 6 tests finish, preventing permanent cache pollution
- Replace single-row queryForObject with queryForList + hasSize(1) in both
audit tests so spurious extra rows will fail explicitly
- Assert auditCount == 0 in the 400 test to lock in the no-audit-on-bad-input invariant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the misleading putConfig_staged_auditActionIsStagedAppConfig test
(which only checked pushResult.total == 0, a duplicate of _savesButDoesNotPush)
with two real audit-log assertions: one verifying "stage_app_config" is written
for apply=staged and a new companion test verifying "update_app_config" for the
live path. Uses jdbcTemplate to query audit_log directly (Option B).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When apply=staged, saves to DB only — no CONFIG_UPDATE dispatched to agents.
When apply=live (default, back-compat), preserves today's immediate-push behavior.
Unknown apply values return 400. Audit action is stage_app_config vs update_app_config.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the missing containerConfig assertion to snapshot_isPopulated_whenDeploymentReachesRunning
(runtimeType + appPort entries), and tightens the await predicate from .isIn(RUNNING, DEGRADED)
to .isEqualTo(RUNNING) — the mock returns a healthy container so RUNNING is deterministic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Injects PostgresApplicationConfigRepository into DeploymentExecutor and
calls saveDeployedConfigSnapshot at the COMPLETE stage, before
markRunning. Snapshot contains jarVersionId, agentConfig (nullable),
and app.containerConfig. The FAILED catch path is left untouched so
snapshot stays null on failure. Verified by DeploymentSnapshotIT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace manual `new PostgresDeploymentRepository(jdbcTemplate, new ObjectMapper())` with
`@Autowired PostgresDeploymentRepository repository` to use the Spring-managed bean whose
ObjectMapper has JavaTimeModule registered. Also removes the redundant isNotNull() assertion
whose work is done by the field-level assertions that follow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Appends DeploymentConfigSnapshot deployedConfigSnapshot to the Deployment
record and adds a matching withDeployedConfigSnapshot wither. All
positional call sites (repository mapper, test fixture) updated to pass
null; Task 1.4 will wire real persistence and Task 1.5 will populate
the field on RUNNING transition.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jmustache on the classpath (for alert notification templates) triggers
Spring Boot's MustacheAutoConfiguration, which warns about the missing
classpath:/templates/ folder we don't use. Disable its check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix stale `AGGREGATE` label (actual enum: `COUNT_IN_WINDOW`). Expand
EXCHANGE_MATCH section with both fire modes, PER_EXCHANGE config-surface
restrictions (0 for reNotifyMinutes/forDurationSeconds, at-least-one-sink
rule), exactly-once guarantee scope, and the first-run backlog-cap knob.
Surface the new config in application.yml with the 24h default and the
opt-out-to-0 semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New property cameleer.server.alerting.perExchangeDeployBacklogCapSeconds
(default 86400 = 24h, 0 disables). On first run (no persisted cursor
or malformed), clamp cursorTs to max(rule.createdAt, now - cap) so a
long-lived PER_EXCHANGE rule doesn't scan from its creation date
forward on first post-deploy tick. Normal-advance path unaffected.
Follows up final-review I-1 on the PER_EXCHANGE exactly-once phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end lifecycle test: 5 FAILED exchanges across 2 ticks produces
exactly 5 FIRING instances + 5 PENDING notifications. Tick 3 with no
new exchanges produces zero new instances or notifications. Ack on one
instance leaves the other four untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PER_EXCHANGE rules: 400 if reNotifyMinutes != 0 or forDurationSeconds != 0.
Any rule: 400 if webhooks + targets are both empty (never notifies anyone).
Turns green: AlertRuleControllerIT#createPerExchangeRule_with*NonZero_returns400,
AlertRuleControllerIT#createAnyRule_withEmptyWebhooksAndTargets_returns400.
Three failing IT tests documenting the contract Task 3.3 will satisfy:
- createPerExchangeRule_withReNotifyMinutesNonZero_returns400
- createPerExchangeRule_withForDurationSecondsNonZero_returns400
- createAnyRule_withEmptyWebhooksAndTargets_returns400
Dead field — was enforced by compact ctor as required for PER_EXCHANGE,
but never read anywhere in the codebase. Removal tightens the API surface
and is precondition for the Task 3.3 cross-field validator.
Pre-prod; no shim / migration.
Wraps instance writes, notification enqueues, and cursor advance in one
transactional boundary per rule tick. Rollback leaves the rule replayable
on next tick. Turns the Phase 2 atomicity IT green (see AlertEvaluatorJobIT
#tickRollback_faultOnSecondNotificationInsert_leavesCursorUnchanged).
Fault-injection IT asserts that a crash mid-batch rolls back every
instance + notification write AND leaves the cursor unchanged. Fails
against current (Phase 1 only) code — turns green when Task 2.2
wraps batch processing in @Transactional.
Replace async @AfterEach ALTER...DELETE with @BeforeEach TRUNCATE TABLE
executions — matches the convention used in ClickHouseExecutionStoreIT
and peers. Env-slug isolation was already preventing cross-test pollution;
this change is about hygiene and determinism (TRUNCATE is synchronous).
Thread EvalResult.Batch.nextEvalState into releaseClaim so the composite
cursor from Task 1.5 actually lands in rule.evalState across tick boundaries.
Guards against empty-batch wipe (would regress to first-run scan).
Follow-up to Task 1.2 flagged by Task 1.5 review (I-1). Single-column
ORDER BY could drop tail rows in a same-millisecond group >50 when
paginating via the composite cursor. Appending ', execution_id <dir>'
as secondary key only when afterExecutionId is set preserves existing
behaviour for UI/stats callers.
Two failing tests documenting the contract Task 1.5 will satisfy:
- cursorMonotonicity_sameMillisecondExchanges_fireExactlyOncePerTick
- firstRun_boundedByRuleCreatedAt_notRetentionHistory
Compile may fail until Task 1.4 adds AlertRule.withEvalState wither.
Adds an optional afterExecutionId field to SearchRequest. When combined
with a non-null timeFrom, ClickHouseSearchIndex applies a strictly-after
tuple predicate (start_time > ts OR (start_time = ts AND execution_id > id))
so same-millisecond exchanges can be consumed exactly once across ticks.
When afterExecutionId is null, timeFrom keeps its existing >= semantics —
no behaviour change for any current caller.
Also adds the SearchRequest.withCursor(ts, id) wither. Threads the field
through existing withInstanceIds / withEnvironment witheres. All existing
positional call-sites (SearchController, ExchangeMatchEvaluator,
ClickHouseSearchIndexIT, ClickHouseChunkPipelineIT) pass null for the new
slot.
Task 1.2 of docs/superpowers/plans/2026-04-22-per-exchange-exactly-once.md.
The evaluator-side wiring that actually supplies the cursor is Task 1.5.
Three distinct root causes, all reproducible when the classes run
solo — not order-dependent as the triage report suggested. Full
diagnosis in .planning/sse-flakiness-diagnosis.md.
1. AgentSseController.events auto-heal was over-permissive: any valid
JWT allowed registering an arbitrary path-id, a spoofing vector.
Surface symptom was the parked sseConnect_unknownAgent_returns404
test hanging on a 200-with-empty-stream instead of getting 404.
Fix: auto-heal requires JWT subject == path id.
2. SseConnectionManager.pingAll read ${agent-registry.ping-interval-ms}
(unprefixed). AgentRegistryConfig binds cameleer.server.agentregistry.*
— same family of bug as the MetricsFlushScheduler fix in a6944911.
Fix: corrected placeholder prefix.
3. Spring's SseEmitter doesn't flush response headers until the first
emitter.send(); clients on BodyHandlers.ofInputStream blocked on
the first body byte, making awaitConnection(5s) unreliable under a
15s ping cadence. Fix: send an initial ": connected" comment on
connect() so headers hit the wire immediately.
Verified: 9/9 SSE tests green across AgentSseControllerIT + SseSigningIT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the ExecutionController removal (0f635576), SearchIndexer
subscribed to ExecutionUpdatedEvent but nothing publishes that event.
Every SearchIndexerStats metric returned always-zero, and the admin
/api/v1/admin/clickhouse/pipeline endpoint that surfaced those stats
carried no signal.
Backend removed:
- core: SearchIndexer, SearchIndexerStats, ExecutionUpdatedEvent
- app: IndexerPipelineResponse DTO, /pipeline endpoint on
ClickHouseAdminController (field + ctor param)
- StorageBeanConfig.searchIndexer bean
UI removed:
- IndexerPipeline type + useIndexerPipeline hook in
api/queries/admin/clickhouse.ts
- Indexer Pipeline card in ClickHouseAdminPage.tsx (plus ProgressBar
import and pipeline* CSS classes)
OpenAPI schema.d.ts + openapi.json regenerated (stale /pipeline path
and IndexerPipelineResponse schema removed).
SearchIndex interface + ClickHouseSearchIndex impl kept — those are
live and used by SearchService + ExchangeMatchEvaluator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The @Scheduled placeholder read ${ingestion.flush-interval-ms:1000}
(unprefixed) but IngestionConfig binds cameleer.server.ingestion.* —
YAML tuning of the metrics flush interval was silently ignored and the
scheduler fell back to the 1s default in every environment.
Corrected to ${cameleer.server.ingestion.flush-interval-ms:1000}.
(The initial attempt to bind via SpEL #{@ingestionConfig.flushIntervalMs}
failed because beans registered via @EnableConfigurationProperties use a
compound bean name "<prefix>-<FQN>", not the simple camelCase form. The
property-placeholder path is sufficient — IngestionConfig still owns
the Java-side default.)
BackpressureIT: drops the obsolete workaround property
`ingestion.flush-interval-ms=60000`; the single prefixed override now
controls both buffer config and flush cadence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-layer fix for the TZ drift that caused stats reads to miss every row
when the JVM default TZ and CH session TZ disagreed:
- Insert side: ClickHouse JDBC 0.9.7 formats java.sql.Timestamp via
Timestamp.toString(), which uses JVM default TZ. A CEST JVM shipping
to a UTC CH server stored Unix timestamps off by the TZ offset (the
triage report's original symptom). Pinned JVM default to UTC in
CameleerServerApplication.main() — standard practice for observability
servers that push to time-series stores.
- Read side: stats_1m_* tables now declare bucket as DateTime('UTC'),
MV SELECTs wrap toStartOfMinute(start_time) in toDateTime(..., 'UTC')
so projections match column type, and ClickHouseStatsStore.lit(Instant)
emits toDateTime('...', 'UTC') rather than a bare literal — defence
in depth against future refactors.
Test class pins its own JVM TZ (the store IT builds its own
HikariDataSource, bypassing the main() path). Debug scaffolding from
the triage investigation removed.
Greenfield CH — no migration needed.
Verified: 14/14 ClickHouseStatsStoreIT green, plus 84/84 across all
ClickHouse IT classes (no regression from the JVM TZ default change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExecutionController was @ConditionalOnMissingBean(ChunkAccumulator.class),
and ChunkAccumulator is registered unconditionally — the legacy controller
never bound in any profile. Even if it had, IngestionService.ingestExecution
called executionStore.upsert(), and the only ExecutionStore impl
(ClickHouseExecutionStore) threw UnsupportedOperationException from upsert
and upsertProcessors. The entire RouteExecution → upsert path was dead code
carrying four transitive dependencies (RouteExecution import, eventPublisher
wiring, body-size-limit config, searchIndexer::onExecutionUpdated hook).
Removed:
- cameleer-server-app/.../controller/ExecutionController.java (whole file)
- ExecutionStore.upsert + upsertProcessors (interface methods)
- ClickHouseExecutionStore.upsert + upsertProcessors (thrower overrides)
- IngestionService.ingestExecution + toExecutionRecord + flattenProcessors
+ hasAnyTraceData + truncateBody + toJson/toJsonObject helpers
- IngestionService constructor now takes (DiagramStore, WriteBuffer<Metrics>);
dropped ExecutionStore + Consumer<ExecutionUpdatedEvent> + bodySizeLimit
- StorageBeanConfig.ingestionService(...) simplified accordingly
Untouched because still in use:
- ExecutionRecord / ProcessorRecord records (findById / findProcessors /
SearchIndexer / DetailController)
- SearchIndexer (its onExecutionUpdated never fires now since no-one
publishes ExecutionUpdatedEvent, but SearchIndexerStats is still
referenced by ClickHouseAdminController — separate cleanup)
- TaggedExecution record has no remaining callers after this change —
flagged in core-classes.md as a leftover; separate cleanup.
Rule docs updated:
- .claude/rules/app-classes.md: retired ExecutionController bullet, fixed
stale URL for ChunkIngestionController (it owns /api/v1/data/executions,
not /api/v1/ingestion/chunk/executions).
- .claude/rules/core-classes.md: IngestionService surface + note the dead
TaggedExecution.
Full IT suite post-removal: 560 tests run, 11 F + 1 E — same 12 failures
in the same 3 previously-parked classes (AgentSseControllerIT / SseSigningIT
SSE-timing + ClickHouseStatsStoreIT timezone bug). No regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pushToAgents fan-out iterates every distinct (app, env) slice in
the shared agent registry. In isolated runs that's 0, but with Spring
context reuse across IT classes we always see non-zero here. Assert
the response has a pushResult.total field (shape) rather than exact 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ForwardCompatIT: send a valid ExecutionChunk envelope with extra
unknown fields instead of a bare {futureField}. Was being parsed into
an empty/degenerate chunk and rejected with 400.
- ProtocolVersionIT.requestWithCorrectProtocolVersionPassesInterceptor:
same shape fix — minimal valid chunk so the controller's 400 is not
an ambiguous signal for interceptor-passthrough.
- BackpressureIT:
* TestPropertySource keys were "ingestion.*" but IngestionConfig is
bound under "cameleer.server.ingestion.*" — overrides were ignored
and the buffer stayed at its default 50_000, so the 503 overflow
branch was unreachable. Corrected the keys.
* MetricsFlushScheduler's @Scheduled uses a *different* key again
("ingestion.flush-interval-ms"), so we override that separately to
stop the default 1s flush from draining the buffer mid-test.
* executionIngestion_isSynchronous_returnsAccepted now uses the
chunked envelope format.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClickHouseChunkPipelineIT.setUp was loading /clickhouse/V2__executions.sql
and /clickhouse/V3__processor_executions.sql — resource paths that no
longer exist after 90083f88 collapsed the V1..V18 ClickHouse schema into
init.sql. Swapped for ClickHouseTestHelper.executeInitSql(jdbc).
ClickHouseExecutionReadIT.detailService_buildTree_withIterations was
asserting getLoopIndex() on children of a split, but DetailService's
seq-based buildTree path (buildTreeBySeq) maps FlatProcessorRecord.iteration
into ProcessorNode.iteration — not loopIndex. The loopIndex path is only
populated by buildTreeByProcessorId (the legacy ID-only fallback). Switched
the assertion to getIteration() to match the seq-driven reconstruction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both tests extend AbstractPostgresIT and inherit the Postgres jdbcTemplate,
which they were using to query ClickHouse-resident tables (executions,
processor_executions, route_diagrams). Now:
- DiagramLinkingIT reads diagramContentHash off the execution-detail REST
response (and tolerates JSON null by normalising to empty string, which
matches how the ingestion service stamps un-linked executions).
- IngestionSchemaIT asserts the reconstructed processor tree through the
execution-detail endpoint (covers both flattening on write and
buildTree on read) and reads processor bodies via the processor-snapshot
endpoint rather than raw processor_executions rows.
Both tests now use the ExecutionChunk envelope on POST /data/executions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Largest Cluster B test: seeded 10 executions via the legacy RouteExecution
shape which ChunkIngestionController silently degenerates to empty chunks,
then verified via a Postgres SELECT against a ClickHouse table. Both
failure modes addressed:
- All 10 seed payloads are now ExecutionChunk envelopes (chunkSeq=0,
final=true, flat processors[]).
- Pipeline visibility probe is the env-scoped search REST endpoint
(polling for the last corr-page-10 row).
- searchGet() helper was using the AGENT token; env-scoped read
endpoints require VIEWER+, so it now uses viewerJwt (matches what
searchPost already did).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DiagramControllerIT.postDiagram_dataAppearsAfterFlush now verifies via
GET /api/v1/environments/{env}/apps/{app}/routes/{route}/diagram instead
of a PG SELECT against the ClickHouse route_diagrams table.
DiagramRenderControllerIT seeds both a diagram and an execution on the
same route, then reads the stamped diagramContentHash off the execution-
detail REST response to drive the flat /api/v1/diagrams/{hash}/render
tests. The env-scoped endpoint only serves JSON, so SVG tests still hit
the content-hash endpoint — but the hash comes from REST now, not SQL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same pattern as DetailControllerIT:
- ExecutionControllerIT: all four tests now post ExecutionChunk envelopes
(chunkSeq=0, final=true) carrying instanceId/applicationId. Flush
visibility check pivoted from PG SELECT → env-scoped search REST.
- MetricsControllerIT: postMetrics_dataAppearsAfterFlush now stamps
collectedAt at now() and verifies through GET /environments/{env}/
agents/{id}/metrics with the default 1h lookback, looking for a
non-zero bucket on the metric name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/v1/data/executions is owned by ChunkIngestionController (the
legacy ExecutionController path is @ConditionalOnMissingBean(ChunkAccumulator)
and never binds). The old RouteExecution-shaped seed was silently parsed
as an empty ExecutionChunk and nothing landed in ClickHouse.
Rewrote the seed as a single final ExecutionChunk with chunkSeq=0 /
final=true and a flat processors[] carrying seq + parentSeq to preserve
the 3-level tree (DetailService.buildTree reconstructs the nested shape
for the API response). Execution-id lookup now goes through the search
REST API filtered by correlationId, per the no-raw-SQL preference.
Template for the other Cluster B ITs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both Testcontainers Postgres ITs were asserting exact counts on rows that
other classes in the shared context had already written.
- FlywayMigrationIT: treat the non-seed tables (users, server_config,
audit_log, application_config, app_settings) as "must exist; COUNT must
return a non-negative integer" rather than expecting exactly 0. The
seeded tables (roles=4, groups=1) still assert exact V1 baseline.
- ConfigEnvIsolationIT.findByEnvironment_excludesOtherEnvs: use unique
prefixed app slugs and switch containsExactlyInAnyOrder to contains +
doesNotContain, so the cross-env filter is still verified without
coupling to other tests' inserts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The agent list moved from /api/v1/agents to /api/v1/environments/{envSlug}/agents;
the 'valid JWT returns 200' test was hitting the retired flat path and
getting 404. The other 'without JWT' cases still pass because Spring
Security rejects them at the filter chain before URL routing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Registration now requires environmentId in the body (400 if missing), so
the stale register bodies were failing every downstream test that relied
on a registered agent. Affected helpers in:
- BootstrapTokenIT (static constant + inline body)
- JwtRefreshIT (registerAndGetTokens)
- RegistrationSecurityIT (registerAgent)
- SseSigningIT (registerAgentWithAuth)
- AgentSseControllerIT (registerAgent helper)
Also in JwtRefreshIT / RegistrationSecurityIT, the "access token can reach
a protected endpoint" tests were hitting env-scoped read endpoints that
now require VIEWER+. Redirected both to the AGENT-role heartbeat endpoint
— it proves the token is accepted by the security filter without being
coupled to RBAC rules for reader endpoints.
JwtRefreshIT.refreshWithValidToken also dropped an isNotEqualTo assertion
that assumed sub-second iat uniqueness — HMAC JWTs with second-precision
claims are byte-identical when minted for the same subject within the
same second, so the old assertion was flaky by design.
SseSigningIT / AgentSseControllerIT still have SSE-connection timing
failures unrelated to registration — parked separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two drifts corrected:
- registerAgent helper missing required environmentId (spec: 400 if absent).
- sendGroupCommand is now synchronous request-reply: returns 200 with an
aggregated CommandGroupResponse {success,total,responded,responses,timedOut}
— no longer 202 with {targetCount,commandIds}. Updated assertions and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four drifts against the current server contract, all now corrected:
- Registration body missing required environmentId (spec: 400 if absent).
- Agent list moved to env-scoped /api/v1/environments/{envSlug}/agents;
flat /api/v1/agents no longer exists.
- heartbeatUnknownAgent now auto-heals via JWT env claim (fb54f9cb);
the 404 branch is only reachable without a JWT, which the security
filter rejects before the controller sees the request.
- sseEndpoint is an absolute URL (ServletUriComponentsBuilder.fromCurrentContextPath),
so assert endsWith the path rather than equals-to-relative.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>