@sanqianx/project-knowledge 4.6.1
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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/CHANGELOG.md +780 -0
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +266 -0
- package/_site/README.md +30 -0
- package/_site/_test/ai-profile-resolver-test.js +137 -0
- package/_site/_test/ai-profile-test.js +75 -0
- package/_site/_test/ai-vendor-presets-test.js +91 -0
- package/_site/_test/automation-queue-test.js +14 -0
- package/_site/_test/automation-ui-test.js +40 -0
- package/_site/_test/background-task-registry-test.js +43 -0
- package/_site/_test/baseline-schema-test.js +91 -0
- package/_site/_test/bin-cli-test.js +218 -0
- package/_site/_test/bridge-adapter-test.js +95 -0
- package/_site/_test/bridge-consumer-service-test.js +310 -0
- package/_site/_test/cdp-browser-failure-test.js +100 -0
- package/_site/_test/chat-claudecodeui-match-test.js +92 -0
- package/_site/_test/claude-executable-discovery-test.js +90 -0
- package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -0
- package/_site/_test/claude-workbench-test.js +170 -0
- package/_site/_test/client-capture-restart-e2e-test.js +189 -0
- package/_site/_test/codex-conversation-projection-test.js +59 -0
- package/_site/_test/codex-notify-fanout-test.js +45 -0
- package/_site/_test/commit-boundary-freeze-test.js +147 -0
- package/_site/_test/commit-conversation-binding-test.js +99 -0
- package/_site/_test/commit-evidence-test.js +110 -0
- package/_site/_test/context-pack-test.js +198 -0
- package/_site/_test/conversation-api-test.js +119 -0
- package/_site/_test/conversation-store-test.js +136 -0
- package/_site/_test/cross-repo-isolation-e2e-test.js +187 -0
- package/_site/_test/data-dir-migration-test.js +77 -0
- package/_site/_test/desktop-browser-compat-test.js +28 -0
- package/_site/_test/desktop-hook-runtime-regression-test.js +204 -0
- package/_site/_test/embedding-config-test.js +32 -0
- package/_site/_test/explicit-commit-processor-test.js +37 -0
- package/_site/_test/fixtures/fake-browser-exit.js +10 -0
- package/_site/_test/fixtures/fake-browser-hang.js +10 -0
- package/_site/_test/fixtures/make-git-repos.js +89 -0
- package/_site/_test/folder-picker-output-test.js +19 -0
- package/_site/_test/full-integration-e2e-test.js +165 -0
- package/_site/_test/git-validation-test.js +81 -0
- package/_site/_test/hook-runtime-endpoint-test.js +88 -0
- package/_site/_test/hook-status-repair-api-test.js +152 -0
- package/_site/_test/hook-trigger-test.js +85 -0
- package/_site/_test/hook-worktree-test.js +42 -0
- package/_site/_test/import-preflight-api-test.js +233 -0
- package/_site/_test/import-ui-flow-test.js +171 -0
- package/_site/_test/index-builder-test.js +41 -0
- package/_site/_test/index-writer-concurrency-test.js +135 -0
- package/_site/_test/integration-adapters-test.js +243 -0
- package/_site/_test/integration-capture-setup-test.js +108 -0
- package/_site/_test/integration-surface-coverage-test.js +165 -0
- package/_site/_test/kb-framework-test.js +57 -0
- package/_site/_test/kbpath-follow-test.js +63 -0
- package/_site/_test/knowledge-db-test.js +144 -0
- package/_site/_test/knowledge-language-control-test.js +170 -0
- package/_site/_test/knowledge-maintenance-test.js +40 -0
- package/_site/_test/knowledge-migration-test.js +75 -0
- package/_site/_test/knowledge-promotion-recovery-test.js +260 -0
- package/_site/_test/knowledge-query-test.js +54 -0
- package/_site/_test/knowledge-retrieval-service-test.js +77 -0
- package/_site/_test/knowledge-scopes-test.js +37 -0
- package/_site/_test/knowledge-storage-location-test.js +28 -0
- package/_site/_test/knowledge-storage-startup-test.js +52 -0
- package/_site/_test/knowledge-store-logs-supervision-test.js +89 -0
- package/_site/_test/legacy-claude-session-alias-test.js +106 -0
- package/_site/_test/legacy-forward-compat-test.js +257 -0
- package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -0
- package/_site/_test/legacy-routes-removed-test.js +47 -0
- package/_site/_test/legacy-workbench-exclusion-test.js +92 -0
- package/_site/_test/log-redaction-test.js +58 -0
- package/_site/_test/logging-api-test.js +89 -0
- package/_site/_test/logging-sse-no-gap-test.js +88 -0
- package/_site/_test/logging-ui-test.js +104 -0
- package/_site/_test/markdown-delta-overlay-test.js +85 -0
- package/_site/_test/markdown-maintenance-api-test.js +75 -0
- package/_site/_test/markdown-maintenance-test.js +11 -0
- package/_site/_test/mcp-server-test.js +149 -0
- package/_site/_test/model-context-windows-test.js +89 -0
- package/_site/_test/module-bridge-eventbridge-test.js +193 -0
- package/_site/_test/non-release-ci-test.js +34 -0
- package/_site/_test/offline-boundary-isolation-test.js +32 -0
- package/_site/_test/orphaned-claim-recovery-test.js +80 -0
- package/_site/_test/p0-data-migration-characterization-test.js +37 -0
- package/_site/_test/p0-e2e-gate-test.js +347 -0
- package/_site/_test/package-boundary-test.js +42 -0
- package/_site/_test/package-startup-test.js +56 -0
- package/_site/_test/packaged-ui-smoke-test.js +79 -0
- package/_site/_test/path-consistency-test.js +151 -0
- package/_site/_test/pending-sweep-test.js +9 -0
- package/_site/_test/post-commit-automation-test.js +87 -0
- package/_site/_test/pr-consumer-contract-test.js +55 -0
- package/_site/_test/project-control-panel-task14-test.js +63 -0
- package/_site/_test/project-delete-recovery-test.js +64 -0
- package/_site/_test/project-goal-editor-test.js +145 -0
- package/_site/_test/project-layout-v2-migration-test.js +127 -0
- package/_site/_test/project-lifecycle-transaction-test.js +106 -0
- package/_site/_test/project-remove-running-guard-test.js +79 -0
- package/_site/_test/project-store-test.js +90 -0
- package/_site/_test/prompt-settings-test.js +115 -0
- package/_site/_test/protected-architecture-gate-test.js +123 -0
- package/_site/_test/refactor-characterization-test.js +36 -0
- package/_site/_test/release-version-sync-test.js +98 -0
- package/_site/_test/requirement-binding-test.js +113 -0
- package/_site/_test/requirement-recorder-test.js +173 -0
- package/_site/_test/run-all-tests.js +156 -0
- package/_site/_test/runtime-endpoint-test.js +64 -0
- package/_site/_test/scanner-test.js +83 -0
- package/_site/_test/server-runtime-migration-safety-test.js +27 -0
- package/_site/_test/server-security-test.js +96 -0
- package/_site/_test/sessions-stream-test.js +92 -0
- package/_site/_test/shared-contracts-test.js +58 -0
- package/_site/_test/simple-import-test.js +97 -0
- package/_site/_test/startup-analysis-disabled-test.js +16 -0
- package/_site/_test/storage-foundation-test.js +78 -0
- package/_site/_test/structured-logger-test.js +101 -0
- package/_site/_test/task15-20-ui-flow-test.js +148 -0
- package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -0
- package/_site/_test/team-knowledge-store-test.js +131 -0
- package/_site/_test/tracking-start-test.js +143 -0
- package/_site/_test/ui-i18n-toggle-test.js +114 -0
- package/_site/_test/ui-smoke-test.js +73 -0
- package/_site/_test/v4122-upgrade-data-contract-test.js +56 -0
- package/_site/_test/workbench-permission-test.js +118 -0
- package/_site/_test/workspace-ui-contract-test.js +43 -0
- package/_site/lib/ai-profile-resolver.js +78 -0
- package/_site/lib/ai-vendor-presets.js +83 -0
- package/_site/lib/ai-workspace.js +104 -0
- package/_site/lib/atomic-file.js +204 -0
- package/_site/lib/automation-config.js +140 -0
- package/_site/lib/bridge-adapter.js +256 -0
- package/_site/lib/bridge-consumer-service.js +402 -0
- package/_site/lib/claude-cli-runner.js +1599 -0
- package/_site/lib/commit-conversation-binder.js +246 -0
- package/_site/lib/commit-processing-ledger.js +27 -0
- package/_site/lib/commit-prompt.js +202 -0
- package/_site/lib/commit-reconciler.js +444 -0
- package/_site/lib/context-pack-builder.js +299 -0
- package/_site/lib/contracts.js +279 -0
- package/_site/lib/conversation-exclusions.js +204 -0
- package/_site/lib/conversation-query-service.js +161 -0
- package/_site/lib/conversation-store.js +385 -0
- package/_site/lib/data-dir.js +81 -0
- package/_site/lib/data-state-classifier.js +40 -0
- package/_site/lib/embedding-service.js +85 -0
- package/_site/lib/evidence-bundle.js +228 -0
- package/_site/lib/folder-picker-output.js +24 -0
- package/_site/lib/git-runner.js +62 -0
- package/_site/lib/github-team-store.js +1175 -0
- package/_site/lib/hook-manager.js +202 -0
- package/_site/lib/index-builder.js +157 -0
- package/_site/lib/index-service.js +173 -0
- package/_site/lib/integration-installer.js +907 -0
- package/_site/lib/kb-framework.js +191 -0
- package/_site/lib/kb-validator.js +124 -0
- package/_site/lib/knowledge-db.js +363 -0
- package/_site/lib/knowledge-promotion.js +435 -0
- package/_site/lib/knowledge-retrieval-service.js +401 -0
- package/_site/lib/knowledge-schema.js +121 -0
- package/_site/lib/knowledge-tool-runtime.js +359 -0
- package/_site/lib/legacy-data-manifest.js +26 -0
- package/_site/lib/llm-client.js +173 -0
- package/_site/lib/markdown-knowledge-indexer.js +230 -0
- package/_site/lib/migration-service.js +442 -0
- package/_site/lib/model-context-windows.js +82 -0
- package/_site/lib/module-bridge.js +496 -0
- package/_site/lib/post-commit-automation.js +141 -0
- package/_site/lib/project-lifecycle-service.js +511 -0
- package/_site/lib/project-registry-store.js +129 -0
- package/_site/lib/project-store.js +262 -0
- package/_site/lib/prompt-registry.js +148 -0
- package/_site/lib/requirement-adapters.js +10 -0
- package/_site/lib/requirement-binder.js +112 -0
- package/_site/lib/requirement-recorder.js +278 -0
- package/_site/lib/runtime-endpoint.js +160 -0
- package/_site/lib/scanner.js +298 -0
- package/_site/lib/server-app.js +1520 -0
- package/_site/lib/settings-store.js +113 -0
- package/_site/lib/storage-layout.js +162 -0
- package/_site/lib/structured-logger.js +574 -0
- package/_site/scripts/codex-notify-fanout.cjs +51 -0
- package/_site/scripts/folder-picker.ps1 +156 -0
- package/_site/scripts/hook-trigger.js +156 -0
- package/_site/scripts/sync-release-version.js +158 -0
- package/_site/server.js +12 -0
- package/_site/start.bat +26 -0
- package/_site/stop.bat +11 -0
- package/bin/project-knowledge-integrations.js +241 -0
- package/bin/project-knowledge-kb.js +65 -0
- package/bin/project-knowledge-mcp.js +195 -0
- package/bin/project-knowledge.js +439 -0
- package/claude-prompts.json +21 -0
- package/docs/README.zh-CN.md +175 -0
- package/docs/assets/architecture.svg +182 -0
- package/docs/assets/dashboard.png +0 -0
- package/docs/assets/draft-review.png +0 -0
- package/docs/assets/logo.svg +50 -0
- package/docs/assets/runs-drafts.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/project-registry-schema.md +33 -0
- package/docs/team-knowledge-mode-a-plan.md +73 -0
- package/docs/testing-strategy.md +33 -0
- package/package.json +100 -0
- package/plugins/project-knowledge/.claude-plugin/plugin.json +19 -0
- package/plugins/project-knowledge/.codex-plugin/plugin.json +38 -0
- package/plugins/project-knowledge/.mcp.json +14 -0
- package/plugins/project-knowledge/opencode/project-knowledge.md +3 -0
- package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -0
- package/plugins/project-knowledge/skills/project-knowledge/agents/openai.yaml +11 -0
- package/templates/change.md +30 -0
- package/templates/project-readme.md +52 -0
- package/ui/app.css +58 -0
- package/ui/app.js +607 -0
- package/ui/favicon.svg +38 -0
- package/ui/i18n.js +146 -0
- package/ui/index.html +162 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const runner = require('../lib/claude-cli-runner');
|
|
7
|
+
|
|
8
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-discovery-test-'));
|
|
9
|
+
|
|
10
|
+
(async () => {
|
|
11
|
+
try {
|
|
12
|
+
const nativeExe = path.join(temp, 'claude.exe');
|
|
13
|
+
const npmRoot = path.join(temp, 'npm');
|
|
14
|
+
const extensionlessNpmShim = path.join(npmRoot, 'claude');
|
|
15
|
+
const npmShim = path.join(npmRoot, 'claude.cmd');
|
|
16
|
+
const npmCli = path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
|
|
17
|
+
const npmExe = path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
|
18
|
+
fs.mkdirSync(path.dirname(npmExe), { recursive: true });
|
|
19
|
+
fs.writeFileSync(nativeExe, 'test');
|
|
20
|
+
fs.writeFileSync(extensionlessNpmShim, '#!/bin/sh');
|
|
21
|
+
fs.writeFileSync(npmShim, '@echo off');
|
|
22
|
+
fs.writeFileSync(npmCli, '#!/usr/bin/env node');
|
|
23
|
+
fs.writeFileSync(npmExe, 'native executable');
|
|
24
|
+
|
|
25
|
+
const fromEnv = await runner.findClaudeExecutableForSdk({
|
|
26
|
+
platform: 'win32',
|
|
27
|
+
env: { CLAUDE_CODE_EXECPATH: nativeExe },
|
|
28
|
+
runCommand: () => ({ status: 1, stdout: '' }),
|
|
29
|
+
});
|
|
30
|
+
assert.equal(fromEnv.cmd, nativeExe);
|
|
31
|
+
assert.equal(fromEnv.source, 'CLAUDE_CODE_EXECPATH');
|
|
32
|
+
|
|
33
|
+
const fromPath = await runner.findClaudeExecutableForSdk({
|
|
34
|
+
platform: 'win32',
|
|
35
|
+
env: {},
|
|
36
|
+
runCommand: () => ({ status: 0, stdout: `${nativeExe}\r\n` }),
|
|
37
|
+
});
|
|
38
|
+
assert.equal(fromPath.cmd, nativeExe);
|
|
39
|
+
assert.equal(fromPath.source, 'PATH');
|
|
40
|
+
|
|
41
|
+
const fromExtensionlessNpmShim = await runner.findClaudeExecutableForSdk({
|
|
42
|
+
platform: 'win32',
|
|
43
|
+
env: {},
|
|
44
|
+
runCommand: () => ({ status: 0, stdout: `${extensionlessNpmShim}\r\n${npmShim}\r\n` }),
|
|
45
|
+
});
|
|
46
|
+
assert.equal(fromExtensionlessNpmShim.cmd, npmExe,
|
|
47
|
+
'extensionless npm shell shim should resolve to the package native executable');
|
|
48
|
+
|
|
49
|
+
const fromCmdNpmShim = await runner.findClaudeExecutableForSdk({
|
|
50
|
+
platform: 'win32',
|
|
51
|
+
env: { CLAUDE_CODE_EXECPATH: npmShim },
|
|
52
|
+
runCommand: () => ({ status: 1, stdout: '' }),
|
|
53
|
+
});
|
|
54
|
+
assert.equal(fromCmdNpmShim.cmd, npmExe,
|
|
55
|
+
'configured npm .cmd shim should resolve to the package native executable');
|
|
56
|
+
assert.equal(fromCmdNpmShim.runtimeExecutable, null,
|
|
57
|
+
'native Claude executable should not need a JavaScript runtime launcher');
|
|
58
|
+
|
|
59
|
+
fs.rmSync(npmExe);
|
|
60
|
+
const fromCliScript = await runner.findClaudeExecutableForSdk({
|
|
61
|
+
platform: 'win32',
|
|
62
|
+
env: { CLAUDE_CODE_EXECPATH: npmShim },
|
|
63
|
+
runtimeExecutable: process.execPath,
|
|
64
|
+
runCommand: () => ({ status: 1, stdout: '' }),
|
|
65
|
+
});
|
|
66
|
+
assert.equal(fromCliScript.cmd, npmCli,
|
|
67
|
+
'npm shim should fall back to the installed Claude cli.js');
|
|
68
|
+
assert.equal(fromCliScript.runtimeExecutable, process.execPath,
|
|
69
|
+
'Claude cli.js must use the current runtime instead of PATH node');
|
|
70
|
+
const scriptLaunch = spawnSync(fromCliScript.runtimeExecutable, [fromCliScript.cmd], {
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
});
|
|
74
|
+
assert.equal(scriptLaunch.status, 0,
|
|
75
|
+
`resolved Claude cli.js should launch through the selected runtime: ${scriptLaunch.stderr}`);
|
|
76
|
+
|
|
77
|
+
const missing = await runner.findClaudeExecutableForSdk({
|
|
78
|
+
platform: 'win32',
|
|
79
|
+
env: {},
|
|
80
|
+
exists: () => false,
|
|
81
|
+
runCommand: () => ({ status: 1, stdout: '' }),
|
|
82
|
+
});
|
|
83
|
+
assert.equal(missing.cmd, null);
|
|
84
|
+
assert.match(missing.reason, /Claude Code was not found/);
|
|
85
|
+
|
|
86
|
+
console.log('claude-executable-discovery-test: PASS');
|
|
87
|
+
} finally {
|
|
88
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Run: node _site/_test/claude-session-lifecycle-sweep-test.js
|
|
2
|
+
//
|
|
3
|
+
// Regression test for the stale-active-session sweep in claude-cli-runner.
|
|
4
|
+
//
|
|
5
|
+
// Without the sweeper, three failure modes leave sessions stuck in
|
|
6
|
+
// state=running forever after the subprocess is gone:
|
|
7
|
+
// 1. dashboard restart kills the SDK parent query; claude.exe becomes
|
|
8
|
+
// orphaned; on reload the persisted record still says "running"
|
|
9
|
+
// 2. SIGKILL of the dashboard before the SDK for-await loop emits its
|
|
10
|
+
// terminal message
|
|
11
|
+
// 3. child Claude.exe dies under IPC drop; in-memory session has
|
|
12
|
+
// subprocess=null while state=running
|
|
13
|
+
//
|
|
14
|
+
// Symptom in production: a project keeps showing the pulsing "running"
|
|
15
|
+
// badge and the embedded terminal view stays empty because no live
|
|
16
|
+
// subprocess streams into it. Until the sweeper ships, the only manual
|
|
17
|
+
// workaround is a dashboard restart that the user has to remember.
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), `kb-sweep-test-${process.pid}-`));
|
|
24
|
+
process.env.KB_DATA_DIR = TMP_DATA;
|
|
25
|
+
process.env.KB_STALE_ACTIVE_MS = '60000';
|
|
26
|
+
require(path.resolve(__dirname, '..', '..', '_site', 'lib', 'data-dir'))._resetCache();
|
|
27
|
+
|
|
28
|
+
// Seed the v2 registry and per-project config/state before loading the runner.
|
|
29
|
+
const PROJECT_IDS = ['demo-zombie-persisted', 'demo-zombie-memory', 'demo-fresh', 'demo-broadcast', 'demo-callback'];
|
|
30
|
+
const { defaultProjectConfig, defaultProjectState } = require('../lib/project-store');
|
|
31
|
+
const now = new Date().toISOString();
|
|
32
|
+
fs.mkdirSync(path.join(TMP_DATA, 'projects'), { recursive: true });
|
|
33
|
+
for (const projectId of PROJECT_IDS) {
|
|
34
|
+
const knowledgePath = path.join(TMP_DATA, 'knowledge', projectId);
|
|
35
|
+
const metadataPath = path.join(TMP_DATA, 'projects', projectId);
|
|
36
|
+
fs.mkdirSync(knowledgePath, { recursive: true });
|
|
37
|
+
fs.mkdirSync(metadataPath, { recursive: true });
|
|
38
|
+
fs.writeFileSync(path.join(metadataPath, 'config.json'), JSON.stringify(defaultProjectConfig(projectId, {
|
|
39
|
+
displayName: projectId, storageName: projectId, repoPath: TMP_DATA, knowledgePath,
|
|
40
|
+
}), null, 2) + '\n', 'utf8');
|
|
41
|
+
fs.writeFileSync(path.join(metadataPath, 'state.json'), JSON.stringify(defaultProjectState(), null, 2) + '\n', 'utf8');
|
|
42
|
+
}
|
|
43
|
+
fs.writeFileSync(path.join(TMP_DATA, 'projects.json'), JSON.stringify({
|
|
44
|
+
schema: 'project-registry/v2', schemaVersion: 2, projectOrder: PROJECT_IDS,
|
|
45
|
+
projects: Object.fromEntries(PROJECT_IDS.map(projectId => [projectId, { createdAt: now, displayNameSnapshot: projectId }])),
|
|
46
|
+
revision: 1, updatedAt: now,
|
|
47
|
+
}, null, 2) + '\n', 'utf8');
|
|
48
|
+
fs.writeFileSync(path.join(TMP_DATA, 'claude-prompts.json'), '{"schema":"prompts/v1","prompts":{}}', 'utf-8');
|
|
49
|
+
|
|
50
|
+
const runner = require('../lib/claude-cli-runner');
|
|
51
|
+
const {
|
|
52
|
+
createSession,
|
|
53
|
+
demoteStaleActiveSessions,
|
|
54
|
+
ACTIVE_STATES,
|
|
55
|
+
TERMINAL_STATES,
|
|
56
|
+
listSessions,
|
|
57
|
+
subscribeList,
|
|
58
|
+
} = runner;
|
|
59
|
+
|
|
60
|
+
function assert(cond, msg) { if (!cond) throw new Error('ASSERT: ' + msg); }
|
|
61
|
+
|
|
62
|
+
function writePersistedRecord(projectSlug, record) {
|
|
63
|
+
const aiDir = path.join(TMP_DATA, 'runtime', 'claude-sessions', projectSlug);
|
|
64
|
+
fs.mkdirSync(aiDir, { recursive: true });
|
|
65
|
+
const target = path.join(aiDir, `${record.sessionId}.json`);
|
|
66
|
+
// Default updatedAt to the same stale time used for startedAt so the
|
|
67
|
+
// sweeper treats the record as orphan-aged. Callers can override either.
|
|
68
|
+
const longAgo = record.startedAt || new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
|
69
|
+
fs.writeFileSync(target, JSON.stringify({
|
|
70
|
+
schema: 'claude-workbench-session/v1',
|
|
71
|
+
updatedAt: record.updatedAt || longAgo,
|
|
72
|
+
...record,
|
|
73
|
+
}, null, 2) + '\n', 'utf-8');
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeStalePersistedRecord(projectSlug, sessionId, state) {
|
|
78
|
+
const longAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
|
79
|
+
return writePersistedRecord(projectSlug, {
|
|
80
|
+
sessionId,
|
|
81
|
+
projectSlug,
|
|
82
|
+
projectPath: 'D:/proj/' + projectSlug,
|
|
83
|
+
kbPath: path.join(TMP_DATA, 'knowledge', projectSlug),
|
|
84
|
+
promptKey: 'post-commit-automation',
|
|
85
|
+
runner: 'sdk',
|
|
86
|
+
state,
|
|
87
|
+
startedAt: longAgo,
|
|
88
|
+
endedAt: null,
|
|
89
|
+
exitCode: null,
|
|
90
|
+
turns: 1,
|
|
91
|
+
error: null,
|
|
92
|
+
pendingPermission: null,
|
|
93
|
+
source: 'git-hook',
|
|
94
|
+
automation: true,
|
|
95
|
+
automationRunId: 'auto-test-1',
|
|
96
|
+
metadata: { source: 'git-hook', automation: true, automationRunId: 'auto-test-1' },
|
|
97
|
+
permissionMode: 'bypassPermissions',
|
|
98
|
+
events: [{ type: 'claude/system-prompt', text: 'fake', promptKey: 'post-commit-automation' }],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
(() => {
|
|
103
|
+
// The sweeper itself is the unit under test.
|
|
104
|
+
assert(typeof demoteStaleActiveSessions === 'function',
|
|
105
|
+
'demoteStaleActiveSessions must be exported');
|
|
106
|
+
assert(ACTIVE_STATES.has('running'),
|
|
107
|
+
'ACTIVE_STATES must include running');
|
|
108
|
+
assert(TERMINAL_STATES.has('failed'),
|
|
109
|
+
'TERMINAL_STATES must include failed');
|
|
110
|
+
|
|
111
|
+
// (1) Persisted record that's been "running" for 10 minutes with no live
|
|
112
|
+
// subprocess — must be demoted. The user's exact scenario: dashboard
|
|
113
|
+
// restart orphaned the SDK parent query and the record on disk still
|
|
114
|
+
// says running.
|
|
115
|
+
{
|
|
116
|
+
const sessionId = 'sess-zombie-persisted-' + Date.now();
|
|
117
|
+
makeStalePersistedRecord('demo-zombie-persisted', sessionId, 'running');
|
|
118
|
+
|
|
119
|
+
const demoted = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
|
|
120
|
+
const hit = demoted.find(d => d.sessionId === sessionId);
|
|
121
|
+
assert(hit, 'sweeper should have demoted the persisted zombie');
|
|
122
|
+
assert(hit.source === 'persisted', `expected source=persisted, got ${hit.source}`);
|
|
123
|
+
|
|
124
|
+
// The rehydrated session in memory should now show failed + endedAt.
|
|
125
|
+
const state = runner.getState(sessionId);
|
|
126
|
+
assert(state.state === 'failed',
|
|
127
|
+
`expected state=failed after sweep, got ${state.state}`);
|
|
128
|
+
assert(state.endedAt, 'endedAt should be set after demotion');
|
|
129
|
+
assert(state.error && /subprocess lost/i.test(state.error),
|
|
130
|
+
`error message should explain the orphan, got ${state.error}`);
|
|
131
|
+
|
|
132
|
+
// Subsequent sweeps should be idempotent — already-failed sessions are
|
|
133
|
+
// not in ACTIVE_STATES so they don't get touched again.
|
|
134
|
+
const secondSweep = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
|
|
135
|
+
const hitAgain = secondSweep.find(d => d.sessionId === sessionId);
|
|
136
|
+
assert(!hitAgain,
|
|
137
|
+
`sweeper must not re-demote already-failed session (hit again: ${JSON.stringify(secondSweep)})`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// (2) In-memory session with state=running AND subprocess=null AND a
|
|
141
|
+
// stale updatedAt — must be demoted. This is the runtime-orphan case:
|
|
142
|
+
// SDK query was kicked off, but the subprocess ref was cleared without
|
|
143
|
+
// a terminal setState (e.g. the dashboard process died between the
|
|
144
|
+
// subprocess=null assignment and the setState(failed) on the catch path).
|
|
145
|
+
{
|
|
146
|
+
const projectSlug = 'demo-zombie-memory';
|
|
147
|
+
const session = createSession({
|
|
148
|
+
projectSlug,
|
|
149
|
+
projectPath: 'D:/proj/' + projectSlug,
|
|
150
|
+
kbPath: path.join(TMP_DATA, 'knowledge', projectSlug),
|
|
151
|
+
promptKey: 'post-commit-automation',
|
|
152
|
+
source: 'git-hook',
|
|
153
|
+
metadata: { source: 'git-hook', automation: true, automationRunId: 'auto-mem' },
|
|
154
|
+
});
|
|
155
|
+
// Force it into the orphan shape: state=running, subprocess=null,
|
|
156
|
+
// updatedAt way in the past.
|
|
157
|
+
session.state = 'running';
|
|
158
|
+
session.subprocess = null;
|
|
159
|
+
session.updatedAt = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
|
160
|
+
session.startedAt = session.updatedAt;
|
|
161
|
+
|
|
162
|
+
const demoted = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
|
|
163
|
+
const hit = demoted.find(d => d.sessionId === session.sessionId);
|
|
164
|
+
assert(hit, 'sweeper should have demoted the in-memory zombie');
|
|
165
|
+
assert(hit.source === 'memory', `expected source=memory, got ${hit.source}`);
|
|
166
|
+
|
|
167
|
+
const state = runner.getState(session.sessionId);
|
|
168
|
+
assert(state.state === 'failed',
|
|
169
|
+
`expected in-memory session to be failed, got ${state.state}`);
|
|
170
|
+
assert(state.error && /subprocess exited/i.test(state.error),
|
|
171
|
+
`expected subprocess-null error, got ${state.error}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// (3) Fresh session (started 5s ago) must NOT be demoted even if
|
|
175
|
+
// subprocess happens to be null. Threshold exists exactly to keep
|
|
176
|
+
// legitimately-thinking sessions from being killed prematurely.
|
|
177
|
+
{
|
|
178
|
+
const projectSlug = 'demo-fresh';
|
|
179
|
+
const session = createSession({
|
|
180
|
+
projectSlug,
|
|
181
|
+
projectPath: 'D:/proj/' + projectSlug,
|
|
182
|
+
kbPath: path.join(TMP_DATA, 'knowledge', projectSlug),
|
|
183
|
+
promptKey: 'post-commit-automation',
|
|
184
|
+
source: 'git-hook',
|
|
185
|
+
metadata: { source: 'git-hook', automation: true, automationRunId: 'auto-fresh' },
|
|
186
|
+
});
|
|
187
|
+
session.state = 'running';
|
|
188
|
+
session.subprocess = null;
|
|
189
|
+
// updatedAt defaults to createdAt via createSession, well under 60s old.
|
|
190
|
+
|
|
191
|
+
const demoted = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
|
|
192
|
+
const hit = demoted.find(d => d.sessionId === session.sessionId);
|
|
193
|
+
assert(!hit,
|
|
194
|
+
`fresh session (age <60s) must NOT be demoted; demoted list = ${JSON.stringify(demoted)}`);
|
|
195
|
+
|
|
196
|
+
// Cleanup so it doesn't leak into the next test.
|
|
197
|
+
runner.deleteSession(session.sessionId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// (4) Demotion must fire the global SSE broadcast so the UI sees the
|
|
201
|
+
// running→failed transition. Without this the blue light stays on even
|
|
202
|
+
// after the in-memory state is wrong.
|
|
203
|
+
{
|
|
204
|
+
const projectSlug = 'demo-broadcast';
|
|
205
|
+
const broadcastEvents = [];
|
|
206
|
+
subscribeList(ev => broadcastEvents.push(ev));
|
|
207
|
+
|
|
208
|
+
const sessionId = 'sess-broadcast-' + Date.now();
|
|
209
|
+
makeStalePersistedRecord(projectSlug, sessionId, 'running');
|
|
210
|
+
|
|
211
|
+
demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
|
|
212
|
+
const sawTransition = broadcastEvents.some(
|
|
213
|
+
ev => ev.sessionId === sessionId && ev.state === 'failed' && ev.kind === 'stale-active-sweep'
|
|
214
|
+
);
|
|
215
|
+
assert(sawTransition,
|
|
216
|
+
`expected SSE broadcast for ${sessionId} with state=failed kind=stale-active-sweep; got ${JSON.stringify(broadcastEvents)}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// (5) The onSessionEnded hook must fire for failed sessions, so the
|
|
220
|
+
// automation queue (post-commit-automation.js) can promote the next
|
|
221
|
+
// queued run. Without this, stranded sessions would also strand any
|
|
222
|
+
// queued successor forever.
|
|
223
|
+
{
|
|
224
|
+
const projectSlug = 'demo-callback';
|
|
225
|
+
const sessionId = 'sess-callback-' + Date.now();
|
|
226
|
+
makeStalePersistedRecord(projectSlug, sessionId, 'running');
|
|
227
|
+
|
|
228
|
+
let endedWithFailed = null;
|
|
229
|
+
const off = runner.onSessionEnded(s => {
|
|
230
|
+
if (s.sessionId === sessionId && s.state === 'failed') {
|
|
231
|
+
endedWithFailed = s;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
|
|
236
|
+
// onSessionEnded fires via setImmediate, so wait one tick.
|
|
237
|
+
return Promise.resolve()
|
|
238
|
+
.then(() => new Promise(resolve => setImmediate(resolve)))
|
|
239
|
+
.then(() => {
|
|
240
|
+
off();
|
|
241
|
+
assert(endedWithFailed,
|
|
242
|
+
`onSessionEnded should have fired for the demoted session`);
|
|
243
|
+
assert(endedWithFailed.sessionId === sessionId,
|
|
244
|
+
`wrong sessionId in callback: ${endedWithFailed && endedWithFailed.sessionId}`);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
})().then(() => {
|
|
248
|
+
console.log('claude-session-lifecycle-sweep-test PASS');
|
|
249
|
+
}).catch(e => {
|
|
250
|
+
console.error(e.stack || e.message);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Claude workbench regression test.
|
|
2
|
+
//
|
|
3
|
+
// Verifies session metadata and the SDK-only session lifecycle. The legacy
|
|
4
|
+
// `claude -p` subprocess path was removed in the CC-Switch migration, so
|
|
5
|
+
// the pre-turn `requirePermission` flow no longer exists — the SDK path
|
|
6
|
+
// shows permission prompts via canUseTool only when an individual tool
|
|
7
|
+
// is actually invoked. Tool-level prompts are exercised by the chat
|
|
8
|
+
// panel integration tests.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const runner = require('../lib/claude-cli-runner');
|
|
13
|
+
|
|
14
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
15
|
+
const TEMP_KB = path.join(ROOT, 'projects', 'claude-workbench-test-temp');
|
|
16
|
+
const TEMP_AI = path.join(ROOT, '_site', '_ai', 'claude-workbench-test-temp');
|
|
17
|
+
|
|
18
|
+
function assert(cond, msg) {
|
|
19
|
+
if (!cond) throw new Error(msg);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
(async () => {
|
|
23
|
+
fs.rmSync(TEMP_KB, { recursive: true, force: true });
|
|
24
|
+
fs.rmSync(TEMP_AI, { recursive: true, force: true });
|
|
25
|
+
fs.mkdirSync(TEMP_KB, { recursive: true });
|
|
26
|
+
|
|
27
|
+
// 1. Default initial-analysis turn: SDK is the only mode. The session
|
|
28
|
+
// should be created without any pre-turn permission prompt (the
|
|
29
|
+
// SDK surfaces tool approvals lazily through canUseTool).
|
|
30
|
+
const started = runner.startSession({
|
|
31
|
+
slug: 'claude-workbench-test-temp',
|
|
32
|
+
projectPath: ROOT,
|
|
33
|
+
kbPath: TEMP_KB,
|
|
34
|
+
promptKey: 'initial-analysis',
|
|
35
|
+
aiProfile: {
|
|
36
|
+
id: 'test-profile',
|
|
37
|
+
implementation: 'claude-code-agent',
|
|
38
|
+
mainModel: 'test-model',
|
|
39
|
+
},
|
|
40
|
+
vars: {
|
|
41
|
+
SLUG: 'claude-workbench-test-temp',
|
|
42
|
+
PROJECT_PATH: ROOT,
|
|
43
|
+
PRIMARY_LANGUAGE: 'JavaScript',
|
|
44
|
+
KNOWLEDGE_LANGUAGE: 'zh-CN',
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert(started.sessionId, 'startSession should return sessionId');
|
|
49
|
+
assert(!started.pendingPermission,
|
|
50
|
+
'SDK mode should not show a pre-turn UI permission prompt');
|
|
51
|
+
assert(started.runner === 'sdk',
|
|
52
|
+
`startSession should default to runner: 'sdk', got ${started.runner}`);
|
|
53
|
+
|
|
54
|
+
const state = runner.getState(started.sessionId);
|
|
55
|
+
assert(state.runner === 'sdk',
|
|
56
|
+
`session state should record runner: 'sdk', got ${state.runner}`);
|
|
57
|
+
|
|
58
|
+
runner.deleteSession(started.sessionId);
|
|
59
|
+
|
|
60
|
+
// 1b. The embedded terminal should be able to create an idle chat
|
|
61
|
+
// session without reading claude-prompts.json or running an
|
|
62
|
+
// initial-analysis prompt. The user's first typed message starts
|
|
63
|
+
// the actual SDK turn via sendInput.
|
|
64
|
+
const chat = runner.startChatSession({
|
|
65
|
+
slug: 'claude-workbench-test-temp',
|
|
66
|
+
projectPath: ROOT,
|
|
67
|
+
kbPath: TEMP_KB,
|
|
68
|
+
aiProfile: {
|
|
69
|
+
id: 'test-profile',
|
|
70
|
+
implementation: 'claude-code-agent',
|
|
71
|
+
mainModel: 'test-model',
|
|
72
|
+
},
|
|
73
|
+
permissionMode: 'default',
|
|
74
|
+
});
|
|
75
|
+
assert(chat.sessionId, 'startChatSession should return sessionId');
|
|
76
|
+
const chatState = runner.getState(chat.sessionId);
|
|
77
|
+
assert(chatState.promptKey === 'terminal-chat',
|
|
78
|
+
`chat session should use terminal-chat promptKey, got ${chatState.promptKey}`);
|
|
79
|
+
assert(chatState.state === 'idle',
|
|
80
|
+
`chat session should stay idle until user input, got ${chatState.state}`);
|
|
81
|
+
assert(chatState.turns === 0, `chat session should not start a turn, got turns=${chatState.turns}`);
|
|
82
|
+
|
|
83
|
+
// 1c. Switching AI profiles mid-session must not resume the old upstream
|
|
84
|
+
// Claude conversation with the new provider. That used to send the
|
|
85
|
+
// previous model (for example MiniMax-M3) to the new base URL (for
|
|
86
|
+
// example GLM), producing "model not found" API errors.
|
|
87
|
+
const chatSession = runner.getSession(chat.sessionId);
|
|
88
|
+
chatSession.aiProfileId = 'minimax-m3';
|
|
89
|
+
chatSession.model = 'MiniMax-M3';
|
|
90
|
+
chatSession.claudeSessionId = 'old-claude-session-id';
|
|
91
|
+
const switched = runner.applyAiProfileToSession(chatSession, {
|
|
92
|
+
id: 'glm',
|
|
93
|
+
implementation: 'claude-code-agent',
|
|
94
|
+
mainModel: 'glm-5.2',
|
|
95
|
+
});
|
|
96
|
+
const switchedState = runner.getState(chat.sessionId);
|
|
97
|
+
assert(switched.resetConversation === true, 'profile switch should reset the upstream conversation');
|
|
98
|
+
assert(switchedState.claudeSessionId === null, 'profile switch should clear stale claudeSessionId');
|
|
99
|
+
assert(switchedState.aiProfileId === 'glm', `profile switch should update aiProfileId, got ${switchedState.aiProfileId}`);
|
|
100
|
+
assert(switchedState.model === 'glm-5.2', `profile switch should update model, got ${switchedState.model}`);
|
|
101
|
+
runner.deleteSession(chat.sessionId);
|
|
102
|
+
|
|
103
|
+
// Session metadata uses durable atomic replacement, but a disk fault must
|
|
104
|
+
// remain fail-open for the live Workbench session and become observable.
|
|
105
|
+
const AtomicFile = require('../lib/atomic-file');
|
|
106
|
+
const originalAtomicWrite = AtomicFile.writeJsonAtomic;
|
|
107
|
+
const persistenceLogs = [];
|
|
108
|
+
runner.setLogger({ error: async (event, message, payload) => { persistenceLogs.push({ event, message, payload }); } });
|
|
109
|
+
AtomicFile.writeJsonAtomic = () => { throw Object.assign(new Error('simulated disk full'), { code: 'ENOSPC' }); };
|
|
110
|
+
let persistenceFaultSession;
|
|
111
|
+
try {
|
|
112
|
+
persistenceFaultSession = runner.createSession({
|
|
113
|
+
projectSlug: 'claude-workbench-test-temp', projectPath: ROOT, kbPath: TEMP_KB, promptKey: 'terminal-chat',
|
|
114
|
+
});
|
|
115
|
+
assert(persistenceFaultSession.sessionId, 'persistence failure must not interrupt the live session');
|
|
116
|
+
assert(persistenceLogs.some(entry => entry.event === 'claude.session_persist_failed' && entry.payload.error.code === 'ENOSPC'), 'persistence failure must be structured and correlated');
|
|
117
|
+
} finally {
|
|
118
|
+
AtomicFile.writeJsonAtomic = originalAtomicWrite;
|
|
119
|
+
runner.setLogger(null);
|
|
120
|
+
if (persistenceFaultSession) runner.deleteSession(persistenceFaultSession.sessionId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const executable = await runner.findClaudeExecutableForSdk();
|
|
124
|
+
if (process.platform === 'win32' && executable.cmd) {
|
|
125
|
+
assert(!/\.(cmd|bat|ps1)$/i.test(executable.cmd),
|
|
126
|
+
`SDK executable must not be a Windows shell shim: ${executable.cmd}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Automation raw prompt sessions must preserve the selected permission
|
|
130
|
+
// mode. This guards against regressing back to bypassPermissions -> default.
|
|
131
|
+
process.env.KB_AUTOMATION_FAKE_CLAUDE = '1';
|
|
132
|
+
const auto = runner.startAutomationSession({
|
|
133
|
+
slug: 'claude-workbench-test-temp',
|
|
134
|
+
projectPath: ROOT,
|
|
135
|
+
kbPath: TEMP_KB,
|
|
136
|
+
userPrompt: 'automation test prompt',
|
|
137
|
+
systemPrompt: 'automation system prompt',
|
|
138
|
+
aiProfile: {
|
|
139
|
+
id: 'test-profile',
|
|
140
|
+
implementation: 'claude-code-agent',
|
|
141
|
+
mainModel: 'test-model',
|
|
142
|
+
},
|
|
143
|
+
permissionMode: 'bypassPermissions',
|
|
144
|
+
allowedTools: ['Read'],
|
|
145
|
+
safetyPolicy: {
|
|
146
|
+
kind: 'kb-automation',
|
|
147
|
+
kbPath: TEMP_KB,
|
|
148
|
+
knowledgeMode: 'requestApproval',
|
|
149
|
+
allowReadOnlyBash: false,
|
|
150
|
+
allowedTools: ['Read'],
|
|
151
|
+
canWriteKb: false,
|
|
152
|
+
},
|
|
153
|
+
metadata: { automation: true, automationRunId: 'auto-test', source: 'test' },
|
|
154
|
+
});
|
|
155
|
+
const autoState = runner.getState(auto.sessionId);
|
|
156
|
+
assert(autoState.permissionMode === 'bypassPermissions',
|
|
157
|
+
`automation session should preserve bypassPermissions, got ${autoState.permissionMode}`);
|
|
158
|
+
assert(autoState.automation === true, 'automation session should be marked automation=true');
|
|
159
|
+
runner.deleteSession(auto.sessionId);
|
|
160
|
+
delete process.env.KB_AUTOMATION_FAKE_CLAUDE;
|
|
161
|
+
|
|
162
|
+
fs.rmSync(TEMP_KB, { recursive: true, force: true });
|
|
163
|
+
fs.rmSync(TEMP_AI, { recursive: true, force: true });
|
|
164
|
+
console.log('Claude workbench test passed');
|
|
165
|
+
})().catch(err => {
|
|
166
|
+
try { fs.rmSync(TEMP_KB, { recursive: true, force: true }); } catch {}
|
|
167
|
+
try { fs.rmSync(TEMP_AI, { recursive: true, force: true }); } catch {}
|
|
168
|
+
console.error(err);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
});
|