@wangyaoshen/remux 0.3.8-dev.a8ceb0c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- package/vitest.config.js +10 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for workspace_head — multi-device shared state.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
import { _resetDbForTest, closeDb } from "../src/store.js";
|
|
7
|
+
import { initWorkspaceHeadTable, getHead, updateHead } from "../src/workspace-head.js";
|
|
8
|
+
|
|
9
|
+
describe("workspace_head", () => {
|
|
10
|
+
let db;
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
db = new Database(":memory:");
|
|
14
|
+
db.pragma("journal_mode = WAL");
|
|
15
|
+
db.pragma("foreign_keys = ON");
|
|
16
|
+
_resetDbForTest(db);
|
|
17
|
+
|
|
18
|
+
// Create minimal schema needed
|
|
19
|
+
db.exec(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS workspace_head (
|
|
21
|
+
id TEXT PRIMARY KEY DEFAULT 'global',
|
|
22
|
+
session_name TEXT NOT NULL,
|
|
23
|
+
tab_id INTEGER NOT NULL,
|
|
24
|
+
topic_id TEXT,
|
|
25
|
+
view TEXT NOT NULL DEFAULT 'live',
|
|
26
|
+
revision INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
updated_by_device TEXT,
|
|
28
|
+
updated_at INTEGER NOT NULL
|
|
29
|
+
);
|
|
30
|
+
`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(() => {
|
|
34
|
+
closeDb();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns null when no head exists", () => {
|
|
38
|
+
const head = getHead();
|
|
39
|
+
expect(head).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("creates head on first updateHead call", () => {
|
|
43
|
+
const head = updateHead({
|
|
44
|
+
sessionName: "main",
|
|
45
|
+
tabId: 0,
|
|
46
|
+
view: "live",
|
|
47
|
+
}, "device-1");
|
|
48
|
+
|
|
49
|
+
expect(head).toBeDefined();
|
|
50
|
+
expect(head.sessionName).toBe("main");
|
|
51
|
+
expect(head.tabId).toBe(0);
|
|
52
|
+
expect(head.view).toBe("live");
|
|
53
|
+
expect(head.revision).toBe(1);
|
|
54
|
+
expect(head.updatedByDevice).toBe("device-1");
|
|
55
|
+
expect(head.updatedAt).toBeGreaterThan(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("getHead returns the created head", () => {
|
|
59
|
+
const head = getHead();
|
|
60
|
+
expect(head).not.toBeNull();
|
|
61
|
+
expect(head.sessionName).toBe("main");
|
|
62
|
+
expect(head.tabId).toBe(0);
|
|
63
|
+
expect(head.revision).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("increments revision on update", () => {
|
|
67
|
+
const head1 = updateHead({ tabId: 1 }, "device-2");
|
|
68
|
+
expect(head1.revision).toBe(2);
|
|
69
|
+
expect(head1.tabId).toBe(1);
|
|
70
|
+
expect(head1.updatedByDevice).toBe("device-2");
|
|
71
|
+
// sessionName should be preserved from previous
|
|
72
|
+
expect(head1.sessionName).toBe("main");
|
|
73
|
+
|
|
74
|
+
const head2 = updateHead({ tabId: 2 }, "device-1");
|
|
75
|
+
expect(head2.revision).toBe(3);
|
|
76
|
+
expect(head2.tabId).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("updates session and resets tab", () => {
|
|
80
|
+
const head = updateHead({
|
|
81
|
+
sessionName: "logs",
|
|
82
|
+
tabId: 5,
|
|
83
|
+
view: "workspace",
|
|
84
|
+
}, "device-3");
|
|
85
|
+
|
|
86
|
+
expect(head.sessionName).toBe("logs");
|
|
87
|
+
expect(head.tabId).toBe(5);
|
|
88
|
+
expect(head.view).toBe("workspace");
|
|
89
|
+
expect(head.revision).toBe(4);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("handles topicId updates", () => {
|
|
93
|
+
const head1 = updateHead({ topicId: "topic-abc" }, "device-1");
|
|
94
|
+
expect(head1.topicId).toBe("topic-abc");
|
|
95
|
+
|
|
96
|
+
const head2 = updateHead({ topicId: null }, "device-1");
|
|
97
|
+
expect(head2.topicId).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("preserves fields not specified in partial update", () => {
|
|
101
|
+
// Set everything
|
|
102
|
+
updateHead({
|
|
103
|
+
sessionName: "work",
|
|
104
|
+
tabId: 10,
|
|
105
|
+
topicId: "topic-xyz",
|
|
106
|
+
view: "inspect",
|
|
107
|
+
}, "device-1");
|
|
108
|
+
|
|
109
|
+
// Update only view
|
|
110
|
+
const head = updateHead({ view: "live" }, "device-2");
|
|
111
|
+
expect(head.sessionName).toBe("work");
|
|
112
|
+
expect(head.tabId).toBe(10);
|
|
113
|
+
expect(head.topicId).toBe("topic-xyz");
|
|
114
|
+
expect(head.view).toBe("live");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for AI workspace primitives: topics, runs, artifacts, approvals.
|
|
3
|
+
* Covers CRUD operations in store.ts and high-level workspace.ts functions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import Database from "better-sqlite3";
|
|
8
|
+
import {
|
|
9
|
+
_resetDbForTest,
|
|
10
|
+
closeDb,
|
|
11
|
+
createTopic,
|
|
12
|
+
updateTopic,
|
|
13
|
+
listTopics,
|
|
14
|
+
deleteTopic,
|
|
15
|
+
createRun,
|
|
16
|
+
updateRun,
|
|
17
|
+
listRuns,
|
|
18
|
+
createArtifact,
|
|
19
|
+
listArtifacts,
|
|
20
|
+
createApproval,
|
|
21
|
+
listApprovals,
|
|
22
|
+
resolveApproval,
|
|
23
|
+
} from "../src/store.ts";
|
|
24
|
+
|
|
25
|
+
/** Create an in-memory SQLite DB with the full schema including workspace tables. */
|
|
26
|
+
function createTestDb() {
|
|
27
|
+
const db = new Database(":memory:");
|
|
28
|
+
db.pragma("journal_mode = WAL");
|
|
29
|
+
db.pragma("foreign_keys = ON");
|
|
30
|
+
db.exec(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
32
|
+
name TEXT PRIMARY KEY,
|
|
33
|
+
created_at INTEGER NOT NULL
|
|
34
|
+
);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS tabs (
|
|
36
|
+
id INTEGER PRIMARY KEY,
|
|
37
|
+
session_name TEXT NOT NULL,
|
|
38
|
+
title TEXT NOT NULL DEFAULT 'Tab',
|
|
39
|
+
scrollback BLOB,
|
|
40
|
+
ended INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
FOREIGN KEY (session_name) REFERENCES sessions(name) ON DELETE CASCADE
|
|
42
|
+
);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS devices (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
name TEXT NOT NULL,
|
|
46
|
+
fingerprint TEXT NOT NULL,
|
|
47
|
+
trust TEXT NOT NULL DEFAULT 'untrusted',
|
|
48
|
+
created_at INTEGER NOT NULL,
|
|
49
|
+
last_seen INTEGER NOT NULL
|
|
50
|
+
);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS pair_codes (
|
|
52
|
+
code TEXT PRIMARY KEY,
|
|
53
|
+
created_by TEXT NOT NULL,
|
|
54
|
+
expires_at INTEGER NOT NULL,
|
|
55
|
+
FOREIGN KEY (created_by) REFERENCES devices(id) ON DELETE CASCADE
|
|
56
|
+
);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
58
|
+
key TEXT PRIMARY KEY,
|
|
59
|
+
value TEXT
|
|
60
|
+
);
|
|
61
|
+
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
62
|
+
device_id TEXT PRIMARY KEY,
|
|
63
|
+
endpoint TEXT NOT NULL,
|
|
64
|
+
p256dh TEXT NOT NULL,
|
|
65
|
+
auth TEXT NOT NULL,
|
|
66
|
+
created_at INTEGER NOT NULL
|
|
67
|
+
);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
session_name TEXT NOT NULL,
|
|
71
|
+
title TEXT NOT NULL,
|
|
72
|
+
created_at INTEGER NOT NULL,
|
|
73
|
+
updated_at INTEGER NOT NULL
|
|
74
|
+
);
|
|
75
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
76
|
+
id TEXT PRIMARY KEY,
|
|
77
|
+
topic_id TEXT REFERENCES topics(id),
|
|
78
|
+
session_name TEXT NOT NULL,
|
|
79
|
+
tab_id INTEGER,
|
|
80
|
+
command TEXT,
|
|
81
|
+
exit_code INTEGER,
|
|
82
|
+
started_at INTEGER NOT NULL,
|
|
83
|
+
ended_at INTEGER,
|
|
84
|
+
status TEXT DEFAULT 'running'
|
|
85
|
+
);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
87
|
+
id TEXT PRIMARY KEY,
|
|
88
|
+
run_id TEXT REFERENCES runs(id),
|
|
89
|
+
topic_id TEXT REFERENCES topics(id),
|
|
90
|
+
session_name TEXT,
|
|
91
|
+
type TEXT NOT NULL,
|
|
92
|
+
title TEXT,
|
|
93
|
+
content TEXT,
|
|
94
|
+
created_at INTEGER NOT NULL
|
|
95
|
+
);
|
|
96
|
+
CREATE TABLE IF NOT EXISTS approvals (
|
|
97
|
+
id TEXT PRIMARY KEY,
|
|
98
|
+
run_id TEXT REFERENCES runs(id),
|
|
99
|
+
topic_id TEXT REFERENCES topics(id),
|
|
100
|
+
title TEXT NOT NULL,
|
|
101
|
+
description TEXT,
|
|
102
|
+
status TEXT DEFAULT 'pending',
|
|
103
|
+
created_at INTEGER NOT NULL,
|
|
104
|
+
resolved_at INTEGER
|
|
105
|
+
);
|
|
106
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts_index USING fts5(
|
|
107
|
+
entity_type, entity_id, title, content,
|
|
108
|
+
tokenize='porter unicode61'
|
|
109
|
+
);
|
|
110
|
+
CREATE TABLE IF NOT EXISTS memory_notes (
|
|
111
|
+
id TEXT PRIMARY KEY,
|
|
112
|
+
content TEXT NOT NULL,
|
|
113
|
+
pinned INTEGER DEFAULT 0,
|
|
114
|
+
created_at INTEGER NOT NULL,
|
|
115
|
+
updated_at INTEGER NOT NULL
|
|
116
|
+
);
|
|
117
|
+
CREATE TABLE IF NOT EXISTS commands (
|
|
118
|
+
id TEXT PRIMARY KEY,
|
|
119
|
+
session_name TEXT NOT NULL,
|
|
120
|
+
tab_id INTEGER NOT NULL,
|
|
121
|
+
command TEXT,
|
|
122
|
+
exit_code INTEGER,
|
|
123
|
+
cwd TEXT,
|
|
124
|
+
started_at INTEGER NOT NULL,
|
|
125
|
+
ended_at INTEGER
|
|
126
|
+
);
|
|
127
|
+
`);
|
|
128
|
+
return db;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Topics ──────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
describe("workspace: topics CRUD", () => {
|
|
134
|
+
let db;
|
|
135
|
+
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
db = createTestDb();
|
|
138
|
+
_resetDbForTest(db);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
afterEach(() => {
|
|
142
|
+
closeDb();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("creates a topic with generated ID and timestamps", () => {
|
|
146
|
+
const topic = createTopic("main", "My first topic");
|
|
147
|
+
expect(topic.id).toBeTruthy();
|
|
148
|
+
expect(topic.id.length).toBeGreaterThan(0);
|
|
149
|
+
expect(topic.sessionName).toBe("main");
|
|
150
|
+
expect(topic.title).toBe("My first topic");
|
|
151
|
+
expect(topic.createdAt).toBeGreaterThan(0);
|
|
152
|
+
expect(topic.updatedAt).toBe(topic.createdAt);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("lists topics filtered by sessionName", () => {
|
|
156
|
+
createTopic("main", "Topic A");
|
|
157
|
+
createTopic("main", "Topic B");
|
|
158
|
+
createTopic("work", "Topic C");
|
|
159
|
+
|
|
160
|
+
const mainTopics = listTopics("main");
|
|
161
|
+
expect(mainTopics).toHaveLength(2);
|
|
162
|
+
expect(mainTopics[0].title).toBe("Topic A");
|
|
163
|
+
expect(mainTopics[1].title).toBe("Topic B");
|
|
164
|
+
|
|
165
|
+
const workTopics = listTopics("work");
|
|
166
|
+
expect(workTopics).toHaveLength(1);
|
|
167
|
+
expect(workTopics[0].title).toBe("Topic C");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("lists all topics when no sessionName filter", () => {
|
|
171
|
+
createTopic("main", "Topic A");
|
|
172
|
+
createTopic("work", "Topic B");
|
|
173
|
+
|
|
174
|
+
const all = listTopics();
|
|
175
|
+
expect(all).toHaveLength(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("updates a topic title and updatedAt", () => {
|
|
179
|
+
const topic = createTopic("main", "Old title");
|
|
180
|
+
const updated = updateTopic(topic.id, "New title");
|
|
181
|
+
expect(updated).toBe(true);
|
|
182
|
+
|
|
183
|
+
const topics = listTopics("main");
|
|
184
|
+
expect(topics[0].title).toBe("New title");
|
|
185
|
+
expect(topics[0].updatedAt).toBeGreaterThanOrEqual(topics[0].createdAt);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("updateTopic returns false for nonexistent ID", () => {
|
|
189
|
+
const result = updateTopic("nonexistent", "Title");
|
|
190
|
+
expect(result).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("deletes a topic", () => {
|
|
194
|
+
const topic = createTopic("main", "To delete");
|
|
195
|
+
expect(deleteTopic(topic.id)).toBe(true);
|
|
196
|
+
expect(listTopics("main")).toHaveLength(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("deleteTopic returns false for nonexistent ID", () => {
|
|
200
|
+
expect(deleteTopic("nonexistent")).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── Runs ────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
describe("workspace: runs CRUD", () => {
|
|
207
|
+
let db;
|
|
208
|
+
|
|
209
|
+
beforeEach(() => {
|
|
210
|
+
db = createTestDb();
|
|
211
|
+
_resetDbForTest(db);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
closeDb();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("creates a run with default status 'running'", () => {
|
|
219
|
+
const topic = createTopic("main", "Test topic");
|
|
220
|
+
const run = createRun({
|
|
221
|
+
topicId: topic.id,
|
|
222
|
+
sessionName: "main",
|
|
223
|
+
tabId: 0,
|
|
224
|
+
command: "ls -la",
|
|
225
|
+
});
|
|
226
|
+
expect(run.id).toBeTruthy();
|
|
227
|
+
expect(run.topicId).toBe(topic.id);
|
|
228
|
+
expect(run.sessionName).toBe("main");
|
|
229
|
+
expect(run.command).toBe("ls -la");
|
|
230
|
+
expect(run.status).toBe("running");
|
|
231
|
+
expect(run.startedAt).toBeGreaterThan(0);
|
|
232
|
+
expect(run.endedAt).toBeNull();
|
|
233
|
+
expect(run.exitCode).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("creates a run without topicId", () => {
|
|
237
|
+
const run = createRun({
|
|
238
|
+
sessionName: "main",
|
|
239
|
+
command: "echo hello",
|
|
240
|
+
});
|
|
241
|
+
expect(run.id).toBeTruthy();
|
|
242
|
+
expect(run.topicId).toBeNull();
|
|
243
|
+
expect(run.status).toBe("running");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("updates run exit code and status", () => {
|
|
247
|
+
const run = createRun({ sessionName: "main", command: "test" });
|
|
248
|
+
const updated = updateRun(run.id, { exitCode: 0, status: "completed" });
|
|
249
|
+
expect(updated).toBe(true);
|
|
250
|
+
|
|
251
|
+
const runs = listRuns();
|
|
252
|
+
expect(runs[0].exitCode).toBe(0);
|
|
253
|
+
expect(runs[0].status).toBe("completed");
|
|
254
|
+
expect(runs[0].endedAt).toBeGreaterThan(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("lists runs filtered by topicId", () => {
|
|
258
|
+
const topic = createTopic("main", "T");
|
|
259
|
+
createRun({ topicId: topic.id, sessionName: "main", command: "a" });
|
|
260
|
+
createRun({ topicId: topic.id, sessionName: "main", command: "b" });
|
|
261
|
+
createRun({ sessionName: "main", command: "c" });
|
|
262
|
+
|
|
263
|
+
const topicRuns = listRuns(topic.id);
|
|
264
|
+
expect(topicRuns).toHaveLength(2);
|
|
265
|
+
|
|
266
|
+
const allRuns = listRuns();
|
|
267
|
+
expect(allRuns).toHaveLength(3);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("updateRun returns false for nonexistent ID", () => {
|
|
271
|
+
expect(updateRun("nonexistent", { status: "failed" })).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ── Artifacts ───────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
describe("workspace: artifacts CRUD", () => {
|
|
278
|
+
let db;
|
|
279
|
+
|
|
280
|
+
beforeEach(() => {
|
|
281
|
+
db = createTestDb();
|
|
282
|
+
_resetDbForTest(db);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
afterEach(() => {
|
|
286
|
+
closeDb();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("creates an artifact with type and content", () => {
|
|
290
|
+
const topic = createTopic("main", "T");
|
|
291
|
+
const artifact = createArtifact({
|
|
292
|
+
topicId: topic.id,
|
|
293
|
+
type: "snapshot",
|
|
294
|
+
title: "Terminal snapshot",
|
|
295
|
+
content: "$ ls\nfoo bar",
|
|
296
|
+
});
|
|
297
|
+
expect(artifact.id).toBeTruthy();
|
|
298
|
+
expect(artifact.topicId).toBe(topic.id);
|
|
299
|
+
expect(artifact.type).toBe("snapshot");
|
|
300
|
+
expect(artifact.content).toBe("$ ls\nfoo bar");
|
|
301
|
+
expect(artifact.createdAt).toBeGreaterThan(0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("creates an artifact linked to a run", () => {
|
|
305
|
+
const run = createRun({ sessionName: "main", command: "ls" });
|
|
306
|
+
const artifact = createArtifact({
|
|
307
|
+
runId: run.id,
|
|
308
|
+
type: "command-card",
|
|
309
|
+
title: "ls output",
|
|
310
|
+
content: "foo bar",
|
|
311
|
+
});
|
|
312
|
+
expect(artifact.runId).toBe(run.id);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("lists artifacts filtered by topicId", () => {
|
|
316
|
+
const topic = createTopic("main", "T");
|
|
317
|
+
createArtifact({ topicId: topic.id, type: "note", title: "N1", content: "x" });
|
|
318
|
+
createArtifact({ topicId: topic.id, type: "note", title: "N2", content: "y" });
|
|
319
|
+
createArtifact({ type: "snapshot", title: "S", content: "z" });
|
|
320
|
+
|
|
321
|
+
const topicArtifacts = listArtifacts({ topicId: topic.id });
|
|
322
|
+
expect(topicArtifacts).toHaveLength(2);
|
|
323
|
+
|
|
324
|
+
const allArtifacts = listArtifacts({});
|
|
325
|
+
expect(allArtifacts).toHaveLength(3);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("lists artifacts filtered by runId", () => {
|
|
329
|
+
const run = createRun({ sessionName: "main", command: "test" });
|
|
330
|
+
createArtifact({ runId: run.id, type: "command-card", title: "C", content: "x" });
|
|
331
|
+
|
|
332
|
+
const runArtifacts = listArtifacts({ runId: run.id });
|
|
333
|
+
expect(runArtifacts).toHaveLength(1);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── Approvals ───────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
describe("workspace: approvals CRUD", () => {
|
|
340
|
+
let db;
|
|
341
|
+
|
|
342
|
+
beforeEach(() => {
|
|
343
|
+
db = createTestDb();
|
|
344
|
+
_resetDbForTest(db);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
afterEach(() => {
|
|
348
|
+
closeDb();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("creates an approval with pending status", () => {
|
|
352
|
+
const approval = createApproval({
|
|
353
|
+
title: "Deploy to production?",
|
|
354
|
+
description: "Running deploy script",
|
|
355
|
+
});
|
|
356
|
+
expect(approval.id).toBeTruthy();
|
|
357
|
+
expect(approval.title).toBe("Deploy to production?");
|
|
358
|
+
expect(approval.status).toBe("pending");
|
|
359
|
+
expect(approval.createdAt).toBeGreaterThan(0);
|
|
360
|
+
expect(approval.resolvedAt).toBeNull();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("creates an approval linked to topic and run", () => {
|
|
364
|
+
const topic = createTopic("main", "T");
|
|
365
|
+
const run = createRun({ topicId: topic.id, sessionName: "main", command: "deploy" });
|
|
366
|
+
const approval = createApproval({
|
|
367
|
+
runId: run.id,
|
|
368
|
+
topicId: topic.id,
|
|
369
|
+
title: "Approve deploy",
|
|
370
|
+
});
|
|
371
|
+
expect(approval.runId).toBe(run.id);
|
|
372
|
+
expect(approval.topicId).toBe(topic.id);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("lists approvals filtered by status", () => {
|
|
376
|
+
createApproval({ title: "A1" });
|
|
377
|
+
createApproval({ title: "A2" });
|
|
378
|
+
|
|
379
|
+
const pending = listApprovals("pending");
|
|
380
|
+
expect(pending).toHaveLength(2);
|
|
381
|
+
|
|
382
|
+
const approved = listApprovals("approved");
|
|
383
|
+
expect(approved).toHaveLength(0);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("lists all approvals when no status filter", () => {
|
|
387
|
+
createApproval({ title: "A1" });
|
|
388
|
+
createApproval({ title: "A2" });
|
|
389
|
+
|
|
390
|
+
const all = listApprovals();
|
|
391
|
+
expect(all).toHaveLength(2);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("resolves an approval as approved", () => {
|
|
395
|
+
const approval = createApproval({ title: "Test" });
|
|
396
|
+
const result = resolveApproval(approval.id, "approved");
|
|
397
|
+
expect(result).toBe(true);
|
|
398
|
+
|
|
399
|
+
const list = listApprovals("approved");
|
|
400
|
+
expect(list).toHaveLength(1);
|
|
401
|
+
expect(list[0].status).toBe("approved");
|
|
402
|
+
expect(list[0].resolvedAt).toBeGreaterThan(0);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("resolves an approval as rejected", () => {
|
|
406
|
+
const approval = createApproval({ title: "Test" });
|
|
407
|
+
resolveApproval(approval.id, "rejected");
|
|
408
|
+
|
|
409
|
+
const list = listApprovals("rejected");
|
|
410
|
+
expect(list).toHaveLength(1);
|
|
411
|
+
expect(list[0].status).toBe("rejected");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("resolveApproval returns false for nonexistent ID", () => {
|
|
415
|
+
expect(resolveApproval("nonexistent", "approved")).toBe(false);
|
|
416
|
+
});
|
|
417
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": false,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/url"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestConnectionBuildWSURL(t *testing.T) {
|
|
9
|
+
tests := []struct {
|
|
10
|
+
name string
|
|
11
|
+
host Host
|
|
12
|
+
path string
|
|
13
|
+
wantScheme string
|
|
14
|
+
}{
|
|
15
|
+
{
|
|
16
|
+
name: "http becomes ws",
|
|
17
|
+
host: Host{URL: "http://localhost:8767", Token: "abc"},
|
|
18
|
+
path: "/ws/control",
|
|
19
|
+
wantScheme: "ws",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "https becomes wss",
|
|
23
|
+
host: Host{URL: "https://my.devtunnels.ms", Token: "xyz"},
|
|
24
|
+
path: "/ws/terminal",
|
|
25
|
+
wantScheme: "wss",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for _, tt := range tests {
|
|
30
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
31
|
+
conn := NewConnection(tt.host, "")
|
|
32
|
+
wsURL, err := conn.buildWSURL(tt.path)
|
|
33
|
+
if err != nil {
|
|
34
|
+
t.Fatalf("buildWSURL failed: %v", err)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
parsed, err := url.Parse(wsURL)
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatalf("parse URL failed: %v", err)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if parsed.Scheme != tt.wantScheme {
|
|
43
|
+
t.Errorf("scheme = %q, want %q", parsed.Scheme, tt.wantScheme)
|
|
44
|
+
}
|
|
45
|
+
if parsed.Path != tt.path {
|
|
46
|
+
t.Errorf("path = %q, want %q", parsed.Path, tt.path)
|
|
47
|
+
}
|
|
48
|
+
if parsed.Query().Get("token") != tt.host.Token {
|
|
49
|
+
t.Errorf("token = %q, want %q", parsed.Query().Get("token"), tt.host.Token)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestHostManagerSessions(t *testing.T) {
|
|
56
|
+
manager := NewHostManager()
|
|
57
|
+
|
|
58
|
+
// Simulate state updates from two hosts.
|
|
59
|
+
manager.mu.Lock()
|
|
60
|
+
manager.updateSessionsFromState("host-a", &StateSnapshot{
|
|
61
|
+
Sessions: []SessionState{
|
|
62
|
+
{Name: "session-1", Attached: true, Windows: 1},
|
|
63
|
+
{Name: "session-2", Attached: false, Windows: 2},
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
manager.updateSessionsFromState("host-b", &StateSnapshot{
|
|
67
|
+
Sessions: []SessionState{
|
|
68
|
+
{Name: "session-3", Attached: false, Windows: 1},
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
manager.mu.Unlock()
|
|
72
|
+
|
|
73
|
+
sessions := manager.Sessions()
|
|
74
|
+
if len(sessions) != 3 {
|
|
75
|
+
t.Fatalf("expected 3 sessions, got %d", len(sessions))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Verify sessions are from the right hosts.
|
|
79
|
+
hostCounts := map[string]int{}
|
|
80
|
+
for _, s := range sessions {
|
|
81
|
+
hostCounts[s.HostName]++
|
|
82
|
+
}
|
|
83
|
+
if hostCounts["host-a"] != 2 {
|
|
84
|
+
t.Errorf("expected 2 sessions from host-a, got %d", hostCounts["host-a"])
|
|
85
|
+
}
|
|
86
|
+
if hostCounts["host-b"] != 1 {
|
|
87
|
+
t.Errorf("expected 1 session from host-b, got %d", hostCounts["host-b"])
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func TestHostManagerStateUpdateReplacesOldSessions(t *testing.T) {
|
|
92
|
+
manager := NewHostManager()
|
|
93
|
+
|
|
94
|
+
// First update.
|
|
95
|
+
manager.mu.Lock()
|
|
96
|
+
manager.updateSessionsFromState("host-a", &StateSnapshot{
|
|
97
|
+
Sessions: []SessionState{
|
|
98
|
+
{Name: "old-session", Attached: false, Windows: 1},
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
manager.mu.Unlock()
|
|
102
|
+
|
|
103
|
+
if len(manager.Sessions()) != 1 {
|
|
104
|
+
t.Fatalf("expected 1 session after first update")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Second update replaces the first.
|
|
108
|
+
manager.mu.Lock()
|
|
109
|
+
manager.updateSessionsFromState("host-a", &StateSnapshot{
|
|
110
|
+
Sessions: []SessionState{
|
|
111
|
+
{Name: "new-session-1", Attached: true, Windows: 1},
|
|
112
|
+
{Name: "new-session-2", Attached: false, Windows: 1},
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
manager.mu.Unlock()
|
|
116
|
+
|
|
117
|
+
sessions := manager.Sessions()
|
|
118
|
+
if len(sessions) != 2 {
|
|
119
|
+
t.Fatalf("expected 2 sessions after replacement, got %d", len(sessions))
|
|
120
|
+
}
|
|
121
|
+
if sessions[0].Name != "new-session-1" {
|
|
122
|
+
t.Errorf("expected first session 'new-session-1', got %q", sessions[0].Name)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|