@modesto_ma/dsh-channels 0.2.0 → 0.3.0
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/README.md +53 -29
- package/docs/access-control-design.md +1 -1
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +15 -13
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +6 -5
- package/docs/workspace-isolation-design.md +78 -0
- package/lib/client.js +23 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +1110 -603
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +256 -119
- package/src/client/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +27 -8
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +119 -41
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +63 -20
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +18 -14
- package/src/settings/surface.ts +12 -4
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
package/src/service.ts
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* - p2p: one workspace per (bot, user), all of that user's sessions inside;
|
|
7
7
|
* - group: one workspace per (chat, user), sessions grouped per user.
|
|
8
8
|
*
|
|
9
|
+
* Every topic's Session works in its own directory inside that workspace
|
|
10
|
+
* (`topics/<token>` or a Git worktree), so topics never mix files while the
|
|
11
|
+
* workspace list grows with users instead of conversations.
|
|
12
|
+
*
|
|
9
13
|
* Routing model:
|
|
10
14
|
* - `thread_id` → bound session (no @ needed inside an owned topic);
|
|
11
15
|
* - degraded chat-scoped keys when Feishu refuses topic replies;
|
|
@@ -45,11 +49,11 @@ import type { EffortCommandAction, ParsedSlashCommand } from './commands.ts'
|
|
|
45
49
|
import { commandSpec } from './commands/registry.ts'
|
|
46
50
|
import type { ChannelCommandSpec } from './commands/registry.ts'
|
|
47
51
|
import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
|
|
48
|
-
import { feishuDomain } from './domain.ts'
|
|
52
|
+
import { feishuDomain, sessionDirOf } from './domain.ts'
|
|
49
53
|
import {
|
|
50
54
|
applyWorkspaceCleanup, isPathInside, planWorkspaceCleanup, retentionMs,
|
|
51
55
|
} from './cleanup.ts'
|
|
52
|
-
import type { CleanupDeps, WorkspaceStats } from './cleanup.ts'
|
|
56
|
+
import type { CleanupDeps, CleanupRoute, CleanupTombstone, ScannedSessionDir, WorkspaceStats } from './cleanup.ts'
|
|
53
57
|
import type {
|
|
54
58
|
AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
|
|
55
59
|
ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, ProcessedRecord, RawDomainHandle,
|
|
@@ -83,6 +87,7 @@ import type {
|
|
|
83
87
|
AccessMutationResponse, AccessRequestWire, AccessWire,
|
|
84
88
|
} from './settings/contract.ts'
|
|
85
89
|
import type { OverlayStore } from './settings/overlay.ts'
|
|
90
|
+
import { legacyTopicWorkspaceRoot } from './workspaces.ts'
|
|
86
91
|
|
|
87
92
|
/** Periodic route-GC sweep cadence (a long-running instance also accumulates
|
|
88
93
|
* dead records; TTL semantics make 6 h granularity more than sufficient). */
|
|
@@ -716,12 +721,12 @@ export class FeishuService {
|
|
|
716
721
|
return value
|
|
717
722
|
}
|
|
718
723
|
|
|
719
|
-
// ----
|
|
724
|
+
// ---- retention cleanup ------------------------------------------------
|
|
720
725
|
|
|
721
726
|
/**
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
727
|
+
* Retention preview for the Web panel: how many plugin workspaces exist,
|
|
728
|
+
* how many directories are past the configured window, and their measured
|
|
729
|
+
* footprint. Never deletes anything.
|
|
725
730
|
*/
|
|
726
731
|
async cleanupPreview(): Promise<{
|
|
727
732
|
retention: string
|
|
@@ -741,7 +746,7 @@ export class FeishuService {
|
|
|
741
746
|
}
|
|
742
747
|
if (this.domain === undefined) {
|
|
743
748
|
// No connection: routes live in the closed storage domain, so a preview
|
|
744
|
-
// would mistake every
|
|
749
|
+
// would mistake every directory for a candidate. Report why instead.
|
|
745
750
|
return {
|
|
746
751
|
...base,
|
|
747
752
|
workspaces: 0,
|
|
@@ -765,43 +770,57 @@ export class FeishuService {
|
|
|
765
770
|
}
|
|
766
771
|
|
|
767
772
|
/**
|
|
768
|
-
* Delete every stale plugin
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
773
|
+
* Delete every stale plugin directory the configured retention allows: the
|
|
774
|
+
* per-topic working directories of current conversations and the legacy
|
|
775
|
+
* one-workspace-per-topic directories of older versions. User workspaces are
|
|
776
|
+
* never deleted.
|
|
777
|
+
*
|
|
778
|
+
* Active routes bound to a removed directory are retired first (thread
|
|
779
|
+
* routes leave a tombstone), so an old topic fails with the explicit expired
|
|
780
|
+
* card instead of resuming into a missing directory. DSH Session logs are not
|
|
781
|
+
* touched.
|
|
772
782
|
*/
|
|
773
783
|
async runWorkspaceCleanup(): Promise<{
|
|
774
784
|
ok: boolean
|
|
775
785
|
deletedWorkspaces: number
|
|
786
|
+
deletedSessionDirs: number
|
|
776
787
|
retiredRoutes: number
|
|
777
788
|
freedBytes: number
|
|
778
789
|
failures: string[]
|
|
779
790
|
error?: string
|
|
780
791
|
}> {
|
|
781
|
-
const empty = {
|
|
792
|
+
const empty = {
|
|
793
|
+
ok: false,
|
|
794
|
+
deletedWorkspaces: 0,
|
|
795
|
+
deletedSessionDirs: 0,
|
|
796
|
+
retiredRoutes: 0,
|
|
797
|
+
freedBytes: 0,
|
|
798
|
+
failures: [] as string[],
|
|
799
|
+
}
|
|
782
800
|
if (this.domain === undefined) {
|
|
783
801
|
// Without the storage domain the route table is unreadable, so a cleanup
|
|
784
|
-
// could mistake live
|
|
802
|
+
// could mistake live directories for candidates and fail to retire their
|
|
785
803
|
// routes. Refuse rather than rely on the panel disabling the button.
|
|
786
804
|
return { ...empty, error: '飞书连接建立后才能清理工作区' }
|
|
787
805
|
}
|
|
788
806
|
if (retentionMs(this.config.workspaceRetention) === undefined) {
|
|
789
|
-
return { ...empty, error: '
|
|
807
|
+
return { ...empty, error: '未启用目录清理阈值(workspaceRetention 为 off)' }
|
|
790
808
|
}
|
|
791
|
-
if (this.cleanupRunning) return { ...empty, error: '
|
|
809
|
+
if (this.cleanupRunning) return { ...empty, error: '目录清理正在进行中,请稍候' }
|
|
792
810
|
this.cleanupRunning = true
|
|
793
811
|
try {
|
|
794
812
|
const deps = this.cleanupDeps()
|
|
795
813
|
const plan = await planWorkspaceCleanup(deps)
|
|
796
|
-
if (plan === undefined) return { ...empty, error: '
|
|
814
|
+
if (plan === undefined) return { ...empty, error: '目录清理不可用:baseDir 配置无效' }
|
|
797
815
|
const outcome = await applyWorkspaceCleanup(deps, plan)
|
|
798
816
|
this.ctx.logger?.info?.(
|
|
799
|
-
'dsh-channels:
|
|
800
|
-
outcome.deletedWorkspaces, outcome.retiredRoutes, outcome.freedBytes,
|
|
817
|
+
'dsh-channels: retention cleanup removed %d legacy workspace(s) and %d topic dir(s), retired %d route(s), freed %d bytes',
|
|
818
|
+
outcome.deletedWorkspaces, outcome.deletedSessionDirs, outcome.retiredRoutes, outcome.freedBytes,
|
|
801
819
|
)
|
|
802
820
|
return {
|
|
803
821
|
ok: true,
|
|
804
822
|
deletedWorkspaces: outcome.deletedWorkspaces,
|
|
823
|
+
deletedSessionDirs: outcome.deletedSessionDirs,
|
|
805
824
|
retiredRoutes: outcome.retiredRoutes,
|
|
806
825
|
freedBytes: outcome.freedBytes,
|
|
807
826
|
failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
|
|
@@ -826,39 +845,27 @@ export class FeishuService {
|
|
|
826
845
|
baseDir,
|
|
827
846
|
retention: this.config.workspaceRetention,
|
|
828
847
|
listWorkspaces: () => this.ctx.workspaceRegistry.list(),
|
|
829
|
-
|
|
848
|
+
listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
|
|
849
|
+
activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]): CleanupRoute => ({
|
|
830
850
|
key,
|
|
831
851
|
kind: record.kind,
|
|
832
852
|
workspacePath: record.workspacePath,
|
|
853
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
833
854
|
sessionId: record.sessionId,
|
|
834
855
|
lastActivityAt: record.lastActivityAt,
|
|
835
856
|
})),
|
|
836
|
-
tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]) => ({
|
|
857
|
+
tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]): CleanupTombstone => ({
|
|
837
858
|
key,
|
|
838
859
|
...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
|
|
860
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
839
861
|
expiredAt: record.expiredAt,
|
|
840
862
|
...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
|
|
841
863
|
})),
|
|
842
864
|
isLive: sessionId => this.sessions.get(sessionId) !== undefined
|
|
843
865
|
|| this.ctx.agents.get(sessionId as SessionId) !== undefined,
|
|
844
|
-
statWorkspace:
|
|
866
|
+
statWorkspace: directory => measureWorkspace(directory),
|
|
845
867
|
removeWorkspace: async workspace => {
|
|
846
|
-
|
|
847
|
-
if (!isPathInside(baseDir, target)) {
|
|
848
|
-
throw new Error(`refusing to delete a path outside baseDir (${target})`)
|
|
849
|
-
}
|
|
850
|
-
// Re-verify the physical path: a symlinked ancestor inside baseDir
|
|
851
|
-
// must not let the recursive delete escape it.
|
|
852
|
-
try {
|
|
853
|
-
const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)])
|
|
854
|
-
if (!isPathInside(physicalBase, physicalTarget)) {
|
|
855
|
-
throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
|
|
856
|
-
}
|
|
857
|
-
} catch (error) {
|
|
858
|
-
if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
|
|
859
|
-
// A missing workspace is already effectively removed (rm is idempotent).
|
|
860
|
-
}
|
|
861
|
-
await rm(target, { recursive: true, force: true })
|
|
868
|
+
await this.deleteDirectoryInsideBase(baseDir, workspace.path)
|
|
862
869
|
try {
|
|
863
870
|
await this.ctx.workspaceRegistry.delete(workspace.id)
|
|
864
871
|
} catch (error) {
|
|
@@ -867,6 +874,9 @@ export class FeishuService {
|
|
|
867
874
|
)
|
|
868
875
|
}
|
|
869
876
|
},
|
|
877
|
+
removeSessionDir: async directory => {
|
|
878
|
+
await this.deleteDirectoryInsideBase(baseDir, directory)
|
|
879
|
+
},
|
|
870
880
|
retireRoute: async route => {
|
|
871
881
|
if (route.kind === 'thread') await this.routes.retire(route.key)
|
|
872
882
|
else await this.routes.remove(route.key)
|
|
@@ -875,6 +885,71 @@ export class FeishuService {
|
|
|
875
885
|
}
|
|
876
886
|
}
|
|
877
887
|
|
|
888
|
+
/**
|
|
889
|
+
* Scan the live user workspaces for per-topic working directories that no
|
|
890
|
+
* route record points at anymore (for example the old route of a degraded
|
|
891
|
+
* chat conversation was deleted by TTL retirement). Route/tombstone evidence
|
|
892
|
+
* wins for age; the scan mtime only covers true orphans.
|
|
893
|
+
*/
|
|
894
|
+
private async listCleanableSessionDirs(baseDir: string): Promise<ScannedSessionDir[]> {
|
|
895
|
+
const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
|
|
896
|
+
const roots: string[] = []
|
|
897
|
+
for (const workspace of this.ctx.workspaceRegistry.list()) {
|
|
898
|
+
if (!this.isPluginWorkspace(workspace)) continue
|
|
899
|
+
const resolved = path.resolve(workspace.path)
|
|
900
|
+
// Legacy per-topic workspaces are cleaned as workspaces, not scanned here.
|
|
901
|
+
if (isPathInside(legacyRoot, resolved)) continue
|
|
902
|
+
roots.push(resolved)
|
|
903
|
+
}
|
|
904
|
+
const scanned: ScannedSessionDir[] = []
|
|
905
|
+
for (const root of roots) {
|
|
906
|
+
for (const relative of ['topics', path.join('.agents', 'worktrees')]) {
|
|
907
|
+
const directory = path.join(root, relative)
|
|
908
|
+
let entries
|
|
909
|
+
try {
|
|
910
|
+
entries = await readdir(directory, { withFileTypes: true })
|
|
911
|
+
} catch {
|
|
912
|
+
continue
|
|
913
|
+
}
|
|
914
|
+
for (const entry of entries) {
|
|
915
|
+
if (!entry.isDirectory()) continue
|
|
916
|
+
const candidate = path.join(directory, entry.name)
|
|
917
|
+
try {
|
|
918
|
+
const info = await lstat(candidate)
|
|
919
|
+
if (info.isDirectory()) scanned.push({ path: candidate, mtimeMs: info.mtimeMs })
|
|
920
|
+
} catch {
|
|
921
|
+
// Removed or unreadable between readdir and lstat; skip it.
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return scanned
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Recursively delete one plugin directory below `baseDir`.
|
|
931
|
+
*
|
|
932
|
+
* The lexical check refuses paths outside `baseDir`; the realpath check then
|
|
933
|
+
* refuses a symlinked ancestor that would let the recursive delete escape
|
|
934
|
+
* the plugin root. A missing directory is already effectively removed.
|
|
935
|
+
*/
|
|
936
|
+
private async deleteDirectoryInsideBase(baseDir: string, target: string): Promise<void> {
|
|
937
|
+
const resolved = path.resolve(target)
|
|
938
|
+
if (!isPathInside(baseDir, resolved)) {
|
|
939
|
+
throw new Error(`refusing to delete a path outside baseDir (${resolved})`)
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(resolved)])
|
|
943
|
+
if (!isPathInside(physicalBase, physicalTarget)) {
|
|
944
|
+
throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
|
|
945
|
+
}
|
|
946
|
+
} catch (error) {
|
|
947
|
+
if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
|
|
948
|
+
// A missing directory is already effectively removed (rm is idempotent).
|
|
949
|
+
}
|
|
950
|
+
await rm(resolved, { recursive: true, force: true })
|
|
951
|
+
}
|
|
952
|
+
|
|
878
953
|
/**
|
|
879
954
|
* (Re)arm the daily retention timer to match the live config. `runNow`
|
|
880
955
|
* performs one pass immediately; it is only used at startup so saving any
|
|
@@ -890,7 +965,7 @@ export class FeishuService {
|
|
|
890
965
|
|| retentionMs(this.config.workspaceRetention) === undefined) return
|
|
891
966
|
const sweep = (): void => {
|
|
892
967
|
void this.runWorkspaceCleanup().then(result => {
|
|
893
|
-
if (!result.ok && result.error !== '
|
|
968
|
+
if (!result.ok && result.error !== '目录清理正在进行中,请稍候') {
|
|
894
969
|
this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
|
|
895
970
|
}
|
|
896
971
|
})
|
|
@@ -1724,7 +1799,7 @@ export class FeishuService {
|
|
|
1724
1799
|
await this.delivery.standaloneReply(msg, buildRenewCard({
|
|
1725
1800
|
previousSessionId: renewAdmission.commit.previous.sessionId,
|
|
1726
1801
|
currentSessionId: renewAdmission.commit.current.sessionId,
|
|
1727
|
-
|
|
1802
|
+
sessionDir: sessionDirOf(renewAdmission.commit.current),
|
|
1728
1803
|
}))
|
|
1729
1804
|
return
|
|
1730
1805
|
}
|
|
@@ -1744,7 +1819,7 @@ export class FeishuService {
|
|
|
1744
1819
|
context,
|
|
1745
1820
|
extras: {
|
|
1746
1821
|
permissionPreset: this.permissionPresetOf(session),
|
|
1747
|
-
|
|
1822
|
+
sessionDir: sessionDirOf(current),
|
|
1748
1823
|
uptimeMs: this.startedAt === undefined ? undefined : Date.now() - this.startedAt,
|
|
1749
1824
|
liveAgents: this.sessions.liveCount(),
|
|
1750
1825
|
routeCount: this.routes.size(),
|
|
@@ -1753,7 +1828,10 @@ export class FeishuService {
|
|
|
1753
1828
|
return
|
|
1754
1829
|
}
|
|
1755
1830
|
if (spec.name === 'workspace' && result.kind === 'success') {
|
|
1756
|
-
const resolved = resolveWorkDir(live.agent.session.header.cwd,
|
|
1831
|
+
const resolved = resolveWorkDir(live.agent.session.header.cwd, {
|
|
1832
|
+
workspacePath: current.workspacePath,
|
|
1833
|
+
...(current.sessionDir === undefined ? {} : { sessionDir: current.sessionDir }),
|
|
1834
|
+
})
|
|
1757
1835
|
if (resolved.kind === 'success') {
|
|
1758
1836
|
await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path))
|
|
1759
1837
|
return
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-topic Session placement: which directory each Feishu conversation works
|
|
3
|
+
* in, without growing the host workspace list with every topic.
|
|
4
|
+
*
|
|
5
|
+
* A Session's **header cwd** is fixed at creation and decides two things:
|
|
6
|
+
* its workspace-registry membership (the GUI project it is grouped under) and
|
|
7
|
+
* the sandbox write boundary. The **effective working directory** is a mutable
|
|
8
|
+
* Session projection (`ctx.workingDirectory`), persisted in the Session log
|
|
9
|
+
* and restored on resume; the bash/fs/terminal tools resolve it before every
|
|
10
|
+
* call.
|
|
11
|
+
*
|
|
12
|
+
* This module plans one topic's placement:
|
|
13
|
+
*
|
|
14
|
+
* - `subdir`: `<workspace>/topics/<token>` — a plain directory switched to via
|
|
15
|
+
* `workingDirectory.set`.
|
|
16
|
+
* - `worktree`: a Git worktree created through `ctx.worktrees` (branch
|
|
17
|
+
* `feishu-<token>`), used when the workspace is a Git repository or when the
|
|
18
|
+
* deployment forces it.
|
|
19
|
+
*
|
|
20
|
+
* Both modes keep the Session's header cwd on the workspace root, so the
|
|
21
|
+
* Session stays grouped under the user's project. When the host exposes no
|
|
22
|
+
* `workingDirectory` service (unusual: it ships with `dsh-base`), the Session
|
|
23
|
+
* is created directly in the topic directory instead — file isolation still
|
|
24
|
+
* holds, but the Session can no longer be attached to the workspace.
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-channels/session/isolation
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomUUID } from 'node:crypto'
|
|
30
|
+
import { mkdir, stat } from 'node:fs/promises'
|
|
31
|
+
import path from 'node:path'
|
|
32
|
+
import type { Agent, HostContext, Session } from '../dsh-types.ts'
|
|
33
|
+
import type { FeishuConfig } from '../config.ts'
|
|
34
|
+
import { topicDirFor, worktreeBranchFor } from '../workspaces.ts'
|
|
35
|
+
|
|
36
|
+
// ---- Host service slices (structural, resolved through ctx.get) ----------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The effective-directory service (`@deepseek-ai/dsh-working-directory`,
|
|
40
|
+
* mounted by every base-backed profile). `set` appends a durable
|
|
41
|
+
* `working-directory/change` event and queues a user-context notice.
|
|
42
|
+
*/
|
|
43
|
+
export interface WorkingDirectoryService {
|
|
44
|
+
get(session: Session): string
|
|
45
|
+
set(agent: Agent, path: string, signal?: AbortSignal): Promise<string>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One created Git worktree checkout (`ctx.worktrees.create` result). */
|
|
49
|
+
export interface CreatedWorktree {
|
|
50
|
+
path: string
|
|
51
|
+
branch: string
|
|
52
|
+
baseCommit: string
|
|
53
|
+
repositoryRoot: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The optional Git-worktree service (`@deepseek-ai/dsh-experimental-worktree`).
|
|
58
|
+
* It is not mounted everywhere, so every use is guarded.
|
|
59
|
+
*/
|
|
60
|
+
export interface WorktreeService {
|
|
61
|
+
create(
|
|
62
|
+
agent: Agent,
|
|
63
|
+
request?: { name?: string; from?: string },
|
|
64
|
+
signal?: AbortSignal,
|
|
65
|
+
): Promise<CreatedWorktree>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Placement capabilities resolved once per call site. */
|
|
69
|
+
export interface PlacementServices {
|
|
70
|
+
workingDirectory?: WorkingDirectoryService
|
|
71
|
+
worktrees?: WorktreeService
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolve the placement capabilities from the host context (all optional). */
|
|
75
|
+
export function placementServices(ctx: HostContext): PlacementServices {
|
|
76
|
+
const get = <T>(name: string): T | undefined => ctx.get?.(name) as T | undefined
|
|
77
|
+
return {
|
|
78
|
+
workingDirectory: get<WorkingDirectoryService>('workingDirectory'),
|
|
79
|
+
worktrees: get<WorktreeService>('worktrees'),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Isolation mode actually used for one topic. */
|
|
84
|
+
export type TopicMode = 'subdir' | 'worktree'
|
|
85
|
+
|
|
86
|
+
/** What the caller needs before creating the Session. */
|
|
87
|
+
export interface TopicPlan {
|
|
88
|
+
/** Isolation mode chosen for this topic. */
|
|
89
|
+
readonly mode: TopicMode
|
|
90
|
+
/** Directory to pass as `meta.cwd` (the Session header cwd). */
|
|
91
|
+
readonly cwd: string
|
|
92
|
+
/** Topic directory for `subdir` mode (already created). */
|
|
93
|
+
readonly subdir?: string
|
|
94
|
+
/** Whether the deployment can switch the Session's working directory. */
|
|
95
|
+
readonly switchable: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Where one topic's Session actually works. */
|
|
99
|
+
export interface TopicPlacement {
|
|
100
|
+
/** Effective working directory (`working-directory` projection value). */
|
|
101
|
+
readonly sessionDir: string
|
|
102
|
+
/** Git branch owning `sessionDir` when it is a worktree checkout. */
|
|
103
|
+
readonly worktreeBranch?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Structured logger slice; best-effort warnings never throw. */
|
|
107
|
+
export type PlacementWarn = (message: string, ...args: unknown[]) => void
|
|
108
|
+
|
|
109
|
+
/** Whether the workspace directory is itself a Git working tree. */
|
|
110
|
+
export async function workspaceHasGitRepository(workspacePath: string): Promise<boolean> {
|
|
111
|
+
try {
|
|
112
|
+
const info = await stat(path.join(workspacePath, '.git'))
|
|
113
|
+
return info.isDirectory() || info.isFile()
|
|
114
|
+
} catch {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pick the isolation mode for one topic. `auto` upgrades to a worktree only
|
|
121
|
+
* when both the capability and a Git repository are present; an explicit
|
|
122
|
+
* `worktree` preference still falls back (with a warning) when unavailable.
|
|
123
|
+
*/
|
|
124
|
+
async function chooseTopicMode(
|
|
125
|
+
services: PlacementServices,
|
|
126
|
+
config: FeishuConfig,
|
|
127
|
+
workspacePath: string,
|
|
128
|
+
warn: PlacementWarn,
|
|
129
|
+
): Promise<TopicMode> {
|
|
130
|
+
const requested = config.topicIsolation
|
|
131
|
+
if (requested === 'subdir') return 'subdir'
|
|
132
|
+
const capable = services.workingDirectory !== undefined && services.worktrees !== undefined
|
|
133
|
+
if (!capable) {
|
|
134
|
+
if (requested === 'worktree') {
|
|
135
|
+
warn('dsh-channels: topicIsolation=worktree needs the workingDirectory and worktrees host services; using a plain topic directory')
|
|
136
|
+
}
|
|
137
|
+
return 'subdir'
|
|
138
|
+
}
|
|
139
|
+
if (requested === 'worktree') return 'worktree'
|
|
140
|
+
return await workspaceHasGitRepository(workspacePath) ? 'worktree' : 'subdir'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Decide how a new Session is placed and materialize everything that must
|
|
145
|
+
* exist before the Session is created.
|
|
146
|
+
*
|
|
147
|
+
* The returned `cwd` is the header cwd: the workspace root when the runtime
|
|
148
|
+
* can switch directories (so the Session is grouped under the user's project),
|
|
149
|
+
* otherwise the topic directory itself.
|
|
150
|
+
*/
|
|
151
|
+
export async function planTopic(
|
|
152
|
+
services: PlacementServices,
|
|
153
|
+
config: FeishuConfig,
|
|
154
|
+
workspacePath: string,
|
|
155
|
+
topicToken: string,
|
|
156
|
+
warn: PlacementWarn,
|
|
157
|
+
): Promise<TopicPlan> {
|
|
158
|
+
const mode = await chooseTopicMode(services, config, workspacePath, warn)
|
|
159
|
+
if (services.workingDirectory !== undefined) {
|
|
160
|
+
const subdir = mode === 'subdir' ? topicDirFor(workspacePath, topicToken) : undefined
|
|
161
|
+
if (subdir !== undefined) await mkdir(subdir, { recursive: true })
|
|
162
|
+
return {
|
|
163
|
+
mode,
|
|
164
|
+
cwd: workspacePath,
|
|
165
|
+
...(subdir === undefined ? {} : { subdir }),
|
|
166
|
+
switchable: true,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// No effective-directory service: create the Session inside the topic
|
|
170
|
+
// directory itself. Isolation is preserved; workspace membership is lost.
|
|
171
|
+
const subdir = topicDirFor(workspacePath, topicToken)
|
|
172
|
+
await mkdir(subdir, { recursive: true })
|
|
173
|
+
return { mode: 'subdir', cwd: subdir, subdir, switchable: false }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Enter the planned topic directory once the Session (and its Agent) exists. */
|
|
177
|
+
export async function enterTopic(
|
|
178
|
+
services: PlacementServices,
|
|
179
|
+
agent: Agent,
|
|
180
|
+
plan: TopicPlan,
|
|
181
|
+
workspacePath: string,
|
|
182
|
+
topicToken: string,
|
|
183
|
+
warn: PlacementWarn,
|
|
184
|
+
): Promise<TopicPlacement> {
|
|
185
|
+
if (plan.mode === 'worktree' && services.worktrees !== undefined) {
|
|
186
|
+
try {
|
|
187
|
+
const created = await services.worktrees.create(agent, { name: worktreeBranchFor(topicToken) })
|
|
188
|
+
return { sessionDir: created.path, worktreeBranch: created.branch }
|
|
189
|
+
} catch (error) {
|
|
190
|
+
warn(
|
|
191
|
+
'dsh-channels: creating a worktree for topic %s failed, using a plain topic directory instead: %s',
|
|
192
|
+
topicToken, String(error),
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const dir = plan.subdir ?? topicDirFor(workspacePath, topicToken)
|
|
197
|
+
await mkdir(dir, { recursive: true })
|
|
198
|
+
if (plan.switchable) await alignWorkingDirectory(services, agent, dir, warn)
|
|
199
|
+
return { sessionDir: dir }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Align one live Session with its recorded topic directory: make sure the
|
|
204
|
+
* directory exists, recreate it (or a fresh worktree) when it disappeared,
|
|
205
|
+
* and restore the effective working directory the route expects.
|
|
206
|
+
*
|
|
207
|
+
* The returned placement may carry a new directory/branch after recovery; the
|
|
208
|
+
* caller persists it back into the route record.
|
|
209
|
+
*/
|
|
210
|
+
export async function restoreTopicDir(
|
|
211
|
+
services: PlacementServices,
|
|
212
|
+
agent: Agent,
|
|
213
|
+
input: {
|
|
214
|
+
readonly workspacePath: string
|
|
215
|
+
readonly sessionDir: string
|
|
216
|
+
readonly worktreeBranch?: string
|
|
217
|
+
},
|
|
218
|
+
warn: PlacementWarn,
|
|
219
|
+
): Promise<TopicPlacement> {
|
|
220
|
+
const { sessionDir, worktreeBranch } = input
|
|
221
|
+
if (await isDirectory(sessionDir)) {
|
|
222
|
+
await alignWorkingDirectory(services, agent, sessionDir, warn)
|
|
223
|
+
return worktreeBranch === undefined ? { sessionDir } : { sessionDir, worktreeBranch }
|
|
224
|
+
}
|
|
225
|
+
// The directory is gone (manual deletion, external cleanup, lost host):
|
|
226
|
+
// give the Session a fresh directory so it never silently falls back to the
|
|
227
|
+
// shared workspace root.
|
|
228
|
+
if (worktreeBranch !== undefined
|
|
229
|
+
&& services.worktrees !== undefined
|
|
230
|
+
&& services.workingDirectory !== undefined) {
|
|
231
|
+
try {
|
|
232
|
+
const created = await services.worktrees.create(agent, {
|
|
233
|
+
name: `${worktreeBranch}-r${randomUUID().slice(0, 8)}`,
|
|
234
|
+
})
|
|
235
|
+
return { sessionDir: created.path, worktreeBranch: created.branch }
|
|
236
|
+
} catch (error) {
|
|
237
|
+
warn(
|
|
238
|
+
'dsh-channels: recreating the worktree for session %s failed, using a plain directory: %s',
|
|
239
|
+
agent.id, String(error),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await mkdir(sessionDir, { recursive: true })
|
|
244
|
+
await alignWorkingDirectory(services, agent, sessionDir, warn)
|
|
245
|
+
return { sessionDir }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Best-effort check used only for placement decisions, never for security. */
|
|
249
|
+
async function isDirectory(dir: string): Promise<boolean> {
|
|
250
|
+
try {
|
|
251
|
+
return (await stat(dir)).isDirectory()
|
|
252
|
+
} catch {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Point the Session's effective working directory at `dir`. */
|
|
258
|
+
async function alignWorkingDirectory(
|
|
259
|
+
services: PlacementServices,
|
|
260
|
+
agent: Agent,
|
|
261
|
+
dir: string,
|
|
262
|
+
warn: PlacementWarn,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
const workingDirectory = services.workingDirectory
|
|
265
|
+
if (workingDirectory === undefined) return
|
|
266
|
+
try {
|
|
267
|
+
if (workingDirectory.get(agent.session) === dir) return
|
|
268
|
+
await workingDirectory.set(agent, dir)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// The durable Session keeps its previous directory; the caller cannot do
|
|
271
|
+
// better than warning because the turn is still deliverable.
|
|
272
|
+
warn(
|
|
273
|
+
'dsh-channels: switching session %s to its topic directory failed: %s',
|
|
274
|
+
agent.id, String(error),
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
}
|