C4 follow-up #3: RFC 8594 Deprecation/Sunset headers + call counter on legacy hire routes
This commit is contained in:
1 parent
1fad88bad1
commit
d2b6f91023
4 files changed
+104
No files matched your search
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* In-process counter for deprecated HTTP routes (C4-followup #3).
|
||||
*
|
||||
* The counters let us assert "legacy HTTP hire traffic has hit zero"
|
||||
* before physically deleting the deprecated handlers. Keys are the
|
||||
* route label the handler passes in (e.g. `POST /api/companies/:companyId/agents`).
|
||||
*
|
||||
* Scope is intentionally narrow: module-level Map, synchronous mutation,
|
||||
* no IO, no fallback. An empty label is a programmer error — we throw
|
||||
* so it surfaces in tests instead of silently recording under "".
|
||||
*
|
||||
* The counter is not exposed over HTTP yet (scope creep). Internal
|
||||
* code reads via `readDeprecationCounters()`.
|
||||
*/
|
||||
|
||||
const deprecationCallCounters = new Map<string, number>();
|
||||
|
||||
export function incrementDeprecationCall(routeLabel: string): void {
|
||||
if (typeof routeLabel !== "string" || routeLabel.length === 0) {
|
||||
throw new Error("incrementDeprecationCall requires a non-empty routeLabel");
|
||||
}
|
||||
const previous = deprecationCallCounters.get(routeLabel) ?? 0;
|
||||
deprecationCallCounters.set(routeLabel, previous + 1);
|
||||
}
|
||||
|
||||
export function readDeprecationCounters(): Record<string, number> {
|
||||
const snapshot: Record<string, number> = {};
|
||||
for (const [label, count] of deprecationCallCounters) {
|
||||
snapshot[label] = count;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function resetDeprecationCounters(): void {
|
||||
deprecationCallCounters.clear();
|
||||
}
|
||||
@@ -10,8 +10,15 @@ import {
|
||||
} from "../agent-permissions";
|
||||
import { resolveApprovalDecision } from "../approval-policy";
|
||||
import { shouldAutoApprove } from "../execution-policy";
|
||||
import { incrementDeprecationCall } from "./deprecation-counter";
|
||||
import { requireParam, type AgentApiServer } from "./server-core";
|
||||
|
||||
// C4-followup #3: RFC 8594 deprecation headers for the legacy HTTP hire
|
||||
// route. Sunset is the 30-day removal gate — if the counter stays zero
|
||||
// through this date, delete the handler.
|
||||
const LEGACY_HIRE_ROUTE_LABEL = "POST /api/companies/:companyId/agents";
|
||||
const LEGACY_HIRE_SUNSET = new Date("2026-05-20T00:00:00Z").toUTCString();
|
||||
|
||||
export function registerAgentRoutes(server: AgentApiServer) {
|
||||
const db = server.db;
|
||||
const callbacks = server.callbacks;
|
||||
@@ -106,6 +113,12 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
* so residual callers are easy to audit.
|
||||
*/
|
||||
server.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => {
|
||||
// RFC 8594 deprecation signaling — emit on every response from this
|
||||
// handler regardless of downstream validation outcome.
|
||||
res.setHeader("Deprecation", "true");
|
||||
res.setHeader("Sunset", LEGACY_HIRE_SUNSET);
|
||||
res.setHeader("Link", '</docs/proposals/C4-followup.md>; rel="deprecation"');
|
||||
incrementDeprecationCall(LEGACY_HIRE_ROUTE_LABEL);
|
||||
const companyId = requireParam(params, "companyId");
|
||||
if (companyId !== token.companyId) {
|
||||
server.json(res, 403, { error: "Company mismatch" });
|
||||
|
||||
@@ -3,8 +3,16 @@ import log from "electron-log/main.js";
|
||||
import { approvalInputSchema } from "@shared/contracts";
|
||||
import { resolveApprovalDecision } from "../approval-policy";
|
||||
import { shouldAutoApprove } from "../execution-policy";
|
||||
import { incrementDeprecationCall } from "./deprecation-counter";
|
||||
import { requireParam, type AgentApiServer } from "./server-core";
|
||||
|
||||
// C4-followup #3: RFC 8594 deprecation headers for the legacy
|
||||
// approve_ceo_strategy HTTP path. Only this approval type is
|
||||
// deprecated — other types still use this route as first-class.
|
||||
const LEGACY_APPROVE_CEO_STRATEGY_LABEL =
|
||||
"POST /api/companies/:companyId/approvals type=approve_ceo_strategy";
|
||||
const LEGACY_APPROVE_CEO_STRATEGY_SUNSET = new Date("2026-05-20T00:00:00Z").toUTCString();
|
||||
|
||||
const approvalListQuerySchema = z.object({
|
||||
requestedByAgentId: z.string().uuid().optional(),
|
||||
relatedTaskId: z.string().uuid().optional(),
|
||||
@@ -43,6 +51,10 @@ export function registerApprovalRoutes(server: AgentApiServer) {
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (input.type === "approve_ceo_strategy") {
|
||||
res.setHeader("Deprecation", "true");
|
||||
res.setHeader("Sunset", LEGACY_APPROVE_CEO_STRATEGY_SUNSET);
|
||||
res.setHeader("Link", '</docs/proposals/C4-followup.md>; rel="deprecation"');
|
||||
incrementDeprecationCall(LEGACY_APPROVE_CEO_STRATEGY_LABEL);
|
||||
log.warn(
|
||||
`[deprecated][C4] POST /api/companies/${companyId}/approvals type=approve_ceo_strategy — prefer the file_approval internal tool (caller agent=${token.agentId}).`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
incrementDeprecationCall,
|
||||
readDeprecationCounters,
|
||||
resetDeprecationCounters,
|
||||
} from "../../src/main/api-server/deprecation-counter";
|
||||
|
||||
/**
|
||||
* C4-followup #3: the in-process deprecation counter backs the
|
||||
* "has legacy traffic hit zero?" decision. Keep these tests tight —
|
||||
* the module has one job.
|
||||
*/
|
||||
describe("deprecation counter", () => {
|
||||
beforeEach(() => {
|
||||
resetDeprecationCounters();
|
||||
});
|
||||
|
||||
it("increments and reads back the count for a single route", () => {
|
||||
incrementDeprecationCall("POST /legacy/hire");
|
||||
incrementDeprecationCall("POST /legacy/hire");
|
||||
expect(readDeprecationCounters()).toEqual({ "POST /legacy/hire": 2 });
|
||||
});
|
||||
|
||||
it("tracks multiple route labels independently", () => {
|
||||
incrementDeprecationCall("POST /legacy/hire");
|
||||
incrementDeprecationCall("POST /legacy/approvals");
|
||||
incrementDeprecationCall("POST /legacy/hire");
|
||||
expect(readDeprecationCounters()).toEqual({
|
||||
"POST /legacy/hire": 2,
|
||||
"POST /legacy/approvals": 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets all counters back to empty", () => {
|
||||
incrementDeprecationCall("POST /legacy/hire");
|
||||
resetDeprecationCounters();
|
||||
expect(readDeprecationCounters()).toEqual({});
|
||||
});
|
||||
|
||||
it("throws on an empty routeLabel instead of silently recording", () => {
|
||||
expect(() => incrementDeprecationCall("")).toThrow(/non-empty routeLabel/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user