diff --git a/README.md b/README.md index ac1d6ba8ea..af87dced4d 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,12 @@ [![Discord](https://img.shields.io/discord/1446553342699507907?logo=discord&label=Discord)](https://cdr.co/mux-discord) [![X (formerly Twitter)](https://img.shields.io/badge/Follow-%40codermux-black?logo=x)](https://x.com/codermux) - > [!IMPORTANT] > This project was renamed from Mux to Shux after Mux.com raised a trademark concern. “Mux” is a common technical abbreviation of “multiplexer” and we do not expect confusion between the projects, but chose to rename ours rather than spend more time on the dispute. “Shux” captures our reaction to the process and bears no other significance. -> -Shux is a desktop & browser application for parallel agentic development. It enables developers to plan and execute tasks with multiple AI agents on local or remote compute. +> +> Shux is a desktop & browser application for parallel agentic development. It enables developers to plan and execute tasks with multiple AI agents on local or remote compute.

Shux product demo

diff --git a/bun.lock b/bun.lock index 1e6e403f9c..a1751a4c2d 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@duckdb/node-api": "^1.4.4-r.1", + "@duckdb/node-api": "^1.5.5-r.4", "@homebridge/ciao": "^1.3.4", "@jitl/quickjs-wasmfile-release-asyncify": "^0.31.0", "@lydell/node-pty": "1.1.0", @@ -561,19 +561,25 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@duckdb/node-api": ["@duckdb/node-api@1.4.4-r.1", "", { "dependencies": { "@duckdb/node-bindings": "1.4.4-r.1" } }, "sha512-oqaH9DXTJNwyLkd2FgJwmSnWVqjB5irbESeTeNVMBnM03iRaNY545BhfBDumu1TnOV2koIdG1mNsmjgq/ZTIkA=="], + "@duckdb/node-api": ["@duckdb/node-api@1.5.5-r.4", "", { "dependencies": { "@duckdb/node-bindings": "1.5.5-r.4" } }, "sha512-8v0CZNo7aM6GQCNUHERGTrZWIfss8xKzZPF3ACNhPdMMrt7UjkNI5nRQ9FTFO7Yx8ghxYxVpdotmBBMQ5vXUOA=="], - "@duckdb/node-bindings": ["@duckdb/node-bindings@1.4.4-r.1", "", { "optionalDependencies": { "@duckdb/node-bindings-darwin-arm64": "1.4.4-r.1", "@duckdb/node-bindings-darwin-x64": "1.4.4-r.1", "@duckdb/node-bindings-linux-arm64": "1.4.4-r.1", "@duckdb/node-bindings-linux-x64": "1.4.4-r.1", "@duckdb/node-bindings-win32-x64": "1.4.4-r.1" } }, "sha512-NFm0AMrK3kiVLQhgnGUEjX5c8Elm93dYePZ9BUCvvd0AVVTKEBeRhBp9afziuzP3Sl5+7XQ1TyaBLsZJKKBDBQ=="], + "@duckdb/node-bindings": ["@duckdb/node-bindings@1.5.5-r.4", "", { "dependencies": { "detect-libc": "^2.1.2" }, "optionalDependencies": { "@duckdb/node-bindings-darwin-arm64": "1.5.5-r.4", "@duckdb/node-bindings-darwin-x64": "1.5.5-r.4", "@duckdb/node-bindings-linux-arm64": "1.5.5-r.4", "@duckdb/node-bindings-linux-arm64-musl": "1.5.5-r.4", "@duckdb/node-bindings-linux-x64": "1.5.5-r.4", "@duckdb/node-bindings-linux-x64-musl": "1.5.5-r.4", "@duckdb/node-bindings-win32-arm64": "1.5.5-r.4", "@duckdb/node-bindings-win32-x64": "1.5.5-r.4" } }, "sha512-n+4hEfjp4vny3BuWn5p1Gh5CzHaPRxoI8TzTytxL1GMlIKKrXcg/o6sSAjrsROUXGAM4WQCiWPLKnPsjJJSggg=="], - "@duckdb/node-bindings-darwin-arm64": ["@duckdb/node-bindings-darwin-arm64@1.4.4-r.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/NtbkCgCAOJDxw41XvSGV/mxQAlsx+2xUvhIVUj6fxoOfTG4jTttRhuphwE3EXNoWzJOjZxCZ5LwhC/qb6ZwLg=="], + "@duckdb/node-bindings-darwin-arm64": ["@duckdb/node-bindings-darwin-arm64@1.5.5-r.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4OdO3pkoJzAZDnZ2iyehi67XL4+WqHPCGYWbyAsYyhGJS+WscGrAnFhMhHudMM2XF8pIEM2tuOKMmwWnTNN2wQ=="], - "@duckdb/node-bindings-darwin-x64": ["@duckdb/node-bindings-darwin-x64@1.4.4-r.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-lzFRDrZwc1EoV513vmKufasiAQ2WlhEb0O6guRBarbvOKKVhRb8tQ5H7LPVTrIewjTI3XDgHrnK+vfh9L+xQcA=="], + "@duckdb/node-bindings-darwin-x64": ["@duckdb/node-bindings-darwin-x64@1.5.5-r.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-WHg3E+TupdujG31LGujMMcmtPIdW6rqRHeihlKgJR8EMetHycoYkwYxRud0niitJvS+uV0du/6pdemzs3HG9GQ=="], - "@duckdb/node-bindings-linux-arm64": ["@duckdb/node-bindings-linux-arm64@1.4.4-r.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-wq92/EcTiOTRW1RSDOwjeLyMMXWwNVNwU21TQdfu3sgS86+Ih3raaK68leDgY5cWgf72We3J2W7HYz8GwxcMYw=="], + "@duckdb/node-bindings-linux-arm64": ["@duckdb/node-bindings-linux-arm64@1.5.5-r.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-VIeHMpYAKpGiWZ4QYsOhODAHDEcXcn089aKty0MFsMEKaEfRJWixKwnwXy/ba2/wwFmnPiSFhKNqygj2BrQbqw=="], - "@duckdb/node-bindings-linux-x64": ["@duckdb/node-bindings-linux-x64@1.4.4-r.1", "", { "os": "linux", "cpu": "x64" }, "sha512-fjYNc+t4/T7mhzZ57oJoIQaWvbYVvxhidcNNansQFiWnd6/JMLCULd4qnt8XI3Tt2BrZsraH690KSBIS3QPt0w=="], + "@duckdb/node-bindings-linux-arm64-musl": ["@duckdb/node-bindings-linux-arm64-musl@1.5.5-r.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Tswnf+/XWpOcFJNUUu1fkFIX/su4tMcnNz0ZV0Nru3/CkJKIMwBh+VL4SM9YV4zPK90GC4Lm8gzafjc1qDmfyQ=="], - "@duckdb/node-bindings-win32-x64": ["@duckdb/node-bindings-win32-x64@1.4.4-r.1", "", { "os": "win32", "cpu": "x64" }, "sha512-+J+MUYGvYWfX0balWToDIy3CBYg7hHI0KQUQ39+SniinXlMF8+puRW6ebyQ+AXrcrKkwuj4wzJuEBD0AdhHGtw=="], + "@duckdb/node-bindings-linux-x64": ["@duckdb/node-bindings-linux-x64@1.5.5-r.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EY+CL/4h8MQZd9MxTBq+98m3U9osmvHBwhE9b5fMWQGt2I6p1j8fvX0SXiwy9KBfQIbjVUOf5XvGdXncI54KSg=="], + + "@duckdb/node-bindings-linux-x64-musl": ["@duckdb/node-bindings-linux-x64-musl@1.5.5-r.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h0ixrgGHtHh+C/Fu1eAL9hX4iCf8yuyJN6Y24Gh8axXXP8UuSh0rQRAQUQTYarQ8pm2qSG/67ZYjyYYydD+J/w=="], + + "@duckdb/node-bindings-win32-arm64": ["@duckdb/node-bindings-win32-arm64@1.5.5-r.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uorAnySIMwWkRDuUhM1yTDxWIislnX8mndhFIw6r4VKhVW61HEOOMX5oAgkSQFII4RNWpC5PCcUAEhSATgNvVQ=="], + + "@duckdb/node-bindings-win32-x64": ["@duckdb/node-bindings-win32-x64@1.5.5-r.4", "", { "os": "win32", "cpu": "x64" }, "sha512-X9XGcWQ10P3mvUIaMXXk2bi94Cow7b/ziTPMKxJ0U8U3wQPxsLzEUP7D7C/KrBWINl5am2k+5SkDVyA/THUgPg=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], diff --git a/flake.nix b/flake.nix index 242be6b69b..73f445d47c 100644 --- a/flake.nix +++ b/flake.nix @@ -84,7 +84,7 @@ outputHashMode = "recursive"; # Marker used by scripts/update_flake_hash.sh to update this hash in place. - outputHash = "sha256-Ci2q4ZCIymKhf4rinh6VKdzaGVBCRBsMUgjQOXTqotM="; # shux-offline-cache-hash + outputHash = "sha256-ri3Q1gY4ifnjT9FMg3wNqoIT5OJbfQRKAWw5Zt8DH9k="; # shux-offline-cache-hash }; configurePhase = '' diff --git a/package.json b/package.json index 8cae898fa8..41a8f2f391 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@duckdb/node-api": "^1.4.4-r.1", + "@duckdb/node-api": "^1.5.5-r.4", "@homebridge/ciao": "^1.3.4", "@jitl/quickjs-wasmfile-release-asyncify": "^0.31.0", "@lydell/node-pty": "1.1.0", diff --git a/src/node/services/analytics/analyticsWorker.ts b/src/node/services/analytics/analyticsWorker.ts index b1d6ab53bf..d76b011783 100644 --- a/src/node/services/analytics/analyticsWorker.ts +++ b/src/node/services/analytics/analyticsWorker.ts @@ -6,6 +6,7 @@ import { decideSyncPlan, type SyncAction } from "./backfillDecision"; import { shouldCheckpointAfterSync } from "./checkpointDecision"; import { clearWorkspaceAnalyticsState, + deleteCorruptAnalyticsRows, getCurrentPricingFingerprint, ingestWorkspace, readStoredPricingFingerprint, @@ -147,6 +148,29 @@ async function handleInit(data: InitData): Promise { for (const migrationSql of DELEGATION_ROLLUPS_COLUMN_MIGRATIONS_SQL) { await activeConn.run(migrationSql); } + + await sweepCorruptRows("init"); +} + +/** + * Delete corruption-class rows and log when anything was actually removed. + * Best-effort: a failed sweep must never reject init (which would cache a + * worker error and disable analytics until restart) or fail an + * otherwise-successful ingest, so errors are logged and swallowed. + */ +async function sweepCorruptRows(context: string): Promise { + try { + const deleted = await deleteCorruptAnalyticsRows(getConn()); + if (deleted > 0) { + process.stderr.write( + `[analytics-worker] Deleted ${deleted} corrupt analytics row(s) (${context})\n` + ); + } + } catch (error) { + process.stderr.write( + `[analytics-worker] Corrupt-row sweep failed (${context}): ${getErrorMessage(error)}\n` + ); + } } async function handleIngest(data: IngestData): Promise { @@ -154,6 +178,7 @@ async function handleIngest(data: IngestData): Promise { assert(data.sessionDir.trim().length > 0, "ingest requires sessionDir"); await ingestWorkspace(getConn(), data.workspaceId, data.sessionDir, data.meta ?? {}); + await sweepCorruptRows("ingest"); } async function handleRebuildAll(data: RebuildAllData): Promise<{ workspacesIngested: number }> { @@ -169,6 +194,7 @@ async function handleRebuildAll(data: RebuildAllData): Promise<{ workspacesInges // A completed rebuild priced everything with the current tables; refresh the // fingerprint so the next sync check does not schedule a redundant rebuild. await storePricingFingerprint(getConn()); + await sweepCorruptRows("rebuildAll"); return result; } @@ -390,6 +416,7 @@ async function handleSyncCheck(data: SyncCheckData): Promise { if (pricingFingerprintChanged) { await storePricingFingerprint(getConn()); } + await sweepCorruptRows("syncCheck full_rebuild"); await checkpointIfNeeded(plan.action, workspacesIngested, 0); const elapsedMs = Math.round(performance.now() - syncStartMs); @@ -448,6 +475,7 @@ async function handleSyncCheck(data: SyncCheckData): Promise { } } + await sweepCorruptRows("syncCheck incremental"); await checkpointIfNeeded(plan.action, workspacesIngested, workspacesPurged); const elapsedMs = Math.round(performance.now() - syncStartMs); diff --git a/src/node/services/analytics/etl.test.ts b/src/node/services/analytics/etl.test.ts index 74b7163148..e7955d1f6e 100644 --- a/src/node/services/analytics/etl.test.ts +++ b/src/node/services/analytics/etl.test.ts @@ -9,6 +9,7 @@ import { appendEvents, CHAT_FILE_NAME, clearWorkspaceAnalyticsState, + deleteCorruptAnalyticsRows, getCurrentPricingFingerprint, ingestWorkspace, parseWorkspaceFromDisk, @@ -1634,3 +1635,83 @@ describe("pricing fingerprint", () => { expect(await readStoredPricingFingerprint(conn)).toBe(getCurrentPricingFingerprint()); }); }); + +describe("deleteCorruptAnalyticsRows", () => { + async function seedWatermark(conn: DuckDBConnection, workspaceId: string): Promise { + await conn.run( + "INSERT INTO ingest_watermarks (workspace_id, last_sequence, last_modified) VALUES (?, ?, ?)", + [workspaceId, 1, 1] + ); + } + + test("deletes corrupt rows while keeping healthy rows", async () => { + const conn = await createTestConn(); + + // Migrated legacy IDs are `${projectBasename}-${workspaceBasename}` with + // no length limit (up to 2x NAME_MAX + 1 = 511 chars) and must survive. + const legacyId = `${"p".repeat(255)}-${"w".repeat(255)}`; + // Custom-provider model IDs have no schema max length; an extremely long + // model on an otherwise-healthy row must never be deletion evidence. + const longModel = `custom:${"m".repeat(2000)}`; + + for (const workspaceId of ["ws-healthy", legacyId, "ws-long-model", "parent-healthy"]) { + await seedWatermark(conn, workspaceId); + } + + for (const [workspaceId, model, cost] of [ + ["ws-healthy", "anthropic:claude-haiku-4-5", 1.0], + [legacyId, "anthropic:claude-haiku-4-5", 2.0], + ["ws-long-model", longModel, 3.0], + // Large-batch phantom: concatenated identifiers exceed the length caps. + ["x".repeat(2000), "anthropic:claude-haiku-4-5".repeat(100), 0.05], + // Small-batch phantom: two concatenated 10-char workspace IDs stay far + // under the length caps but can never match a real watermark. + ["aaaaabbbbbcccccddddd", "openai:gpt-5.6-solopenai:gpt-5.6-sol", 0.05], + ] as const) { + await conn.run("INSERT INTO events (workspace_id, model, total_cost_usd) VALUES (?, ?, ?)", [ + workspaceId, + model, + cost, + ]); + } + + for (const [parent, child] of [ + ["parent-healthy", "child-healthy"], + // A rollup may outlive its removed child workspace; only the parent + // must be a known workspace. + ["parent-healthy", "child-removed"], + ["p".repeat(2000), "child-corrupt"], + // Small-batch phantom parent: unknown to watermarks. + ["par-aaaaapar-bbbbb", "child-x"], + ] as const) { + await conn.run( + `INSERT INTO delegation_rollups (parent_workspace_id, child_workspace_id, model) + VALUES (?, ?, ?)`, + [parent, child, "openai:gpt-5.6-sol"] + ); + } + + expect(await deleteCorruptAnalyticsRows(conn)).toBe(4); + + const eventRows = await queryRows( + conn, + "SELECT workspace_id FROM events ORDER BY LENGTH(workspace_id)" + ); + expect(eventRows).toEqual([ + { workspace_id: "ws-healthy" }, + { workspace_id: "ws-long-model" }, + { workspace_id: legacyId }, + ]); + const rollupRows = await queryRows( + conn, + "SELECT child_workspace_id FROM delegation_rollups ORDER BY child_workspace_id" + ); + expect(rollupRows).toEqual([ + { child_workspace_id: "child-healthy" }, + { child_workspace_id: "child-removed" }, + ]); + + // Idempotent: nothing left to delete. + expect(await deleteCorruptAnalyticsRows(conn)).toBe(0); + }); +}); diff --git a/src/node/services/analytics/etl.ts b/src/node/services/analytics/etl.ts index ad366ecda9..8a69408af7 100644 --- a/src/node/services/analytics/etl.ts +++ b/src/node/services/analytics/etl.ts @@ -865,6 +865,60 @@ export async function clearWorkspaceAnalyticsState( } } +/** + * Self-healing sweep for a rare native-layer corruption class: a phantom row + * can materialize whose every VARCHAR column is the concatenation of that + * column's non-null values across an entire batch of inserted rows (observed + * once in the wild: a 17KB "model" string spanning ~670 events, which then + * wallpapered the Analytics dashboard as one giant legend entry). The donor + * rows are written correctly, so deleting rows with impossible string lengths + * loses no real data. + * + * Two evidence classes, both structural (unbounded columns like model, + * paths, and workspace names are never deletion evidence on their own, since + * custom-provider model IDs etc. have no schema max length): + * + * 1. Identifier length beyond the legal construction maximum. New workspace + * IDs are short hex; migrated legacy IDs are + * `${projectBasename}-${workspaceBasename}` (config.generateLegacyId), + * each basename bounded by the filesystem's NAME_MAX (255 bytes), so 511 + * is the ceiling. Agent IDs/types also derive from basenames. Cap at 1024. + * + * 2. Workspace identity unknown to ingest_watermarks. A concatenation of two + * or more workspace IDs can never equal a real workspace ID, no matter how + * small the corrupted batch, while every legitimate row's workspace gets a + * watermark by the end of the ingest/rebuild pass that wrote it (sweeps + * run after those passes complete). If a crash lands between the event + * write and the watermark write, deleting the orphans is still safe: the + * missing watermark makes the next syncCheck re-ingest that workspace from + * disk in full. delegation_rollups joins on parent_workspace_id only; + * child_workspace_id may legitimately reference a removed child workspace. + */ +export async function deleteCorruptAnalyticsRows(conn: DuckDBConnection): Promise { + const eventsResult = await conn.run(` + DELETE FROM events + WHERE LENGTH(workspace_id) > 1024 + OR LENGTH(parent_workspace_id) > 1024 + OR LENGTH(agent_id) > 1024 + OR NOT EXISTS ( + SELECT 1 FROM ingest_watermarks w WHERE w.workspace_id = events.workspace_id + ) + `); + + const rollupsResult = await conn.run(` + DELETE FROM delegation_rollups + WHERE LENGTH(parent_workspace_id) > 1024 + OR LENGTH(child_workspace_id) > 1024 + OR LENGTH(agent_type) > 1024 + OR NOT EXISTS ( + SELECT 1 FROM ingest_watermarks w + WHERE w.workspace_id = delegation_rollups.parent_workspace_id + ) + `); + + return eventsResult.rowsChanged + rollupsResult.rowsChanged; +} + function serializeHeadSignatureValue(value: string | number | null): string { if (value === null) { return "null";