@sanqianx/project-knowledge 5.0.18 → 5.0.20-rc.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 +3 -3
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +14 -0
- package/LICENSE +201 -201
- package/NOTICE +5 -5
- package/README.md +349 -349
- package/_modules/knowledge-engine/README.md +4 -0
- package/_modules/knowledge-engine/src/analysis-contract.js +4 -3
- package/_modules/knowledge-engine/src/analysis-failures.js +39 -0
- package/_modules/knowledge-engine/src/analysis-worker.js +6 -2
- package/_modules/knowledge-engine/src/coordinator.js +8 -0
- package/_modules/knowledge-engine/src/headless-analyzer.js +2 -1
- package/_modules/knowledge-engine/src/memories.js +10 -5
- package/_modules/knowledge-engine/src/memory-eligibility.js +74 -0
- package/_modules/knowledge-engine/src/retrieval-ranking.js +19 -1
- package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +1 -0
- package/_modules/knowledge-engine/src/runtime/commit-prompt.js +12 -9
- package/_modules/knowledge-engine/src/runtime/conversation-store.js +2 -0
- package/_modules/knowledge-engine/src/server.js +1 -1
- package/_site/_test/agent-capture-mcp-ui-test.js +67 -67
- package/_site/_test/ai-profile-test.js +75 -75
- package/_site/_test/ai-vendor-presets-test.js +91 -91
- package/_site/_test/automatic-context-test.js +70 -70
- package/_site/_test/bin-cli-test.js +230 -230
- package/_site/_test/bridge-adapter-test.js +95 -95
- package/_site/_test/bridge-consumer-service-test.js +310 -310
- package/_site/_test/candidate-bin-content-test.js +35 -0
- package/_site/_test/candidate-product-assets-test.js +13 -0
- package/_site/_test/candidate-release-contract-test.js +14 -14
- package/_site/_test/capture-hook-dedup-test.js +158 -158
- package/_site/_test/claude-executable-discovery-test.js +28 -28
- package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -252
- package/_site/_test/cli-instance-management-test.js +62 -62
- package/_site/_test/client-capture-restart-e2e-test.js +189 -189
- package/_site/_test/codex-conversation-projection-test.js +60 -60
- package/_site/_test/codex-marketplace-scoped-path-test.js +186 -186
- package/_site/_test/codex-notify-capture-e2e-test.js +101 -101
- package/_site/_test/codex-notify-fanout-test.js +134 -134
- package/_site/_test/codex-notify-upgrade-test.js +253 -253
- package/_site/_test/commit-boundary-freeze-test.js +147 -147
- package/_site/_test/commit-conversation-binding-test.js +4 -4
- package/_site/_test/configuration-generation-test.js +22 -22
- package/_site/_test/context-pack-test.js +200 -200
- package/_site/_test/conversation-api-test.js +10 -10
- package/_site/_test/conversation-origin-test.js +15 -0
- package/_site/_test/cross-repo-isolation-e2e-test.js +187 -187
- package/_site/_test/embedding-config-test.js +32 -32
- package/_site/_test/fixtures/fake-browser-exit.js +10 -10
- package/_site/_test/fixtures/fake-browser-hang.js +10 -10
- package/_site/_test/fixtures/integration-busy-worker.cjs +6 -6
- package/_site/_test/fixtures/make-git-repos.js +92 -92
- package/_site/_test/fixtures/retrieval-gold-queries.json +32 -0
- package/_site/_test/fresh-hook-reimport-test.js +53 -53
- package/_site/_test/fresh-product-data-test.js +56 -56
- package/_site/_test/git-path-compatibility-test.js +80 -80
- package/_site/_test/git-validation-test.js +81 -81
- package/_site/_test/hook-worktree-test.js +42 -42
- package/_site/_test/index-builder-test.js +41 -41
- package/_site/_test/index-error-display-contract-test.js +10 -10
- package/_site/_test/integration-adapters-test.js +67 -67
- package/_site/_test/integration-capture-setup-test.js +118 -118
- package/_site/_test/integration-runtime-binding-a2-test.js +566 -566
- package/_site/_test/integration-runtime-binding-a3-test.js +227 -227
- package/_site/_test/integration-runtime-binding-test.js +473 -473
- package/_site/_test/integration-status-scan-test.js +29 -29
- package/_site/_test/kb-framework-test.js +57 -57
- package/_site/_test/kbpath-follow-test.js +63 -63
- package/_site/_test/knowledge-db-test.js +144 -144
- package/_site/_test/knowledge-engine-client-test.js +109 -109
- package/_site/_test/knowledge-maintenance-test.js +40 -40
- package/_site/_test/knowledge-panel-ui-contract-test.js +112 -0
- package/_site/_test/knowledge-scopes-test.js +37 -37
- package/_site/_test/knowledge-storage-location-test.js +28 -28
- package/_site/_test/launcher-readiness-test.js +80 -80
- package/_site/_test/legacy-claude-session-alias-test.js +106 -106
- package/_site/_test/legacy-routes-removed-test.js +47 -47
- package/_site/_test/legacy-workbench-exclusion-test.js +92 -92
- package/_site/_test/live-gateway-test.js +48 -48
- package/_site/_test/local-marketplace-test.js +16 -16
- package/_site/_test/log-redaction-test.js +58 -58
- package/_site/_test/markdown-maintenance-test.js +11 -11
- package/_site/_test/model-context-windows-test.js +89 -89
- package/_site/_test/module-agent-start-proxy-test.js +65 -65
- package/_site/_test/module-artifact-integrity-test.js +27 -27
- package/_site/_test/module-boundary-test.js +30 -30
- package/_site/_test/module-bridge-eventbridge-test.js +202 -202
- package/_site/_test/module-dynamic-port-test.js +118 -118
- package/_site/_test/module-index-proxy-test.js +33 -0
- package/_site/_test/module-model-configuration-test.js +44 -44
- package/_site/_test/module-process-lifecycle-test.js +35 -35
- package/_site/_test/module-stream-proxy-test.js +34 -34
- package/_site/_test/module-terminal-restart-test.js +63 -0
- package/_site/_test/module-wiring-unbind-race-test.js +59 -59
- package/_site/_test/native-context-integrations-test.js +86 -86
- package/_site/_test/orphaned-claim-recovery-test.js +80 -80
- package/_site/_test/package-boundary-test.js +43 -42
- package/_site/_test/package-startup-test.js +56 -56
- package/_site/_test/packaged-ui-smoke-test.js +88 -88
- package/_site/_test/pr-consumer-contract-test.js +55 -55
- package/_site/_test/product-brand-contract-test.js +25 -25
- package/_site/_test/product-diagnostics-ui-test.js +93 -93
- package/_site/_test/product-import-ui-test.js +104 -104
- package/_site/_test/product-progress-status-test.js +12 -1
- package/_site/_test/project-remove-running-guard-test.js +79 -79
- package/_site/_test/project-store-test.js +90 -90
- package/_site/_test/registry-events-test.js +38 -0
- package/_site/_test/release-version-sync-test.js +115 -115
- package/_site/_test/requirement-binding-test.js +113 -113
- package/_site/_test/runtime-endpoint-test.js +64 -64
- package/_site/_test/scanner-test.js +83 -83
- package/_site/_test/server-security-test.js +96 -96
- package/_site/_test/sessions-stream-test.js +93 -93
- package/_site/_test/shared-scope-root-binding-test.js +67 -67
- package/_site/_test/simple-import-test.js +98 -98
- package/_site/_test/startup-analysis-disabled-test.js +6 -6
- package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -196
- package/_site/_test/team-knowledge-store-test.js +132 -132
- package/_site/_test/test-server-cleanup-test.js +35 -35
- package/_site/_test/test-server-module-isolation-test.js +37 -37
- package/_site/_test/tracking-start-test.js +35 -35
- package/_site/_test/workbench-context-receipts-test.js +22 -0
- package/_site/_test/workspace-ui-contract-test.js +31 -31
- package/_site/_test/zcode-capture-integration-test.js +108 -108
- package/_site/lib/ai-vendor-presets.js +83 -83
- package/_site/lib/ai-workspace.js +3 -3
- package/_site/lib/atomic-file.js +204 -204
- package/_site/lib/bridge-adapter.js +260 -260
- package/_site/lib/capture-hook-config.js +108 -108
- package/_site/lib/claude-cli-runner.js +89 -89
- package/_site/lib/cli-instances.js +43 -43
- package/_site/lib/codex-context-status.js +17 -17
- package/_site/lib/codex-notify-config.js +193 -193
- package/_site/lib/commit-conversation-binder.js +13 -12
- package/_site/lib/commit-reconciler.js +38 -38
- package/_site/lib/context-pack-builder.js +299 -299
- package/_site/lib/conversation-exclusions.js +204 -204
- package/_site/lib/conversation-query-service.js +17 -17
- package/_site/lib/conversation-store.js +2 -0
- package/_site/lib/data-dir.js +13 -13
- package/_site/lib/embedding-service.js +85 -85
- package/_site/lib/engine-connection.js +93 -90
- package/_site/lib/engine-knowledge-client.js +72 -72
- package/_site/lib/folder-picker-output.js +3 -3
- package/_site/lib/git-path.js +14 -14
- package/_site/lib/github-team-store.js +3 -3
- package/_site/lib/index-builder.js +157 -157
- package/_site/lib/integration-status-scan.js +99 -99
- package/_site/lib/integration-status-worker.js +14 -14
- package/_site/lib/kb-framework.js +15 -15
- package/_site/lib/knowledge-db.js +363 -363
- package/_site/lib/knowledge-schema.js +121 -121
- package/_site/lib/live-gateway.js +78 -76
- package/_site/lib/llm-client.js +12 -12
- package/_site/lib/model-context-windows.js +82 -82
- package/_site/lib/module-bridge.js +874 -865
- package/_site/lib/module-port-resolver.js +57 -57
- package/_site/lib/post-commit-automation.js +54 -54
- package/_site/lib/product-data.js +35 -35
- package/_site/lib/project-lifecycle-service.js +14 -14
- package/_site/lib/project-registry-store.js +129 -129
- package/_site/lib/prompt-registry.js +148 -148
- package/_site/lib/registry-events.js +16 -0
- package/_site/lib/requirement-adapters.js +10 -10
- package/_site/lib/requirement-binder.js +112 -112
- package/_site/lib/runtime-control.js +89 -89
- package/_site/lib/runtime-endpoint.js +5 -5
- package/_site/lib/server-app.js +10 -1
- package/_site/lib/zcode-context-integration.js +60 -60
- package/_site/scripts/codex-notify-fanout.cjs +95 -95
- package/_site/scripts/install-module-candidates.js +69 -69
- package/_site/scripts/pack-module-candidates.js +89 -72
- package/_site/scripts/sync-product-content.js +28 -28
- package/_site/scripts/sync-release-version.js +164 -164
- package/_site/scripts/vendor-modules.js +49 -49
- package/_site/scripts/verify-module-runtime.js +50 -50
- package/_site/scripts/verify-product-runtime.js +36 -36
- package/_site/scripts/verify-retrieval-gold.js +83 -0
- package/_site/server.js +15 -15
- package/_site/start.bat +26 -26
- package/_site/stop.bat +11 -11
- package/bin/project-knowledge-integrations.js +254 -254
- package/bin/project-knowledge-kb.js +67 -67
- package/bin/project-knowledge-mcp.js +8 -8
- package/bin/project-knowledge.js +170 -170
- package/claude-prompts.json +21 -21
- package/docs/README.zh-CN.md +205 -205
- package/docs/assets/architecture.svg +181 -181
- package/docs/assets/logo.svg +49 -49
- package/docs/fresh-v5-start.md +42 -42
- package/docs/leader-glm-workbench-playbook.md +122 -119
- package/docs/project-registry-schema.md +11 -11
- package/docs/release-5.0.16-acceptance.md +176 -176
- package/docs/release-5.0.17-acceptance.md +117 -117
- package/docs/release-5.0.19-acceptance.md +39 -0
- package/docs/team-knowledge-mode-a-plan.md +73 -73
- package/module-runtime-manifest.json +126 -104
- package/package.json +110 -108
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
- package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
- package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
- package/templates/change.md +30 -30
- package/templates/project-readme.md +18 -18
- package/ui/index.html +1 -1
- package/ui/knowledge-panel.html +118 -0
- package/ui/product.css +8 -0
- package/ui/product.js +175 -8
- package/vendor-manifest.json +55 -53
|
@@ -1,252 +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
|
-
});
|
|
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
|
+
});
|
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
const assert = require('node:assert/strict');
|
|
3
|
-
const fs = require('node:fs');
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const os = require('node:os');
|
|
6
|
-
const http = require('node:http');
|
|
7
|
-
const { spawn } = require('node:child_process');
|
|
8
|
-
const endpoint = require('../lib/runtime-endpoint');
|
|
9
|
-
const control = require('../lib/runtime-control');
|
|
10
|
-
const packageRoot = process.env.KB_CLI_TEST_PACKAGE || path.resolve(__dirname, '../..');
|
|
11
|
-
const bin = path.join(packageRoot, 'bin/project-knowledge.js');
|
|
12
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-instance-management-'));
|
|
13
|
-
const a = path.join(root, '知识库 A'), b = path.join(root, '知识库 B');
|
|
14
|
-
const temp = path.join(root, 'isolated-temp'); fs.mkdirSync(temp);
|
|
15
|
-
const env = { ...process.env, KB_DATA_DIR: a, TEMP: temp, TMP: temp, KB_MODULES_AUTOSTART: '0', AI_CODING_EVENT_BRIDGE_HOME: path.join(root, 'bridge') };
|
|
16
|
-
const pids = [];
|
|
17
|
-
function run(args) {
|
|
18
|
-
return new Promise((resolve, reject) => {
|
|
19
|
-
const child = spawn(process.execPath, [bin, ...args], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
20
|
-
let stdout = '', stderr = '';
|
|
21
|
-
const timer = setTimeout(() => { child.kill(); reject(new Error(`CLI timeout: ${stdout} ${stderr}`)); }, 25000);
|
|
22
|
-
child.stdout.on('data', c => { stdout += c; }); child.stderr.on('data', c => { stderr += c; });
|
|
23
|
-
child.once('error', reject); child.once('exit', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
async function usable(port) { try { return (await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(700) })).ok; } catch { return false; } }
|
|
27
|
-
let foreign;
|
|
28
|
-
(async () => {
|
|
29
|
-
foreign = http.createServer((req, res) => { res.writeHead(404); res.end('not knowledge'); });
|
|
30
|
-
await new Promise(resolve => foreign.listen(0, '127.0.0.1', resolve));
|
|
31
|
-
const foreignPort = foreign.address().port;
|
|
32
|
-
fs.writeFileSync(path.join(temp, '.project-knowledge.pid'), `${process.pid}\n${foreignPort}`);
|
|
33
|
-
for (const args of [['stoop'], ['STOP', 'unexpected'], ['stop', '--port', '12junk'], ['stop', '--port', '65536'], ['stop', '--port'], ['stop', '--all']]) {
|
|
34
|
-
const result = await run(args); assert.equal(result.code, 1, JSON.stringify(result));
|
|
35
|
-
assert(!/Already running|starting in background/.test(result.stdout));
|
|
36
|
-
}
|
|
37
|
-
assert.equal(fs.existsSync(a), false, 'invalid commands must not create data or services');
|
|
38
|
-
const noInstance = await run(['STOP']); assert.equal(noInstance.code, 0); assert.match(noInstance.stdout, /No background/);
|
|
39
|
-
assert.equal(await usable(foreignPort), false); assert(foreign.listening, 'shared legacy PID must not kill an unrelated process');
|
|
40
|
-
const launchedA = await run(['START', '--no-open']); assert.equal(launchedA.code, 0, launchedA.stderr);
|
|
41
|
-
const infoA = endpoint.readEndpoint(a); pids.push(infoA.pid); assert(await usable(infoA.port));
|
|
42
|
-
const launchedB = await run(['start', '--data', b, '--no-open']); assert.equal(launchedB.code, 0, launchedB.stderr);
|
|
43
|
-
const infoB = endpoint.readEndpoint(b); pids.push(infoB.pid); assert(await usable(infoB.port)); assert.notEqual(infoA.port, infoB.port);
|
|
44
|
-
const status = await run(['STATUS']); assert.equal(status.code, 0); assert.match(status.stdout, new RegExp(`PID ${infoA.pid}`));
|
|
45
|
-
const inventory = await run(['STATUS', '--all']); assert.equal(inventory.code, 0, inventory.stderr);
|
|
46
|
-
assert.match(inventory.stdout, new RegExp(`PID ${infoA.pid}`)); assert.match(inventory.stdout, new RegExp(`PID ${infoB.pid}`));
|
|
47
|
-
const wrongPort = await run(['STOP', '--port', String(foreignPort)]); assert.equal(wrongPort.code, 1); assert(foreign.listening);
|
|
48
|
-
const conflict = await run(['stop', '--data', a, '--port', String(infoB.port)]); assert.equal(conflict.code, 1); assert(await usable(infoB.port));
|
|
49
|
-
await assert.rejects(() => control.request(a, { action: 'stop', pid: infoA.pid + 1, port: infoA.port, startedAt: infoA.startedAt }), /身份/);
|
|
50
|
-
assert(await usable(infoA.port));
|
|
51
|
-
const stoppedA = await run(['STOP']); assert.equal(stoppedA.code, 0, stoppedA.stderr); assert.match(stoppedA.stdout, new RegExp(`:${infoA.port}`));
|
|
52
|
-
assert.equal(await usable(infoA.port), false); assert(await usable(infoB.port), 'default stop must preserve a different data directory');
|
|
53
|
-
const stoppedB = await run(['stop', '--port', String(infoB.port)]); assert.equal(stoppedB.code, 0, stoppedB.stderr); assert.equal(await usable(infoB.port), false);
|
|
54
|
-
assert.equal((await run(['stop'])).code, 0, 'repeated stop is harmless');
|
|
55
|
-
fs.writeFileSync(path.join(a, 'runtime-endpoint.json'), JSON.stringify({ schema: endpoint.SCHEMA, pid: process.pid, host: '127.0.0.1', port: foreignPort }));
|
|
56
|
-
const stale = await run(['STOP']); assert.equal(stale.code, 1); assert.match(stale.stderr, /控制通道不可用/); assert(foreign.listening, 'a live but unrelated recorded PID is never signaled');
|
|
57
|
-
const refused = await run(['start', '--no-open']); assert.equal(refused.code, 1); assert(foreign.listening);
|
|
58
|
-
console.log('CLI instance management PASS: case, strict arguments, inventory, two directories, targeted stop, spoof protection, stale/shared PID safety');
|
|
59
|
-
})().catch(error => { console.error(error); process.exitCode = 1; }).finally(async () => {
|
|
60
|
-
foreign?.closeAllConnections(); if (foreign?.listening) await new Promise(resolve => foreign.close(resolve));
|
|
61
|
-
for (const data of [a, b]) try { const info = await control.request(data, { action: 'status' }, 500); await control.request(data, { action: 'stop', pid: info.instance.pid, port: info.instance.port, startedAt: info.instance.startedAt }); } catch {}
|
|
62
|
-
});
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const http = require('node:http');
|
|
7
|
+
const { spawn } = require('node:child_process');
|
|
8
|
+
const endpoint = require('../lib/runtime-endpoint');
|
|
9
|
+
const control = require('../lib/runtime-control');
|
|
10
|
+
const packageRoot = process.env.KB_CLI_TEST_PACKAGE || path.resolve(__dirname, '../..');
|
|
11
|
+
const bin = path.join(packageRoot, 'bin/project-knowledge.js');
|
|
12
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-instance-management-'));
|
|
13
|
+
const a = path.join(root, '知识库 A'), b = path.join(root, '知识库 B');
|
|
14
|
+
const temp = path.join(root, 'isolated-temp'); fs.mkdirSync(temp);
|
|
15
|
+
const env = { ...process.env, KB_DATA_DIR: a, TEMP: temp, TMP: temp, KB_MODULES_AUTOSTART: '0', AI_CODING_EVENT_BRIDGE_HOME: path.join(root, 'bridge') };
|
|
16
|
+
const pids = [];
|
|
17
|
+
function run(args) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const child = spawn(process.execPath, [bin, ...args], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
20
|
+
let stdout = '', stderr = '';
|
|
21
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error(`CLI timeout: ${stdout} ${stderr}`)); }, 25000);
|
|
22
|
+
child.stdout.on('data', c => { stdout += c; }); child.stderr.on('data', c => { stderr += c; });
|
|
23
|
+
child.once('error', reject); child.once('exit', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async function usable(port) { try { return (await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(700) })).ok; } catch { return false; } }
|
|
27
|
+
let foreign;
|
|
28
|
+
(async () => {
|
|
29
|
+
foreign = http.createServer((req, res) => { res.writeHead(404); res.end('not knowledge'); });
|
|
30
|
+
await new Promise(resolve => foreign.listen(0, '127.0.0.1', resolve));
|
|
31
|
+
const foreignPort = foreign.address().port;
|
|
32
|
+
fs.writeFileSync(path.join(temp, '.project-knowledge.pid'), `${process.pid}\n${foreignPort}`);
|
|
33
|
+
for (const args of [['stoop'], ['STOP', 'unexpected'], ['stop', '--port', '12junk'], ['stop', '--port', '65536'], ['stop', '--port'], ['stop', '--all']]) {
|
|
34
|
+
const result = await run(args); assert.equal(result.code, 1, JSON.stringify(result));
|
|
35
|
+
assert(!/Already running|starting in background/.test(result.stdout));
|
|
36
|
+
}
|
|
37
|
+
assert.equal(fs.existsSync(a), false, 'invalid commands must not create data or services');
|
|
38
|
+
const noInstance = await run(['STOP']); assert.equal(noInstance.code, 0); assert.match(noInstance.stdout, /No background/);
|
|
39
|
+
assert.equal(await usable(foreignPort), false); assert(foreign.listening, 'shared legacy PID must not kill an unrelated process');
|
|
40
|
+
const launchedA = await run(['START', '--no-open']); assert.equal(launchedA.code, 0, launchedA.stderr);
|
|
41
|
+
const infoA = endpoint.readEndpoint(a); pids.push(infoA.pid); assert(await usable(infoA.port));
|
|
42
|
+
const launchedB = await run(['start', '--data', b, '--no-open']); assert.equal(launchedB.code, 0, launchedB.stderr);
|
|
43
|
+
const infoB = endpoint.readEndpoint(b); pids.push(infoB.pid); assert(await usable(infoB.port)); assert.notEqual(infoA.port, infoB.port);
|
|
44
|
+
const status = await run(['STATUS']); assert.equal(status.code, 0); assert.match(status.stdout, new RegExp(`PID ${infoA.pid}`));
|
|
45
|
+
const inventory = await run(['STATUS', '--all']); assert.equal(inventory.code, 0, inventory.stderr);
|
|
46
|
+
assert.match(inventory.stdout, new RegExp(`PID ${infoA.pid}`)); assert.match(inventory.stdout, new RegExp(`PID ${infoB.pid}`));
|
|
47
|
+
const wrongPort = await run(['STOP', '--port', String(foreignPort)]); assert.equal(wrongPort.code, 1); assert(foreign.listening);
|
|
48
|
+
const conflict = await run(['stop', '--data', a, '--port', String(infoB.port)]); assert.equal(conflict.code, 1); assert(await usable(infoB.port));
|
|
49
|
+
await assert.rejects(() => control.request(a, { action: 'stop', pid: infoA.pid + 1, port: infoA.port, startedAt: infoA.startedAt }), /身份/);
|
|
50
|
+
assert(await usable(infoA.port));
|
|
51
|
+
const stoppedA = await run(['STOP']); assert.equal(stoppedA.code, 0, stoppedA.stderr); assert.match(stoppedA.stdout, new RegExp(`:${infoA.port}`));
|
|
52
|
+
assert.equal(await usable(infoA.port), false); assert(await usable(infoB.port), 'default stop must preserve a different data directory');
|
|
53
|
+
const stoppedB = await run(['stop', '--port', String(infoB.port)]); assert.equal(stoppedB.code, 0, stoppedB.stderr); assert.equal(await usable(infoB.port), false);
|
|
54
|
+
assert.equal((await run(['stop'])).code, 0, 'repeated stop is harmless');
|
|
55
|
+
fs.writeFileSync(path.join(a, 'runtime-endpoint.json'), JSON.stringify({ schema: endpoint.SCHEMA, pid: process.pid, host: '127.0.0.1', port: foreignPort }));
|
|
56
|
+
const stale = await run(['STOP']); assert.equal(stale.code, 1); assert.match(stale.stderr, /控制通道不可用/); assert(foreign.listening, 'a live but unrelated recorded PID is never signaled');
|
|
57
|
+
const refused = await run(['start', '--no-open']); assert.equal(refused.code, 1); assert(foreign.listening);
|
|
58
|
+
console.log('CLI instance management PASS: case, strict arguments, inventory, two directories, targeted stop, spoof protection, stale/shared PID safety');
|
|
59
|
+
})().catch(error => { console.error(error); process.exitCode = 1; }).finally(async () => {
|
|
60
|
+
foreign?.closeAllConnections(); if (foreign?.listening) await new Promise(resolve => foreign.close(resolve));
|
|
61
|
+
for (const data of [a, b]) try { const info = await control.request(data, { action: 'status' }, 500); await control.request(data, { action: 'stop', pid: info.instance.pid, port: info.instance.port, startedAt: info.instance.startedAt }); } catch {}
|
|
62
|
+
});
|