@bitkyc08/opencodex 2.10.0 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +77 -0
- package/README.md +4 -10
- package/bin/ocx.mjs +71 -18
- package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
- package/gui/dist/assets/index-ChZQsmBY.js +70 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +8 -4
- package/src/adapters/anthropic.ts +208 -14
- package/src/adapters/base.ts +16 -5
- package/src/adapters/cursor/effort-map.ts +3 -2
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/live-transport.ts +105 -95
- package/src/adapters/cursor/native-exec.ts +32 -6
- package/src/adapters/cursor/protobuf-request.ts +20 -15
- package/src/adapters/cursor/request-builder.ts +21 -7
- package/src/adapters/cursor/types.ts +7 -0
- package/src/adapters/google-antigravity-replay.ts +237 -21
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +50 -9
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +10 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +37 -11
- package/src/adapters/openai-chat.ts +284 -83
- package/src/adapters/openai-responses.ts +182 -24
- package/src/bridge.ts +177 -7
- package/src/chat/outbound.ts +78 -23
- package/src/claude/agents-inject.ts +27 -5
- package/src/claude/inbound.ts +11 -1
- package/src/claude/model-info.ts +13 -10
- package/src/claude/outbound.ts +17 -0
- package/src/cli/account-api.ts +24 -0
- package/src/cli/account-auth.ts +31 -6
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +5 -0
- package/src/cli/claude.ts +2 -1
- package/src/cli/doctor.ts +93 -22
- package/src/cli/export-command.ts +26 -12
- package/src/cli/help.ts +8 -6
- package/src/cli/index.ts +56 -22
- package/src/cli/integrations.ts +84 -1
- package/src/cli/observe.ts +54 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +1 -1
- package/src/cli/v2.ts +36 -9
- package/src/clients/config-export.ts +687 -10
- package/src/codex/account-lifecycle.ts +30 -5
- package/src/codex/account-usability.ts +22 -2
- package/src/codex/admission.ts +255 -0
- package/src/codex/auth-api.ts +427 -140
- package/src/codex/auth-context.ts +155 -30
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +62 -0
- package/src/codex/catalog/aggregation.ts +14 -1
- package/src/codex/catalog/bundled.ts +282 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/metadata.ts +51 -6
- package/src/codex/catalog/parsing.ts +6 -3
- package/src/codex/catalog/provider-fetch.ts +576 -41
- package/src/codex/catalog/sync.ts +505 -66
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-write-serialization.ts +241 -0
- package/src/codex/catalog.ts +2 -1
- package/src/codex/codex-write-lock.ts +372 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +441 -0
- package/src/codex/desired-state.ts +177 -0
- package/src/codex/features.ts +52 -8
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +257 -0
- package/src/codex/history-lock.ts +241 -0
- package/src/codex/history-migration-guardian.ts +18 -5
- package/src/codex/history-provider.ts +9 -2
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +176 -0
- package/src/codex/inject-coordination.ts +245 -0
- package/src/codex/inject.ts +605 -124
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +10 -1
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +158 -0
- package/src/codex/native-main-owner.ts +315 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1512 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +340 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +557 -0
- package/src/codex/project-config-warnings.ts +18 -4
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +86 -3
- package/src/codex/routing.ts +299 -62
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +39 -13
- package/src/codex/subagent-model-fallback.ts +73 -12
- package/src/codex/transition-state.ts +604 -0
- package/src/codex/upstream-host-health.ts +70 -0
- package/src/codex/user-identity.ts +266 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/config.ts +562 -26
- package/src/generated/jawcode-model-metadata.ts +2 -2
- package/src/grok/inject.ts +15 -4
- package/src/grok/inspect.ts +45 -0
- package/src/images/loop.ts +113 -20
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +165 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +101 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/bounded-body.ts +46 -8
- package/src/lib/bun-runtime.ts +110 -1
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/redact.ts +407 -2
- package/src/lib/shadow-call.ts +24 -0
- package/src/lib/translator-budget.ts +10 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +154 -2
- package/src/lib/windows-secret-acl.ts +212 -11
- package/src/lib/winsw.ts +9 -3
- package/src/oauth/index.ts +61 -3
- package/src/oauth/key-providers.ts +4 -0
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/store.ts +31 -6
- package/src/oauth/token-guardian.ts +9 -3
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/derive.ts +33 -1
- package/src/providers/free-directory.ts +3 -1
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/openai-sidecar.ts +64 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota.ts +334 -26
- package/src/providers/registry.ts +284 -16
- package/src/providers/xai-transport.ts +11 -4
- package/src/responses/compaction.ts +8 -1
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +44 -2
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +105 -0
- package/src/responses/spill-store.ts +45 -8
- package/src/responses/state.ts +161 -17
- package/src/router.ts +302 -16
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +204 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +590 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile.ts +423 -0
- package/src/routing/quota.ts +124 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/auth-cors.ts +46 -6
- package/src/server/chat-completions.ts +28 -13
- package/src/server/claude-messages.ts +23 -15
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +10 -1
- package/src/server/index.ts +238 -52
- package/src/server/lifecycle.ts +228 -9
- package/src/server/live.ts +14 -3
- package/src/server/management/agent-settings-routes.ts +64 -14
- package/src/server/management/combo-routes.ts +5 -5
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +42 -2
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +32 -113
- package/src/server/management/model-rows.ts +117 -0
- package/src/server/management/native-integration-routes.ts +587 -0
- package/src/server/management/oauth-account-routes.ts +1 -1
- package/src/server/management/provider-routes.ts +218 -117
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +177 -0
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +276 -24
- package/src/server/management/system-routes.ts +4 -0
- package/src/server/management-api.ts +84 -9
- package/src/server/management-auth.ts +43 -5
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +120 -6
- package/src/server/request-log.ts +26 -6
- package/src/server/responses/collaboration.ts +63 -8
- package/src/server/responses/compact.ts +272 -41
- package/src/server/responses/core.ts +730 -132
- package/src/server/responses/fetch-helpers.ts +15 -1
- package/src/server/responses-item-id-repair.ts +32 -3
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/search.ts +51 -6
- package/src/server/sse-payload-rewrite.ts +89 -12
- package/src/server/startup-health-cache.ts +7 -1
- package/src/server/ws-bridge.ts +11 -17
- package/src/service-manager-probe.ts +297 -0
- package/src/service.ts +222 -32
- package/src/tray/windows-tray.ps1 +9 -0
- package/src/tray/windows.ts +15 -7
- package/src/types.ts +194 -14
- package/src/update/index.ts +13 -13
- package/src/update/job.ts +24 -21
- package/src/update/notify.ts +7 -3
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +129 -10
- package/src/usage/log.ts +50 -15
- package/src/usage/summary.ts +4 -4
- package/src/vision/index.ts +6 -1
- package/src/web-search/loop.ts +161 -34
- package/gui/dist/assets/index-OY43ubAq.css +0 -1
- package/gui/dist/assets/index-YwNnKZcL.js +0 -67
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Management routes for the client-integration toggle.
|
|
3
|
+
*
|
|
4
|
+
* This module is an HTTP adapter and nothing more: every policy decision —
|
|
5
|
+
* what counts as ownership, when a mutation is refused, what gets journaled —
|
|
6
|
+
* belongs to src/integrations/writer.ts. Duplicating any of it here is how the
|
|
7
|
+
* API and the writer would start disagreeing about what happened to a file.
|
|
8
|
+
*
|
|
9
|
+
* Design of record: devlog/_plan/260802_client_toggle_api/040_wp4_management_api.md.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import type { IntegrationIO } from "../../integrations/config-io";
|
|
13
|
+
import { matchesOperationResult } from "../../integrations/journal";
|
|
14
|
+
import {
|
|
15
|
+
INTEGRATION_CLIENT_IDS,
|
|
16
|
+
isIntegrationClientId,
|
|
17
|
+
type IntegrationClientId,
|
|
18
|
+
} from "../../integrations/registry";
|
|
19
|
+
import { readIntegrationState } from "../../integrations/state";
|
|
20
|
+
import { createIntegrationStateStore, type IntegrationStateStore } from "../../integrations/store";
|
|
21
|
+
import {
|
|
22
|
+
applyIntegration,
|
|
23
|
+
disableIntegration,
|
|
24
|
+
restoreIntegration,
|
|
25
|
+
type IntegrationRestoreInput,
|
|
26
|
+
type IntegrationWriteInput,
|
|
27
|
+
type WriteRefused,
|
|
28
|
+
} from "../../integrations/writer";
|
|
29
|
+
import { jsonResponse } from "../auth-cors";
|
|
30
|
+
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
31
|
+
import type { ManagementContext } from "./context";
|
|
32
|
+
import { loadExportModels } from "./model-rows";
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
const INTEGRATION_ROUTE_PREFIX = "/api/client-integrations/";
|
|
36
|
+
const INTEGRATION_MUTATION_JOIN_MS = 120_000;
|
|
37
|
+
export const INTEGRATION_MUTATION_TERMINAL_MS = 10 * 60_000;
|
|
38
|
+
|
|
39
|
+
type IntegrationStateRecord = Awaited<ReturnType<typeof readIntegrationState>>;
|
|
40
|
+
type ApplyResult = Awaited<ReturnType<typeof applyIntegration>>;
|
|
41
|
+
type DisableResult = Awaited<ReturnType<typeof disableIntegration>>;
|
|
42
|
+
type RestoreResult = Awaited<ReturnType<typeof restoreIntegration>>;
|
|
43
|
+
|
|
44
|
+
export type IntegrationStateEnvelope = {
|
|
45
|
+
clientId: IntegrationClientId;
|
|
46
|
+
} & IntegrationStateRecord;
|
|
47
|
+
|
|
48
|
+
export interface IntegrationStateListEnvelope {
|
|
49
|
+
clients: IntegrationStateEnvelope[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type IntegrationToggleEnvelope =
|
|
53
|
+
| ({ clientId: IntegrationClientId } & ApplyResult)
|
|
54
|
+
| ({ clientId: IntegrationClientId } & DisableResult);
|
|
55
|
+
|
|
56
|
+
export type IntegrationRestoreEnvelope = {
|
|
57
|
+
clientId: IntegrationClientId;
|
|
58
|
+
} & RestoreResult;
|
|
59
|
+
|
|
60
|
+
export interface IntegrationJournalEnvelope {
|
|
61
|
+
operations: IntegrationJournalRow[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface IntegrationJournalRow {
|
|
65
|
+
opId: string;
|
|
66
|
+
clientId: IntegrationClientId;
|
|
67
|
+
kind: "apply" | "disable" | "refresh" | "restore";
|
|
68
|
+
at: string;
|
|
69
|
+
configPath: string;
|
|
70
|
+
snapshot: "none" | "stored" | "expired";
|
|
71
|
+
undoable: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface IntegrationToggleBody {
|
|
75
|
+
enabled: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface IntegrationRestoreBody {
|
|
79
|
+
opId: string;
|
|
80
|
+
confirmDrift?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface IntegrationMutationFlight {
|
|
84
|
+
key: string;
|
|
85
|
+
startedAt: number;
|
|
86
|
+
promise: Promise<unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class IntegrationMutationBusyError extends Error {
|
|
90
|
+
constructor(readonly clientId: IntegrationClientId) {
|
|
91
|
+
super("integration_mutation_busy");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const integrationMutationFlights = new Map<IntegrationClientId, IntegrationMutationFlight>();
|
|
96
|
+
let integrationMutationTestHooks: {
|
|
97
|
+
io?: IntegrationIO;
|
|
98
|
+
/**
|
|
99
|
+
* Bind every read and write in the request to one store. Without this a
|
|
100
|
+
* route test could isolate the writer but not the journal listing or the
|
|
101
|
+
* restore preflight (A-gate round 12).
|
|
102
|
+
*/
|
|
103
|
+
store?: IntegrationStateStore;
|
|
104
|
+
run?: (operation: () => Promise<unknown>) => Promise<unknown>;
|
|
105
|
+
} | null = null;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Home and environment overrides for tests.
|
|
109
|
+
*
|
|
110
|
+
* Bun's `os.homedir()` snapshots the real home at startup and ignores a later
|
|
111
|
+
* `process.env.HOME` assignment, so a test that only rewrites `HOME` still
|
|
112
|
+
* resolves the DEVELOPER'S client configs — which is exactly how a route test
|
|
113
|
+
* wrote a real `~/.hermes/config.yaml` during this work package. The writer
|
|
114
|
+
* and the state reader both already take `env`/`home` explicitly; the route
|
|
115
|
+
* simply had no way to pass them. It does now, and production leaves it unset.
|
|
116
|
+
*/
|
|
117
|
+
let integrationPathTestHooks: { env?: NodeJS.ProcessEnv; home?: string } | null = null;
|
|
118
|
+
|
|
119
|
+
export function setIntegrationPathTestHooks(hooks: { env?: NodeJS.ProcessEnv; home?: string } | null): void {
|
|
120
|
+
integrationPathTestHooks = hooks;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The `env`/`home` overrides, spread into every registry-resolving call. */
|
|
124
|
+
function pathOverrides(): { env?: NodeJS.ProcessEnv; home?: string } {
|
|
125
|
+
return {
|
|
126
|
+
...(integrationPathTestHooks?.env ? { env: integrationPathTestHooks.env } : {}),
|
|
127
|
+
...(integrationPathTestHooks?.home ? { home: integrationPathTestHooks.home } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
132
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function decodeClientPath(pathname: string): string | null {
|
|
136
|
+
if (!pathname.startsWith(INTEGRATION_ROUTE_PREFIX)) return null;
|
|
137
|
+
const encoded = pathname.slice(INTEGRATION_ROUTE_PREFIX.length);
|
|
138
|
+
if (!encoded || encoded.includes("/")) return null;
|
|
139
|
+
try {
|
|
140
|
+
return decodeURIComponent(encoded);
|
|
141
|
+
} catch {
|
|
142
|
+
return encoded;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function runIntegrationMutationFlight<T>(
|
|
147
|
+
clientId: IntegrationClientId,
|
|
148
|
+
key: string,
|
|
149
|
+
now: () => number,
|
|
150
|
+
operation: () => Promise<T>,
|
|
151
|
+
): Promise<T> {
|
|
152
|
+
const startedAt = now();
|
|
153
|
+
const current = integrationMutationFlights.get(clientId);
|
|
154
|
+
if (current) {
|
|
155
|
+
const age = startedAt - current.startedAt;
|
|
156
|
+
if (current.key === key && age < INTEGRATION_MUTATION_JOIN_MS) {
|
|
157
|
+
return current.promise as Promise<T>;
|
|
158
|
+
}
|
|
159
|
+
if (age <= INTEGRATION_MUTATION_TERMINAL_MS) {
|
|
160
|
+
return Promise.reject(new IntegrationMutationBusyError(clientId));
|
|
161
|
+
}
|
|
162
|
+
if (integrationMutationFlights.get(clientId) === current) {
|
|
163
|
+
integrationMutationFlights.delete(clientId);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const flight: IntegrationMutationFlight = {
|
|
168
|
+
key,
|
|
169
|
+
startedAt,
|
|
170
|
+
promise: Promise.resolve(),
|
|
171
|
+
};
|
|
172
|
+
const run = async (): Promise<unknown> => operation();
|
|
173
|
+
flight.promise = (integrationMutationTestHooks?.run
|
|
174
|
+
? integrationMutationTestHooks.run(run)
|
|
175
|
+
: run()
|
|
176
|
+
).finally(() => {
|
|
177
|
+
if (integrationMutationFlights.get(clientId) === flight) {
|
|
178
|
+
integrationMutationFlights.delete(clientId);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
integrationMutationFlights.set(clientId, flight);
|
|
182
|
+
return flight.promise as Promise<T>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function setIntegrationMutationFlightTestHooks(
|
|
186
|
+
hooks: {
|
|
187
|
+
io?: IntegrationIO;
|
|
188
|
+
/** Binds the WHOLE request — reads, writes and journal — to one store. */
|
|
189
|
+
store?: IntegrationStateStore;
|
|
190
|
+
run?: (operation: () => Promise<unknown>) => Promise<unknown>;
|
|
191
|
+
} | null,
|
|
192
|
+
): void {
|
|
193
|
+
integrationMutationTestHooks = hooks;
|
|
194
|
+
integrationMutationFlights.clear();
|
|
195
|
+
// Path overrides are part of the same isolation contract: clearing flights
|
|
196
|
+
// while leaving a temp home bound would let the next suite write real files.
|
|
197
|
+
if (hooks === null) integrationPathTestHooks = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* ONE store per request, used by every read and every write in that request.
|
|
202
|
+
* The route previously called module-level `listOperations`/`readSnapshot`
|
|
203
|
+
* while handing the writer a separate default store, so a test could not bind
|
|
204
|
+
* the whole operation to a temp root (A-gate round 12).
|
|
205
|
+
*/
|
|
206
|
+
function integrationStore(): IntegrationStateStore {
|
|
207
|
+
return integrationMutationTestHooks?.store ?? createIntegrationStateStore();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function buildIntegrationWriteInput(
|
|
211
|
+
clientId: IntegrationClientId,
|
|
212
|
+
ctx: ManagementContext,
|
|
213
|
+
store: IntegrationStateStore,
|
|
214
|
+
): Promise<IntegrationWriteInput> {
|
|
215
|
+
return {
|
|
216
|
+
clientId,
|
|
217
|
+
models: await loadExportModels(ctx.config),
|
|
218
|
+
config: ctx.config,
|
|
219
|
+
port: Number(ctx.url.port) || ctx.config.port,
|
|
220
|
+
store,
|
|
221
|
+
io: integrationMutationTestHooks?.io,
|
|
222
|
+
...pathOverrides(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The file's current bytes, or `null` when it is missing.
|
|
228
|
+
*
|
|
229
|
+
* `null` is NOT the same as `""`: an absent file and an empty one are
|
|
230
|
+
* different states, and collapsing them is what made this route disagree with
|
|
231
|
+
* the writer about whether an absence-result operation had drifted.
|
|
232
|
+
* `undefined` means we could not read it at all, which is neither.
|
|
233
|
+
*/
|
|
234
|
+
function currentConfigText(configPath: string): string | null | undefined {
|
|
235
|
+
try {
|
|
236
|
+
return readFileSync(configPath, "utf8");
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (isPlainRecord(error) && error.code === "ENOENT") return null;
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function invalidClientResponse(ctx: ManagementContext): Response {
|
|
244
|
+
return jsonResponse({
|
|
245
|
+
error: "invalid integration client",
|
|
246
|
+
code: "invalid_integration_client",
|
|
247
|
+
validClients: INTEGRATION_CLIENT_IDS,
|
|
248
|
+
}, 400, ctx.req, ctx.config);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function internalErrorResponse(error: unknown, ctx: ManagementContext): Response {
|
|
252
|
+
return jsonResponse({
|
|
253
|
+
error: error instanceof Error ? error.message : String(error),
|
|
254
|
+
code: "integration_internal_error",
|
|
255
|
+
}, 500, ctx.req, ctx.config);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function readJsonBody(ctx: ManagementContext): Promise<unknown | Response> {
|
|
259
|
+
try {
|
|
260
|
+
return await readManagementJsonBody(ctx.req);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
rethrowManagementBodyTooLarge(error);
|
|
263
|
+
return jsonResponse({
|
|
264
|
+
error: "invalid JSON body",
|
|
265
|
+
code: "invalid_json_body",
|
|
266
|
+
}, 400, ctx.req, ctx.config);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function writerFailureResponse(
|
|
271
|
+
clientId: IntegrationClientId,
|
|
272
|
+
/*
|
|
273
|
+
* The writer's own refusal type, not a structural echo of it.
|
|
274
|
+
*
|
|
275
|
+
* A local shape with an optional `message` accepted a refusal that had lost
|
|
276
|
+
* its message on the way here, which is exactly how the drift branch shipped
|
|
277
|
+
* without one. `WriteRefused` requires it, so the compiler now objects.
|
|
278
|
+
*/
|
|
279
|
+
result: WriteRefused,
|
|
280
|
+
ctx: ManagementContext,
|
|
281
|
+
): Response {
|
|
282
|
+
/*
|
|
283
|
+
* Routed by `reason`, never by `state` (006 §5). A `write_failed` that
|
|
284
|
+
* happens to occur while the file is in a `conflict` state is still a write
|
|
285
|
+
* failure, and mapping on state first silently dropped its message,
|
|
286
|
+
* snapshotPath, and residual — the recovery information the flag exists to
|
|
287
|
+
* carry (A-gate round 5, blocker 3).
|
|
288
|
+
*/
|
|
289
|
+
const recovery = {
|
|
290
|
+
message: result.message,
|
|
291
|
+
...(result.snapshotPath ? { snapshotPath: result.snapshotPath } : {}),
|
|
292
|
+
...(result.residual ? { residual: true } : {}),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
if (result.reason === "unsafe") {
|
|
296
|
+
return jsonResponse({
|
|
297
|
+
error: "integration config is unsafe",
|
|
298
|
+
code: "integration_unsafe",
|
|
299
|
+
clientId, state: result.state, reason: result.reason, ...recovery,
|
|
300
|
+
}, 409, ctx.req, ctx.config);
|
|
301
|
+
}
|
|
302
|
+
if (result.reason === "conflict") {
|
|
303
|
+
return jsonResponse({
|
|
304
|
+
error: "integration config conflicts with ownership record",
|
|
305
|
+
code: "integration_conflict",
|
|
306
|
+
clientId, state: result.state, reason: result.reason, ...recovery,
|
|
307
|
+
}, 409, ctx.req, ctx.config);
|
|
308
|
+
}
|
|
309
|
+
if (result.reason === "drift_requires_confirm") {
|
|
310
|
+
return jsonResponse({
|
|
311
|
+
error: "restore requires drift confirmation",
|
|
312
|
+
code: "integration_drift_confirmation_required",
|
|
313
|
+
clientId, state: result.state, reason: result.reason, ...recovery,
|
|
314
|
+
}, 409, ctx.req, ctx.config);
|
|
315
|
+
}
|
|
316
|
+
if (result.reason === "snapshot_expired") {
|
|
317
|
+
return jsonResponse({
|
|
318
|
+
error: "integration snapshot expired",
|
|
319
|
+
code: "integration_snapshot_expired",
|
|
320
|
+
clientId, state: result.state, reason: result.reason, ...recovery,
|
|
321
|
+
}, 410, ctx.req, ctx.config);
|
|
322
|
+
}
|
|
323
|
+
// not_installed, non_loopback, write_failed — always carry recovery fields.
|
|
324
|
+
return jsonResponse({
|
|
325
|
+
error: "integration mutation failed",
|
|
326
|
+
code: "integration_mutation_failed",
|
|
327
|
+
clientId, state: result.state, reason: result.reason, ...recovery,
|
|
328
|
+
}, 500, ctx.req, ctx.config);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
332
|
+
const { req, url } = ctx;
|
|
333
|
+
|
|
334
|
+
if (url.pathname === "/api/client-integrations" && req.method === "GET") {
|
|
335
|
+
try {
|
|
336
|
+
const models = await loadExportModels(ctx.config);
|
|
337
|
+
const port = Number(url.port) || ctx.config.port;
|
|
338
|
+
// One store for the whole collection read: without it this route retried
|
|
339
|
+
// maintenance and counted snapshots in the default store even when the
|
|
340
|
+
// caller had bound everything else to a temp root (A-gate round 13).
|
|
341
|
+
const store = integrationStore();
|
|
342
|
+
/*
|
|
343
|
+
* The envelope carries `clientId`, but it is not restated here: WP2 and
|
|
344
|
+
* WP3 both echo `input.clientId` back in their result, and `input`
|
|
345
|
+
* IS this route's client. Writing it twice made the compiler pick the
|
|
346
|
+
* later one silently — a duplicate that could only ever hide a
|
|
347
|
+
* disagreement, never surface it.
|
|
348
|
+
*/
|
|
349
|
+
const clients = INTEGRATION_CLIENT_IDS.map(clientId =>
|
|
350
|
+
readIntegrationState({ clientId, models, config: ctx.config, port, store, ...pathOverrides() }));
|
|
351
|
+
return jsonResponse({ clients } satisfies IntegrationStateListEnvelope, 200, req, ctx.config);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return internalErrorResponse(error, ctx);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (url.pathname === "/api/client-integrations/journal") {
|
|
358
|
+
if (req.method !== "GET") return null;
|
|
359
|
+
const requestedClient = url.searchParams.get("client");
|
|
360
|
+
if (requestedClient !== null && !isIntegrationClientId(requestedClient)) {
|
|
361
|
+
return invalidClientResponse(ctx);
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const store = integrationStore();
|
|
365
|
+
const storedOperations = store.listOperations(requestedClient ?? undefined);
|
|
366
|
+
const newestByClient = new Map<IntegrationClientId, string>();
|
|
367
|
+
for (const operation of storedOperations) {
|
|
368
|
+
if (!newestByClient.has(operation.clientId)) {
|
|
369
|
+
newestByClient.set(operation.clientId, operation.opId);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const operations: IntegrationJournalRow[] = storedOperations.map(operation => {
|
|
373
|
+
/*
|
|
374
|
+
* Resolved against the DISK, not read off the row.
|
|
375
|
+
*
|
|
376
|
+
* Retention deletes snapshot files and deliberately leaves the row's
|
|
377
|
+
* persisted tag saying `stored`, so copying that tag advertised undo
|
|
378
|
+
* for bytes that no longer exist — the GUI would offer the button and
|
|
379
|
+
* the restore route would answer 410. `readSnapshot` is the same
|
|
380
|
+
* resolver that preflight uses, which is what keeps the two agreeing.
|
|
381
|
+
*/
|
|
382
|
+
const snapshot = store.readSnapshot(operation).kind;
|
|
383
|
+
return {
|
|
384
|
+
opId: operation.opId,
|
|
385
|
+
clientId: operation.clientId,
|
|
386
|
+
kind: operation.kind,
|
|
387
|
+
at: operation.at,
|
|
388
|
+
configPath: operation.configPath,
|
|
389
|
+
snapshot,
|
|
390
|
+
/*
|
|
391
|
+
* The SAME resolution decides `undoable`. Reporting the tag honestly
|
|
392
|
+
* and then offering undo anyway is the identical defect one field
|
|
393
|
+
* over: restore would answer 410 for a row the GUI drew a button on.
|
|
394
|
+
* `none` stays undoable — restoring an op that created a file means
|
|
395
|
+
* deleting it, and that needs no snapshot bytes.
|
|
396
|
+
*/
|
|
397
|
+
/*
|
|
398
|
+
* Eligibility goes through the SAME matcher restore uses. This route
|
|
399
|
+
* used to represent a missing file as `""` and call that a match,
|
|
400
|
+
* while restore hashed `""` into a real digest — so the row was
|
|
401
|
+
* offered as Undo and then refused as drift.
|
|
402
|
+
*/
|
|
403
|
+
undoable: (() => {
|
|
404
|
+
if (snapshot === "expired") return false;
|
|
405
|
+
if (newestByClient.get(operation.clientId) !== operation.opId) return false;
|
|
406
|
+
const current = currentConfigText(operation.configPath);
|
|
407
|
+
return current === undefined ? false : matchesOperationResult(operation, current);
|
|
408
|
+
})(),
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
return jsonResponse({ operations } satisfies IntegrationJournalEnvelope, 200, req, ctx.config);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
return internalErrorResponse(error, ctx);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (url.pathname === "/api/client-integrations/restore") {
|
|
418
|
+
if (req.method !== "POST") return null;
|
|
419
|
+
const parsed = await readJsonBody(ctx);
|
|
420
|
+
if (parsed instanceof Response) return parsed;
|
|
421
|
+
if (!isPlainRecord(parsed) || typeof parsed.opId !== "string" || parsed.opId.trim().length === 0) {
|
|
422
|
+
return jsonResponse({
|
|
423
|
+
error: "opId must be a non-empty string",
|
|
424
|
+
code: "invalid_op_id",
|
|
425
|
+
}, 400, req, ctx.config);
|
|
426
|
+
}
|
|
427
|
+
if (parsed.confirmDrift !== undefined && typeof parsed.confirmDrift !== "boolean") {
|
|
428
|
+
return jsonResponse({
|
|
429
|
+
error: "confirmDrift must be a boolean",
|
|
430
|
+
code: "invalid_confirm_drift",
|
|
431
|
+
}, 400, req, ctx.config);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const opId = parsed.opId.trim();
|
|
435
|
+
const confirmDrift = parsed.confirmDrift ?? false;
|
|
436
|
+
try {
|
|
437
|
+
const store = integrationStore();
|
|
438
|
+
const operation = store.findOperation(opId);
|
|
439
|
+
if (!operation) {
|
|
440
|
+
return jsonResponse({
|
|
441
|
+
error: "integration operation not found",
|
|
442
|
+
code: "integration_operation_not_found",
|
|
443
|
+
opId,
|
|
444
|
+
}, 404, req, ctx.config);
|
|
445
|
+
}
|
|
446
|
+
const snapshot = store.readSnapshot(operation);
|
|
447
|
+
if (snapshot.kind === "expired") {
|
|
448
|
+
return jsonResponse({
|
|
449
|
+
error: "integration snapshot expired",
|
|
450
|
+
code: "integration_snapshot_expired",
|
|
451
|
+
opId,
|
|
452
|
+
}, 410, req, ctx.config);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const writeInput = await buildIntegrationWriteInput(operation.clientId, ctx, store);
|
|
456
|
+
const restoreInput: IntegrationRestoreInput = {
|
|
457
|
+
...writeInput,
|
|
458
|
+
opId,
|
|
459
|
+
confirmDrift,
|
|
460
|
+
};
|
|
461
|
+
const result = await runIntegrationMutationFlight(
|
|
462
|
+
operation.clientId,
|
|
463
|
+
`restore:${opId}:${confirmDrift}`,
|
|
464
|
+
writeInput.io?.now ?? Date.now,
|
|
465
|
+
() => Promise.resolve(restoreIntegration(restoreInput)),
|
|
466
|
+
);
|
|
467
|
+
if (!result.ok) {
|
|
468
|
+
/*
|
|
469
|
+
* Drift is NOT special-cased here.
|
|
470
|
+
*
|
|
471
|
+
* It used to be, and the hand-written branch dropped the writer's
|
|
472
|
+
* `message` — which is the only thing that tells the user WHICH file
|
|
473
|
+
* drifted and where its backup went. Every refusal leaves through the
|
|
474
|
+
* one serializer, so a refusal cannot lose its recovery fields by
|
|
475
|
+
* being routed through a shorter path.
|
|
476
|
+
*/
|
|
477
|
+
return writerFailureResponse(operation.clientId, result, ctx);
|
|
478
|
+
}
|
|
479
|
+
return jsonResponse(result satisfies IntegrationRestoreEnvelope, 200, req, ctx.config);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (error instanceof IntegrationMutationBusyError) {
|
|
482
|
+
return jsonResponse({
|
|
483
|
+
error: "integration mutation busy",
|
|
484
|
+
code: "integration_mutation_busy",
|
|
485
|
+
clientId: error.clientId,
|
|
486
|
+
}, 409, req, ctx.config);
|
|
487
|
+
}
|
|
488
|
+
return internalErrorResponse(error, ctx);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (req.method !== "GET" && req.method !== "PUT") return null;
|
|
493
|
+
const requestedClient = decodeClientPath(url.pathname);
|
|
494
|
+
if (requestedClient === null) return null;
|
|
495
|
+
if (!isIntegrationClientId(requestedClient)) return invalidClientResponse(ctx);
|
|
496
|
+
|
|
497
|
+
if (req.method === "GET") {
|
|
498
|
+
try {
|
|
499
|
+
const input = await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
|
|
500
|
+
const state = readIntegrationState(input);
|
|
501
|
+
return jsonResponse(state satisfies IntegrationStateEnvelope, 200, req, ctx.config);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
return internalErrorResponse(error, ctx);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const parsed = await readJsonBody(ctx);
|
|
508
|
+
if (parsed instanceof Response) return parsed;
|
|
509
|
+
if (!isPlainRecord(parsed) || typeof parsed.enabled !== "boolean") {
|
|
510
|
+
return jsonResponse({
|
|
511
|
+
error: "enabled must be a boolean",
|
|
512
|
+
code: "invalid_enabled",
|
|
513
|
+
}, 400, req, ctx.config);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const input = await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
|
|
518
|
+
const result = await runIntegrationMutationFlight(
|
|
519
|
+
requestedClient,
|
|
520
|
+
parsed.enabled ? "apply" : "disable",
|
|
521
|
+
input.io?.now ?? Date.now,
|
|
522
|
+
() => Promise.resolve(parsed.enabled
|
|
523
|
+
? applyIntegration(input)
|
|
524
|
+
: disableIntegration(input)),
|
|
525
|
+
);
|
|
526
|
+
if (!result.ok) return writerFailureResponse(requestedClient, result, ctx);
|
|
527
|
+
return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (error instanceof IntegrationMutationBusyError) {
|
|
530
|
+
return jsonResponse({
|
|
531
|
+
error: "integration mutation busy",
|
|
532
|
+
code: "integration_mutation_busy",
|
|
533
|
+
clientId: error.clientId,
|
|
534
|
+
}, 409, req, ctx.config);
|
|
535
|
+
}
|
|
536
|
+
return internalErrorResponse(error, ctx);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
@@ -121,7 +121,7 @@ function refreshedUsageSummary<T extends UsageSummary & { historyTruncated: bool
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
124
|
-
const { req, url, config, deps,
|
|
124
|
+
const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx;
|
|
125
125
|
|
|
126
126
|
if (url.pathname === "/api/logs" && req.method === "GET") {
|
|
127
127
|
const all = getRequestLogEntries();
|