Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -452,10 +452,11 @@ async fn a_dropped_rekey_keeps_its_route_and_requests_after_the_worker_finished(
.project_state(&old_owner)
.await
.expect("project entry");
old_state.requeue_request(tracedecay_store::SessionRefreshBeginOrJoinRequestV1::new(
let request = tracedecay_store::SessionRefreshBeginOrJoinRequestV1::new(
tracedecay_domain::SessionId::new("session.finished-rekey").unwrap(),
tracedecay_store::SessionRefreshFrontierV1::new(0, 0).unwrap(),
));
);
old_state.requeue_request(request.clone());
assert_eq!(old_state.queued_request_count(), 1);

{
Expand Down Expand Up @@ -493,6 +494,10 @@ async fn a_dropped_rekey_keeps_its_route_and_requests_after_the_worker_finished(
"a rekey dropped after its join must leave the route without a live target"
);

// Keep the destination's real worker in history while checking the
// handoff. Otherwise it can consume the request before the queue is read.
let destination_history =
mount_held_project(&registry, &harness, &new_owner, HeldHistory::default()).await;
registry
.rekey_project(&old_owner, new_owner.clone(), harness.registered.clone())
.await;
Expand All @@ -505,11 +510,18 @@ async fn a_dropped_rekey_keeps_its_route_and_requests_after_the_worker_finished(
1,
"a rekey dropped after its join must retain the requests it staged"
);
let transferred = resumed.take_requests(1).pop().expect("transferred request");
assert!(
transferred.is_equivalent_to(&request),
"the parked handoff must preserve the exact request and frontier"
);
resumed.requeue_request(transferred);
assert!(
old_wake.wake(),
"the old route must reach the new owner once the retry completes"
);

destination_history.release.notify_one();
registry.shutdown().await;
}

Expand Down Expand Up @@ -540,10 +552,11 @@ async fn a_rekey_after_a_readmission_lands_every_route_and_request() {
.project_state(&a_owner)
.await
.expect("project entry");
a_state.requeue_request(tracedecay_store::SessionRefreshBeginOrJoinRequestV1::new(
let parked_request = tracedecay_store::SessionRefreshBeginOrJoinRequestV1::new(
tracedecay_domain::SessionId::new("session.readmitted-a").unwrap(),
tracedecay_store::SessionRefreshFrontierV1::new(0, 0).unwrap(),
));
);
a_state.requeue_request(parked_request.clone());

{
let mut rekey = std::pin::pin!(registry.rekey_project(
Expand Down Expand Up @@ -601,18 +614,35 @@ async fn a_rekey_after_a_readmission_lands_every_route_and_request() {
!a_wake.wake(),
"the parked handoff stays owed to its own destination"
);
// Keep both live workers in history while inspecting transferred work.
// Otherwise either owner can consume its queue before the handoff assertion.
let readmitted_history =
mount_held_project(&registry, &harness, &a_owner, HeldHistory::default()).await;
let destination_history =
mount_held_project(&registry, &harness, &d_owner, HeldHistory::default()).await;
let readmitted = registry
.project_state(&a_owner)
.await
.expect("readmitted project entry");
readmitted.requeue_request(tracedecay_store::SessionRefreshBeginOrJoinRequestV1::new(
let live_request = tracedecay_store::SessionRefreshBeginOrJoinRequestV1::new(
tracedecay_domain::SessionId::new("session.readmitted-live").unwrap(),
tracedecay_store::SessionRefreshFrontierV1::new(0, 0).unwrap(),
));
);
readmitted.requeue_request(live_request.clone());

registry
.rekey_project(&a_owner, d_owner.clone(), harness.registered.clone())
.await;
{
let mut rekey = std::pin::pin!(registry.rekey_project(
&a_owner,
d_owner.clone(),
harness.registered.clone(),
));
assert!(
rekey.as_mut().now_or_never().is_none(),
"the readmitted worker must be cancelled before releasing its history pass"
);
readmitted_history.release.notify_one();
rekey.await;
}

assert!(registry.project_state(&a_owner).await.is_none());
let landed = registry
Expand All @@ -624,6 +654,18 @@ async fn a_rekey_after_a_readmission_lands_every_route_and_request() {
2,
"the parked request and the readmitted entry's request must both reach the final owner"
);
let transferred = landed.take_requests(2);
for expected in [&parked_request, &live_request] {
assert!(
transferred
.iter()
.any(|request| request.is_equivalent_to(expected)),
"the handoff must retain both exact requests and frontiers"
);
}
for request in transferred {
landed.requeue_request(request);
}
assert!(
a_wake.wake(),
"the parked route must reach the new owner once the rekey completes"
Expand All @@ -636,6 +678,7 @@ async fn a_rekey_after_a_readmission_lands_every_route_and_request() {
// The destination retains the complete route set it absorbed: a later
// D-to-E rekey must still land both handles, or older callers die at
// the next move.
destination_history.release.notify_one();
registry
.rekey_project(&d_owner, e_owner.clone(), harness.registered.clone())
.await;
Expand Down
5 changes: 4 additions & 1 deletion plugin/chatgpt-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ not-found-or-not-authorized problem rather than an empty success.
Inside the server a `DaemonBridge` reads through `@tracedecay/sdk` (the
workspace's generated operation contracts, never hand-edited):

- MCP-transport operations (`project_list`, `search`, `node`, `impact`,
- Registry operations (`project_list`, `project_context`) use a projectless
`RegistryClient` over a profile-scoped `tracedecay serve` session; it takes
no base URL, project id, or token.
- MCP-transport project operations (`search`, `node`, `impact`,
`context`, `status`, `find_exact_symbol`) go through an `McpToolAdapter`
that spawns `tracedecay serve` as an MCP client.
- HTTP operations (`code_symbol_search`, `code_callers`, `code_callees`,
Expand Down
22 changes: 11 additions & 11 deletions plugin/chatgpt-extension/embedded/server.mjs

Large diffs are not rendered by default.

17 changes: 5 additions & 12 deletions plugin/chatgpt-extension/src/server/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from "@tracedecay/sdk";
import type { DaemonState, Failure, ProjectRef } from "../shared/view.js";
import { httpBaseUrl, probeDaemon, readDaemonAuthority, type AuthorityLookup, type DaemonAuthorityRecord } from "./authority.js";
import { RegistryClient } from "./registry-client.js";
import { classifyCode, DaemonFailure, ServeSession, toFailure } from "./serve-client.js";

export type BridgeOptions = {
Expand Down Expand Up @@ -90,29 +91,21 @@ export class DaemonBridge {
return this.#registry;
}

#registryClient(): TraceDecayClient {
return createClient({
baseUrl: "http://127.0.0.1",
projectId: "registry",
token: "unused-registry-reads-use-mcp",
mcp: {
callTool: (toolName, request, options) =>
this.#registrySession().callJson(toolName, asRecord(request), options.signal),
},
});
#registryClient(): RegistryClient {
return new RegistryClient((toolName, request, signal) => this.#registrySession().callJson(toolName, request, signal));
}

async listProjects(signal?: AbortSignal): Promise<readonly ProjectRef[]> {
await this.#requireAuthority();
const client = this.#registryClient();
const listed = await runSdk(() => client.operations.project_list({ limit: 10 }, optional(signal)));
const listed = await runSdk(() => client.projectList({ limit: 10 }, signal));
return listed.projects.map(projectRef);
}

async resolveProject(projectId: string, signal?: AbortSignal): Promise<ProjectRef> {
await this.#requireAuthority();
const client = this.#registryClient();
const context = await runSdk(() => client.operations.project_context({ project_selector: { project_id: projectId } }, optional(signal)));
const context = await runSdk(() => client.projectContext({ project_selector: { project_id: projectId } }, signal));
if (context.status === "not_found") {
const cached = this.#projects.get(projectId);
this.#projects.delete(projectId);
Expand Down
72 changes: 72 additions & 0 deletions plugin/chatgpt-extension/src/server/registry-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
OPERATIONS,
TraceDecayAbortError,
TraceDecayMalformedResponseError,
TraceDecayTransportError,
type OperationApplicationProjectContextRequest,
type OperationApplicationProjectContextResult,
type OperationApplicationProjectListRequest,
type OperationApplicationProjectListResult,
} from "@tracedecay/sdk";

/** Executes a generated MCP tool binding against a projectless daemon session. */
export type RegistryToolCaller = (toolName: string, request: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>;

type RegistryDescriptor<Req, Res> = {
readonly operation: string;
readonly transport: { readonly kind: string; readonly toolName?: string };
readonly decodeRequest: (value: unknown) => Req;
readonly decodeResult: (value: unknown) => Res;
};

function registryDescriptor<Req, Res>(operation: string): RegistryDescriptor<Req, Res> {
const descriptor = OPERATIONS.find((candidate) => candidate.operation === operation);
if (descriptor === undefined || descriptor.transport.kind !== "mcp_tool") {
throw new TypeError(`${operation} is not an MCP-backed operation in the canonical catalog`);
}
return descriptor as unknown as RegistryDescriptor<Req, Res>;
}

const PROJECT_LIST = registryDescriptor<OperationApplicationProjectListRequest, OperationApplicationProjectListResult>("project_list");
const PROJECT_CONTEXT = registryDescriptor<OperationApplicationProjectContextRequest, OperationApplicationProjectContextResult>("project_context");

/**
* Projectless registry capability. It has no base URL, project id, or token:
* registry reads are admitted by the profile-scoped serve session alone, with
* operation identity and request/result contracts taken from the generated
* catalog.
*/
export class RegistryClient {
readonly #call: RegistryToolCaller;

constructor(call: RegistryToolCaller) {
this.#call = call;
}

projectList(request: OperationApplicationProjectListRequest, signal?: AbortSignal): Promise<OperationApplicationProjectListResult> {
return this.#invoke(PROJECT_LIST, request, signal);
}

projectContext(request: OperationApplicationProjectContextRequest, signal?: AbortSignal): Promise<OperationApplicationProjectContextResult> {
return this.#invoke(PROJECT_CONTEXT, request, signal);
}

async #invoke<Req, Res>(descriptor: RegistryDescriptor<Req, Res>, request: Req, signal?: AbortSignal): Promise<Res> {
const toolName = descriptor.transport.toolName as string;
const decoded = descriptor.decodeRequest(request) as Record<string, unknown>;
let response: unknown;
try {
response = await this.#call(toolName, decoded, signal);
} catch (cause) {
if (signal?.aborted) throw new TraceDecayAbortError(signal.reason ?? cause);
throw new TraceDecayTransportError(`MCP tool ${toolName} failed`, cause);
}
try {
return descriptor.decodeResult(response);
} catch (cause) {
throw new TraceDecayMalformedResponseError(`MCP tool ${toolName} returned an invalid ${descriptor.operation} result`, {
payload: { response, cause },
});
}
}
}
Loading