diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 15:31:49 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 15:31:49 +0900 |
| commit | b60586285863f8bb82242a9df49c4d47e1235755 (patch) | |
| tree | dd38669dbd8092987bc50d16dcf523c68d43c460 /packages/transport-http/src | |
| parent | fb4a9217b55dd3ba11670104ac23536416d36940 (diff) | |
| parent | 076edf7d1dfc4dc818f173f751dcb1e57b5baaeb (diff) | |
| download | dispatch-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.ts | 149 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 79 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 1 |
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", ], }, |
