summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 15:31:49 +0900
committerAdam Malczewski <[email protected]>2026-06-28 15:31:49 +0900
commitb60586285863f8bb82242a9df49c4d47e1235755 (patch)
treedd38669dbd8092987bc50d16dcf523c68d43c460 /packages/transport-http/src
parentfb4a9217b55dd3ba11670104ac23536416d36940 (diff)
parent076edf7d1dfc4dc818f173f751dcb1e57b5baaeb (diff)
downloaddispatch-b60586285863f8bb82242a9df49c4d47e1235755.tar.gz
dispatch-b60586285863f8bb82242a9df49c4d47e1235755.zip
Merge branch 'feature/workspace-star' into predev
# Conflicts: # packages/provider-concurrency/src/concurrency-manager.ts # packages/provider-concurrency/src/extension.ts
Diffstat (limited to 'packages/transport-http/src')
-rw-r--r--packages/transport-http/src/app.test.ts149
-rw-r--r--packages/transport-http/src/app.ts79
-rw-r--r--packages/transport-http/src/extension.ts1
3 files changed, 229 insertions, 0 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 7ae0354..03f1959 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -110,6 +110,7 @@ function createFakeConversationStore(
title: "default",
defaultCwd: null,
defaultComputerId: null,
+ starred: false,
createdAt: 0,
lastActivityAt: 0,
};
@@ -207,6 +208,9 @@ function createFakeConversationStore(
async setWorkspaceDefaultComputerId(id, defaultComputerId) {
return { ...sampleWorkspace, id, defaultComputerId };
},
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
async deleteWorkspace() {
return { closedCount: 0 };
},
@@ -3562,6 +3566,7 @@ describe("Workspaces", () => {
title: "proj",
defaultCwd: null,
defaultComputerId: null,
+ starred: false,
createdAt: 1000,
lastActivityAt: 2000,
};
@@ -3756,6 +3761,150 @@ describe("Workspaces", () => {
const res = await app.request("/workspaces/default", { method: "DELETE" });
expect(res.status).toBe(409);
});
+
+ // ─── Star/unstar workspace (concurrency priority) ────────────────────────
+
+ it("PUT /workspaces/:id/star persists + notifies the concurrency service", async () => {
+ let starredCalled: { id: string; starred: boolean } | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ };
+ const concurrencyService = {
+ acquire: async () => () => {},
+ reportRateLimit() {},
+ setLimit() {},
+ getLimit: () => undefined,
+ getLimits: () => [],
+ getStatus: () => undefined,
+ getStatusAll: () => [],
+ notifyWorkspaceStarred(id: string, starred: boolean) {
+ starredCalled = { id, starred };
+ },
+ destroy() {},
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ ...(concurrencyService !== undefined ? { concurrencyService } : {}),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/star", { method: "PUT" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.starred).toBe(true);
+ expect(starredCalled).toEqual({ id: "proj", starred: true });
+ });
+
+ it("DELETE /workspaces/:id/star persists + notifies the concurrency service", async () => {
+ let starredCalled: { id: string; starred: boolean } | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ };
+ const concurrencyService = {
+ acquire: async () => () => {},
+ reportRateLimit() {},
+ setLimit() {},
+ getLimit: () => undefined,
+ getLimits: () => [],
+ getStatus: () => undefined,
+ getStatusAll: () => [],
+ notifyWorkspaceStarred(id: string, starred: boolean) {
+ starredCalled = { id, starred };
+ },
+ destroy() {},
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ concurrencyService,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/star", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.starred).toBe(false);
+ expect(starredCalled).toEqual({ id: "proj", starred: false });
+ });
+
+ it("PUT /workspaces/:id/star rejects invalid slug", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/Bad Slug!/star", { method: "PUT" });
+ expect(res.status).toBe(400);
+ });
+
+ it("DELETE /workspaces/:id cleans up the in-memory starred cache (bug fix)", async () => {
+ // Bug 1 fix: deleting a workspace must notify the concurrency service to
+ // remove the workspace ID from the starred cache, preventing stale IDs.
+ let starredCalled: { id: string; starred: boolean } | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async deleteWorkspace() {
+ return { closedCount: 2 };
+ },
+ };
+ const concurrencyService = {
+ acquire: async () => () => {},
+ reportRateLimit() {},
+ setLimit() {},
+ getLimit: () => undefined,
+ getLimits: () => [],
+ getStatus: () => undefined,
+ getStatusAll: () => [],
+ notifyWorkspaceStarred(id: string, starred: boolean) {
+ starredCalled = { id, starred };
+ },
+ destroy() {},
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ concurrencyService,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ // The concurrency service must be notified to clear the starred cache.
+ expect(starredCalled).toEqual({ id: "proj", starred: false });
+ });
+
+ it("PUT /workspaces/:id/star logs warning when concurrency service is absent (bug fix)", async () => {
+ // Bug 2 fix: when the concurrency service is not loaded, the star toggle
+ // persists but the priority cache is not updated. A warning log makes this
+ // degraded behavior visible.
+ const logger = createFakeLogger();
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ };
+ // NOTE: no concurrencyService provided — simulates the extension being absent.
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+ const res = await app.request("/workspaces/proj/star", { method: "PUT" });
+ expect(res.status).toBe(200);
+ // The star persisted, but a warning was logged about the missing service.
+ const warnings = logger.records.filter((r) => r.level === "warn");
+ expect(warnings.some((r) => r.msg.includes("concurrency service is not loaded"))).toBe(true);
+ });
});
it("POST /chat threads workspaceId", async () => {
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index ee7b2de..656be9d 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -1529,6 +1529,10 @@ export function createApp(opts: CreateServerOptions): Hono {
try {
const { closedCount } = await opts.conversationStore.deleteWorkspace(workspaceId);
+ // Clean up the in-memory starred cache so a deleted workspace's ID
+ // doesn't linger (and so a future workspace re-created with the same
+ // slug doesn't inherit the stale starred state).
+ opts.concurrencyService?.notifyWorkspaceStarred(workspaceId, false);
log.info("workspaces: deleted", { workspaceId, closedCount });
const response: DeleteWorkspaceResponse = { workspaceId, closedCount };
return c.json(response, 200);
@@ -1538,6 +1542,81 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ // ─── Star/unstar workspace (concurrency priority) ───────────────────────────
+ // Starred workspaces receive PRIORITY in the concurrency limiter queue —
+ // their agents jump ahead of agents from non-starred workspaces. The
+ // starred state is persisted in the conversation store AND the in-memory
+ // cache in the concurrency service is notified so already-queued agents
+ // are re-prioritized immediately.
+
+ app.put("/workspaces/:id/star", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (!isValidWorkspaceSlug(workspaceId)) {
+ return c.json(
+ {
+ error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)",
+ },
+ 400,
+ );
+ }
+ try {
+ const workspace = await opts.conversationStore.setWorkspaceStarred(workspaceId, true);
+ // Notify the concurrency service's in-memory cache so queued agents
+ // from this workspace jump ahead immediately. When the concurrency
+ // service is absent (extension not loaded), the starred state is
+ // persisted but the in-memory priority cache is NOT updated — log a
+ // warning so the degraded behavior is visible (queued agents keep
+ // their old priority until restart or the extension is loaded).
+ if (opts.concurrencyService !== undefined) {
+ opts.concurrencyService.notifyWorkspaceStarred(workspaceId, true);
+ } else {
+ log.warn(
+ "workspaces: starred but concurrency service is not loaded — priority cache not updated",
+ {
+ workspaceId,
+ },
+ );
+ }
+ log.info("workspaces: starred", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: star failure", { err, workspaceId });
+ return c.json({ error: "Failed to star workspace" }, 500);
+ }
+ });
+
+ app.delete("/workspaces/:id/star", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (!isValidWorkspaceSlug(workspaceId)) {
+ return c.json(
+ {
+ error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)",
+ },
+ 400,
+ );
+ }
+ try {
+ const workspace = await opts.conversationStore.setWorkspaceStarred(workspaceId, false);
+ if (opts.concurrencyService !== undefined) {
+ opts.concurrencyService.notifyWorkspaceStarred(workspaceId, false);
+ } else {
+ log.warn(
+ "workspaces: unstarred but concurrency service is not loaded — priority cache not updated",
+ {
+ workspaceId,
+ },
+ );
+ }
+ log.info("workspaces: unstarred", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: unstar failure", { err, workspaceId });
+ return c.json({ error: "Failed to unstar workspace" }, 500);
+ }
+ });
+
// ─── Heartbeat (per-workspace AI loop) ─────────────────────────────────────
// The config + run history for a workspace's heartbeat loop. Delegated to
// the HeartbeatService (provided by the `heartbeat` extension). When
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index 7eb39c9..f424e42 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -72,6 +72,7 @@ export const manifest: Manifest = {
"/workspaces/:id/heartbeat",
"/workspaces/:id/heartbeat/runs",
"/workspaces/:id/heartbeat/runs/:runId/stop",
+ "/workspaces/:id/star",
"/workspaces/:id/title",
],
},