From 53179daefaf465ea25669ceee021a8a664d9d84f Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 22 Sep 2026 21:59:32 -0400 Subject: [PATCH] feat(core): order projects by recent activity (#50790) --- .../runtime/server/global-sync/utils.test.ts | 4 +- .../src/settings/servers/inventory.test.ts | 2 +- packages/app/src/workspaces/inventory.test.ts | 2 +- .../client/src/promise/generated/types.ts | 2 +- packages/client/src/solid/data.ts | 2 +- packages/core/schema.json | 14 +++++- packages/core/src/database/migration.gen.ts | 2 + .../20260923013825_project_time_active.ts | 14 ++++++ packages/core/src/database/schema.gen.ts | 1 + .../core/src/database/v1-migration.bun.ts | 8 ++-- packages/core/src/location-lifecycle.ts | 20 ++++++++- packages/core/src/project.ts | 27 ++++++++++-- packages/core/src/project/sql.ts | 4 ++ .../test/effect/layer-node/node-build.test.ts | 1 + packages/core/test/lib/project.ts | 1 + packages/core/test/location.test.ts | 1 + packages/core/test/project.test.ts | 44 +++++++++++++++---- packages/protocol/openapi.json | 16 ++++++- packages/schema/src/project.ts | 1 + packages/tui/test/cli/tui/data.test.tsx | 22 +++++----- services/www/openapi.json | 16 ++++++- services/www/public/openapi.json | 16 ++++++- 22 files changed, 180 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/database/migration/20260923013825_project_time_active.ts diff --git a/packages/app/src/runtime/server/global-sync/utils.test.ts b/packages/app/src/runtime/server/global-sync/utils.test.ts index 5e39ccb8cc6..6acf71db514 100644 --- a/packages/app/src/runtime/server/global-sync/utils.test.ts +++ b/packages/app/src/runtime/server/global-sync/utils.test.ts @@ -93,7 +93,7 @@ describe("normalizeProviderList", () => { describe("normalizeProjectInfo", () => { test("keeps the project VCS backend", () => { - const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] } + const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1, active: 1 }, sandboxes: [] } expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git") expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg") expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined() @@ -124,7 +124,7 @@ describe("updateProjectInfo", () => { canonical: "/repo", name: "Repo", icon: { color: "purple" }, - time: { created: 1, updated: 2 }, + time: { created: 1, updated: 2, active: 2 }, sandboxes: ["/repo-sandbox"], } satisfies Project diff --git a/packages/app/src/settings/servers/inventory.test.ts b/packages/app/src/settings/servers/inventory.test.ts index 0f4d0b07873..5a67181c192 100644 --- a/packages/app/src/settings/servers/inventory.test.ts +++ b/packages/app/src/settings/servers/inventory.test.ts @@ -24,7 +24,7 @@ test("settings project inventory reads metadata without acquiring directory stor name: `Project ${index}`, icon: { color: "orange" }, commands: { start: "bun install" }, - time: { created: 1, updated: 1 }, + time: { created: 1, updated: 1, active: 1 }, sandboxes: [], worktrees: [], })) diff --git a/packages/app/src/workspaces/inventory.test.ts b/packages/app/src/workspaces/inventory.test.ts index 2abe8f3ed65..de623177e3e 100644 --- a/packages/app/src/workspaces/inventory.test.ts +++ b/packages/app/src/workspaces/inventory.test.ts @@ -107,7 +107,7 @@ describe("withWorktreeInventory", () => { id: "project", canonical: "/repo", name: "Before", - time: { created: 1, updated: 1 }, + time: { created: 1, updated: 1, active: 1 }, sandboxes: [], } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index b96dc0febd3..a7d41240fb1 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -296,7 +296,7 @@ export type ProjectIcon = { url?: string; override?: string; color?: string } export type ProjectCommands = { start?: string } -export type ProjectTime = { created: number; updated: number } +export type ProjectTime = { created: number; updated: number; active: number } export type PermissionSource = { type: "tool"; messageID: string; id: string } diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 4716d2599ff..4a1666f88b7 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -1788,7 +1788,7 @@ export function createData(config: CreateDataInput) { }, project: { list() { - return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated) + return Object.values(store.project.info).toSorted((a, b) => b.time.active - a.time.active) }, get(projectID: string) { return store.project.info[projectID] diff --git a/packages/core/schema.json b/packages/core/schema.json index de4e2ebf9d2..714676303c9 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "be60f352-8da1-40e1-8d70-dc41121cfbc5", - "prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"], + "id": "e98619ef-c334-45e3-8c48-9932ed1ef290", + "prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"], "ddl": [ { "name": "account_state", @@ -700,6 +700,16 @@ "entityType": "columns", "table": "project" }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "time_active", + "entityType": "columns", + "table": "project" + }, { "type": "text", "notNull": true, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 0d6a015dc27..722d13682fa 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -46,6 +46,7 @@ import m43 from "./migration/20260812213948_worktree.js" import m44 from "./migration/20260819222447_session_viewed_state.js" import m45 from "./migration/20260823191254_nullable_workspace_binding.js" import m46 from "./migration/20260910120000_clear_v1_session_permission.js" +import m47 from "./migration/20260923013825_project_time_active.js" export const migrations = [ m00, @@ -95,4 +96,5 @@ export const migrations = [ m44, m45, m46, + m47, ] satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260923013825_project_time_active.ts b/packages/core/src/database/migration/20260923013825_project_time_active.ts new file mode 100644 index 00000000000..94d105fe91f --- /dev/null +++ b/packages/core/src/database/migration/20260923013825_project_time_active.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration.js" + +const migration: DatabaseMigration.Migration = { + id: "20260923013825_project_time_active", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`project\` ADD \`time_active\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(`UPDATE \`project\` SET \`time_active\` = \`time_updated\`;`) + }) + }, +} + +export default migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index e3032391607..8461e542e5d 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -110,6 +110,7 @@ const schema: Omit = { \`time_created\` integer NOT NULL, \`time_updated\` integer NOT NULL, \`time_initialized\` integer, + \`time_active\` integer DEFAULT 0 NOT NULL, \`sandboxes\` text NOT NULL, \`commands\` text ); diff --git a/packages/core/src/database/v1-migration.bun.ts b/packages/core/src/database/v1-migration.bun.ts index 8378b5d8a24..11f6a331148 100644 --- a/packages/core/src/database/v1-migration.bun.ts +++ b/packages/core/src/database/v1-migration.bun.ts @@ -523,8 +523,8 @@ export function run(options: Options = {}): Effect.Effect { + if (!isSessionEvent(event) || !event.location) return Effect.void + const ref = LocationServiceMap.canonical(event.location) + if (ref.directory !== location.directory || ref.workspaceID !== location.workspaceID) return Effect.void + // Listeners run inside publish; keep the write and its Project event off that path. + return project.activate(location.project.id).pipe(Effect.forkIn(scope), Effect.asVoid) + }) + yield* Effect.addFinalizer(() => unsubscribe) let closed = false const shutdown = yield* Effect.cached( Effect.gen(function* () { @@ -48,5 +64,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, Location.node, Permission.node, Form.node, Rpc.node], + deps: [Bus.node, Location.node, Permission.node, Project.node, Form.node, Rpc.node], }) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index c03e11c6de9..2ce76ef2b79 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -2,7 +2,7 @@ export * as Project from "./project.js" import { Context, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" -import { and, asc, desc, eq, gte, isNull, lte } from "drizzle-orm" +import { and, asc, desc, eq, gte, isNull, lte, sql } from "drizzle-orm" import path from "path" import { AbsolutePath } from "./schema.js" import { Bus } from "./bus.js" @@ -57,9 +57,13 @@ export const root = Effect.fn("Project.root")(function* ( ) }) +const ACTIVATE_INTERVAL = 60_000 + export interface Interface { readonly list: () => Effect.Effect> readonly update: (input: UpdateInput) => Effect.Effect + /** Records Project activity for recency ordering, at most once per minute per Project. */ + readonly activate: (projectID: ID) => Effect.Effect /** Resolves and persists the owning Project. */ readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect } @@ -85,6 +89,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info { time: { created: row.time_created, updated: row.time_updated, + active: row.time_active, }, sandboxes: row.sandboxes, } @@ -201,7 +206,7 @@ const layer = Layer.effect( const rows = yield* db .select() .from(ProjectTable) - .orderBy(desc(ProjectTable.time_updated), asc(ProjectTable.id)) + .orderBy(desc(ProjectTable.time_active), asc(ProjectTable.id)) .all() .pipe(Effect.orDie) return rows.map(fromRow) @@ -233,6 +238,22 @@ const layer = Layer.effect( return project }) + const activated = new Map() + const activate = Effect.fn("Project.activate")(function* (projectID: ID) { + const now = Date.now() + if (now - (activated.get(projectID) ?? 0) < ACTIVATE_INTERVAL) return + activated.set(projectID, now) + const row = yield* db + .update(ProjectTable) + // Activity is not a metadata edit, so keep time_updated out of the implicit bump. + .set({ time_active: now, time_updated: sql`${ProjectTable.time_updated}` }) + .where(eq(ProjectTable.id, projectID)) + .returning() + .get() + .pipe(Effect.orDie) + if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row)) + }) + const cached = Effect.fnUntraced(function* (dir: string) { return yield* fs.readFileString(path.join(dir, "opencode")).pipe( Effect.map((value) => value.trim()), @@ -361,7 +382,7 @@ const layer = Layer.effect( }) }) - return Service.of({ list, update, resolve }) + return Service.of({ list, update, activate, resolve }) }), ) diff --git a/packages/core/src/project/sql.ts b/packages/core/src/project/sql.ts index 6bbee585209..3a31acc5052 100644 --- a/packages/core/src/project/sql.ts +++ b/packages/core/src/project/sql.ts @@ -19,6 +19,10 @@ export const ProjectTable = sqliteTable("project", { icon_color: text(), ...Timestamps, time_initialized: integer(), + time_active: integer() + .notNull() + .default(0) + .$defaultFn(() => Date.now()), sandboxes: absoluteArrayColumn().notNull(), commands: text({ mode: "json" }).$type<{ start?: string }>(), }) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index 4343c7fc049..d2aeca97d72 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -100,6 +100,7 @@ describe("node build", () => { return Project.Service.of({ list: () => Effect.succeed([]), update: () => Effect.die("not implemented"), + activate: () => Effect.void, resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), }) }), diff --git a/packages/core/test/lib/project.ts b/packages/core/test/lib/project.ts index 5ae3a27af62..30760c58e0f 100644 --- a/packages/core/test/lib/project.ts +++ b/packages/core/test/lib/project.ts @@ -13,6 +13,7 @@ export const globalProjectNode = makeGlobalNode({ return Project.Service.of({ list: () => Effect.succeed([]), update: () => Effect.die("not implemented"), + activate: () => Effect.void, resolve: (directory) => { const project = { id: Project.ID.global, directory, canonical: directory } return upsertProject(database.db, project).pipe(Effect.orDie, Effect.as(project)) diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index 499b0e61d1b..8843145f494 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -14,6 +14,7 @@ const projectLayer = Layer.succeed( Project.Service.of({ list: () => Effect.succeed([]), update: () => Effect.die("not implemented"), + activate: () => Effect.void, resolve: () => Effect.succeed({ id: Project.ID.make("project"), diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 2e8e37ff522..ef7ac7f7f22 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -18,7 +18,7 @@ import { testEffect } from "./lib/effect" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node]))) describe("Project.list", () => { - it.effect("returns complete projects ordered by recent update", () => + it.effect("returns complete projects ordered by recent activity", () => Effect.gen(function* () { const db = (yield* Database.Service).db const project = yield* Project.Service @@ -35,6 +35,7 @@ describe("Project.list", () => { sandboxes: [abs("/older/sandbox")], time_created: 1, time_updated: 1, + time_active: 4, }, { id: Project.ID.make("newer"), @@ -43,17 +44,12 @@ describe("Project.list", () => { time_created: 2, time_updated: 2, time_initialized: 3, + time_active: 3, }, ]) .run() expect(yield* project.list()).toEqual([ - { - id: Project.ID.make("newer"), - canonical: abs("/newer"), - time: { created: 2, updated: 2 }, - sandboxes: [], - }, { id: Project.ID.make("older"), canonical: abs("/older"), @@ -61,9 +57,15 @@ describe("Project.list", () => { name: "Older", icon: { color: "#000000" }, commands: { start: "bun dev" }, - time: { created: 1, updated: 1 }, + time: { created: 1, updated: 1, active: 4 }, sandboxes: [abs("/older/sandbox")], }, + { + id: Project.ID.make("newer"), + canonical: abs("/newer"), + time: { created: 2, updated: 2, active: 3 }, + sandboxes: [], + }, ]) }), ) @@ -83,6 +85,7 @@ describe("Project.update", () => { sandboxes: [], time_created: 1, time_updated: 1, + time_active: 1, }) .run() @@ -111,13 +114,36 @@ describe("Project.update", () => { expect((yield* project.list())[0]).toEqual({ id, canonical: abs("/update"), - time: { created: 1, updated: expect.any(Number) }, + time: { created: 1, updated: expect.any(Number), active: 1 }, sandboxes: [], }) }), ) }) +describe("Project.activate", () => { + it.effect("records activity without editing metadata time and throttles repeats", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const project = yield* Project.Service + const id = Project.ID.make("activate") + yield* db + .insert(ProjectTable) + .values({ id, worktree: abs("/activate"), sandboxes: [], time_created: 1, time_updated: 1, time_active: 1 }) + .run() + + yield* project.activate(id) + const first = (yield* project.list())[0] + expect(first?.time.updated).toBe(1) + expect(first?.time.active).toBeGreaterThan(1) + + yield* db.update(ProjectTable).set({ time_active: 2, time_updated: 1 }).run() + yield* project.activate(id) + expect((yield* project.list())[0]?.time).toEqual({ created: 1, updated: 1, active: 2 }) + }), + ) +}) + function remoteID(remote: string) { return Project.ID.make(Hash.fast(`git-remote:${remote}`)) } diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 23b920807a3..7ca75e0ec9b 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -1556,6 +1556,16 @@ } ] }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Metadata" + }, + { + "type": "null" + } + ] + }, "permissions": { "anyOf": [ { @@ -16308,9 +16318,13 @@ "updated": { "type": "integer", "minimum": 0 + }, + "active": { + "type": "integer", + "minimum": 0 } }, - "required": ["created", "updated"], + "required": ["created", "updated", "active"], "additionalProperties": false }, "Project.Vcs": { diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index 48f082e5cc2..5545265c623 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -32,6 +32,7 @@ export interface Commands extends Schema.Schema.Type {} export const Time = Schema.Struct({ created: NonNegativeInt, updated: NonNegativeInt, + active: NonNegativeInt, }).annotate({ identifier: "Project.Time" }) export interface Time extends Schema.Schema.Type {} diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 86233661429..cce439f4e54 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -153,7 +153,7 @@ test("syncs VCS info and applies branch updates", async () => { } }) -test("proactively syncs project metadata newest first", async () => { +test("proactively syncs project metadata most recently active first", async () => { const events = createEventStream() const calls = createFetch((url) => { if (url.pathname !== "/api/project") return @@ -162,14 +162,14 @@ test("proactively syncs project metadata newest first", async () => { id: "proj_old", canonical: "/old/project", name: "Old project", - time: { created: 1, updated: 1 }, + time: { created: 1, updated: 1, active: 3 }, sandboxes: [], }, { id: "proj_test", canonical: worktree, name: "OpenCode", - time: { created: 1, updated: 2 }, + time: { created: 1, updated: 2, active: 2 }, sandboxes: [], }, ]) @@ -196,18 +196,18 @@ test("proactively syncs project metadata newest first", async () => { try { await wait(() => data.project.get("proj_test") !== undefined) expect(data.project.list()).toEqual([ - { - id: "proj_test", - canonical: worktree, - name: "OpenCode", - time: { created: 1, updated: 2 }, - sandboxes: [], - }, { id: "proj_old", canonical: "/old/project", name: "Old project", - time: { created: 1, updated: 1 }, + time: { created: 1, updated: 1, active: 3 }, + sandboxes: [], + }, + { + id: "proj_test", + canonical: worktree, + name: "OpenCode", + time: { created: 1, updated: 2, active: 2 }, sandboxes: [], }, ]) diff --git a/services/www/openapi.json b/services/www/openapi.json index 23b920807a3..7ca75e0ec9b 100644 --- a/services/www/openapi.json +++ b/services/www/openapi.json @@ -1556,6 +1556,16 @@ } ] }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Metadata" + }, + { + "type": "null" + } + ] + }, "permissions": { "anyOf": [ { @@ -16308,9 +16318,13 @@ "updated": { "type": "integer", "minimum": 0 + }, + "active": { + "type": "integer", + "minimum": 0 } }, - "required": ["created", "updated"], + "required": ["created", "updated", "active"], "additionalProperties": false }, "Project.Vcs": { diff --git a/services/www/public/openapi.json b/services/www/public/openapi.json index 23b920807a3..7ca75e0ec9b 100644 --- a/services/www/public/openapi.json +++ b/services/www/public/openapi.json @@ -1556,6 +1556,16 @@ } ] }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Metadata" + }, + { + "type": "null" + } + ] + }, "permissions": { "anyOf": [ { @@ -16308,9 +16318,13 @@ "updated": { "type": "integer", "minimum": 0 + }, + "active": { + "type": "integer", + "minimum": 0 } }, - "required": ["created", "updated"], + "required": ["created", "updated", "active"], "additionalProperties": false }, "Project.Vcs": {