dsh-bot-team 1.1.0 → 1.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.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Match @deepseek-ai/dsh-session-log-export's canonical JSONL format.
3
+ * Call only with the committed header/events from readBotSessionLog, never a
4
+ * prepared sessionQuery observation (which can append in-memory repair events).
5
+ * The header schema belongs to the native log format; event payloads are opaque.
6
+ */
7
+ export function serializeBotSessionLog(header, events) {
8
+ const lines = [JSON.stringify({
9
+ type: 'session',
10
+ version: header.version,
11
+ id: header.id,
12
+ createdAt: header.createdAt,
13
+ ...(header.cwd !== undefined ? { cwd: header.cwd } : {}),
14
+ ...(header.parentSession !== undefined ? { parentSession: header.parentSession } : {}),
15
+ isSeeded: header.isSeeded,
16
+ ...(header.origin !== undefined ? { origin: header.origin } : {}),
17
+ delegationDepth: header.delegationDepth ?? 0,
18
+ ...(header.agentPreset !== undefined ? { agentPreset: header.agentPreset } : {}),
19
+ })];
20
+ for (const event of events) lines.push(JSON.stringify(event));
21
+ return lines.join('\n') + '\n';
22
+ }
@@ -0,0 +1,147 @@
1
+ import { inspectSessionHeader } from './native-session.js';
2
+
3
+ const isSessionId = value => typeof value === 'string' && value.length > 0;
4
+
5
+ /**
6
+ * One-way migration of ordinary Sessions already owned by Bot Team.
7
+ * New background/subagent Sessions are never archived. The workspace API owns
8
+ * activity checks; omitting stopActivity is essential to preserve running work.
9
+ */
10
+ export class BotSessionVisibility {
11
+ constructor({ ctx, repo, logger, isBusy = () => false }) {
12
+ this.ctx = ctx;
13
+ this.repo = repo;
14
+ this.logger = logger;
15
+ this.isBusy = isBusy;
16
+ this.disposed = false;
17
+ // Ownership survives group/member removal once observed in the repository.
18
+ // Never populate this cache from global Session lists or archive responses.
19
+ this.owned = new Set();
20
+ this.completed = new Map();
21
+ this.busy = new Set();
22
+ this.inFlight = new Map();
23
+ this.activityRevision = new Map();
24
+ this.reportedErrors = new Map();
25
+ this.off = [
26
+ ctx.on('session/event', (session, event) => {
27
+ if (event?.type === 'turn/end' ||
28
+ (event?.type === 'goal/change' && event.data?.goal?.phase && event.data.goal.phase !== 'active')) {
29
+ return this.retryAfterActivity(session?.id);
30
+ }
31
+ }, { global: true }),
32
+ // turn/end is appended while the native loop still reports running.
33
+ ctx.on('agent/status', ({ agent, status }) => status === 'idle' ? this.retryAfterActivity(agent?.id) : undefined, { global: true }),
34
+ ctx.on('agent/disposed', ({ agent }) => this.retryAfterActivity(agent?.id), { global: true }),
35
+ ctx.on('goal/activation-changed', ({ sessionId, goal }) => {
36
+ // rc.3 emits only id/revision/activation here, not the goal phase.
37
+ if (!goal || goal.activation === 'disarmed') return this.retryAfterActivity(sessionId);
38
+ }, { global: true }),
39
+ ];
40
+ this.ready = this.reconcile();
41
+ }
42
+
43
+ collectOwnership() {
44
+ const add = id => { if (isSessionId(id)) this.owned.add(id); };
45
+ for (const binding of this.repo.sessionBindings?.values() || []) add(binding.sessionId);
46
+ for (const member of this.repo.members?.values() || []) {
47
+ add(member.sessionId);
48
+ if (Array.isArray(member.previousSessionIds)) for (const id of member.previousSessionIds) add(id);
49
+ }
50
+ for (const message of this.repo.messages?.values() || []) {
51
+ if (message.author?.type === 'bot') add(message.meta?.execution?.sessionId);
52
+ }
53
+ }
54
+
55
+ /** Refresh repository ownership; errors are returned and logged, never retried by timers. */
56
+ async reconcile() {
57
+ if (this.disposed) return [];
58
+ this.collectOwnership();
59
+ return Promise.all([...this.owned].map(sessionId => this.run(sessionId)));
60
+ }
61
+
62
+ retryAfterActivity(sessionId) {
63
+ if (this.disposed || !this.owned.has(sessionId) || this.completed.has(sessionId)) return;
64
+ // Remember a completion that races an in-flight busy response, but do not
65
+ // start work for arbitrary tracked IDs that have never been known busy.
66
+ if (this.inFlight.has(sessionId)) {
67
+ this.activityRevision.set(sessionId, (this.activityRevision.get(sessionId) || 0) + 1);
68
+ return this.inFlight.get(sessionId);
69
+ }
70
+ if (this.busy.has(sessionId)) return this.run(sessionId);
71
+ }
72
+
73
+ run(sessionId) {
74
+ if (this.disposed || !this.owned.has(sessionId)) return Promise.resolve({ sessionId, status: 'disposed' });
75
+ if (this.completed.has(sessionId)) return Promise.resolve({ sessionId, status: this.completed.get(sessionId) });
76
+ const existing = this.inFlight.get(sessionId);
77
+ if (existing) return existing;
78
+ // Start on a microtask so synchronous lifecycle notifications and concurrent
79
+ // reconcile calls share this entry before observation begins.
80
+ const pending = Promise.resolve().then(async () => {
81
+ let result;
82
+ do {
83
+ const revision = this.activityRevision.get(sessionId) || 0;
84
+ this.busy.delete(sessionId);
85
+ result = await this.archiveIdleOrdinary(sessionId);
86
+ if (result.status !== 'busy' || this.disposed || revision === (this.activityRevision.get(sessionId) || 0)) break;
87
+ } while (true);
88
+ return result;
89
+ }).finally(() => this.inFlight.delete(sessionId));
90
+ this.inFlight.set(sessionId, pending);
91
+ return pending;
92
+ }
93
+
94
+ async archiveIdleOrdinary(sessionId) {
95
+ const result = status => ({ sessionId, status });
96
+ if (this.disposed || !this.owned.has(sessionId)) return result('disposed');
97
+ try {
98
+ const header = await inspectSessionHeader(this.ctx, sessionId);
99
+ if (this.disposed || !this.owned.has(sessionId)) return result('disposed');
100
+ if (!header) return result('missing');
101
+ if (header.origin === 'subagent') {
102
+ this.completed.set(sessionId, 'subagent');
103
+ return result('subagent');
104
+ }
105
+ const agent = this.ctx.agents?.get?.(sessionId);
106
+ const goal = agent ? this.ctx.get?.('goals')?.get(agent) : undefined;
107
+ if (this.isBusy(sessionId) || agent?.status === 'running' ||
108
+ (goal?.phase === 'active' && goal.activation === 'armed')) {
109
+ this.busy.add(sessionId);
110
+ return result('busy');
111
+ }
112
+ const controller = this.ctx.get?.('workspaceController') || this.ctx.workspaceController;
113
+ if (typeof controller?.archiveSession !== 'function') throw new Error('Bot Team requires workspaceController.archiveSession for ordinary Session migration');
114
+ // Recheck after the asynchronous observation and immediately before the
115
+ // mutation. A removed group is still covered by the trusted ownership cache.
116
+ if (this.disposed || !this.owned.has(sessionId)) return result('disposed');
117
+ const archived = await controller.archiveSession({ sessionId });
118
+ if (!Array.isArray(archived?.archivedSessionIds) || !archived.archivedSessionIds.includes(sessionId)) {
119
+ throw new Error('Workspace archive response did not include the owned Bot Session');
120
+ }
121
+ this.completed.set(sessionId, 'archived');
122
+ this.reportedErrors.delete(sessionId);
123
+ return result('archived');
124
+ } catch (error) {
125
+ if (error?.code === 'workspace/session-active') {
126
+ if (!this.disposed) this.busy.add(sessionId);
127
+ return result('busy');
128
+ }
129
+ if (error?.code === 'session/not-found' || error?.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return result('missing');
130
+ const description = String(error?.message || error);
131
+ const key = String(error?.code || '') + ':' + description;
132
+ if (this.reportedErrors.get(sessionId) !== key) {
133
+ this.reportedErrors.set(sessionId, key);
134
+ this.logger?.warn?.('bot-team: ordinary Session archive failed for ' + sessionId + ': ' + description);
135
+ }
136
+ return { ...result('error'), error };
137
+ }
138
+ }
139
+
140
+ /** Detach observation and await already-started calls; never stop native work. */
141
+ async dispose() {
142
+ this.disposed = true;
143
+ for (const off of this.off.splice(0)) off();
144
+ this.busy.clear();
145
+ await Promise.allSettled([...this.inFlight.values()]);
146
+ }
147
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * DSH 0.2 JSON storage accepts underscores in unit names, but not hyphens.
3
+ * Keep the legal unit name and relocate its physical directory, leaving a
4
+ * directory link for the backend (and older plugin versions) to follow.
5
+ */
6
+ import path from 'node:path';
7
+ import { lstat, mkdir, readlink, rename, symlink } from 'node:fs/promises';
8
+
9
+ export const STORAGE_DIRECTORY_NAME = 'dsh-bot-team';
10
+
11
+ async function inspect(target) {
12
+ try {
13
+ return await lstat(target);
14
+ } catch (error) {
15
+ if (error.code === 'ENOENT') return null;
16
+ throw error;
17
+ }
18
+ }
19
+
20
+ /** Runs only while the storage domain is closed. Never merges two stores. */
21
+ export async function relocateStorageDirectory(legacyDirectory) {
22
+ const legacy = path.resolve(legacyDirectory);
23
+ if (path.basename(legacy) !== 'bot_team') {
24
+ throw new Error(`bot-team: unexpected storage directory: ${legacy}`);
25
+ }
26
+ const root = path.dirname(legacy);
27
+ const target = path.join(root, STORAGE_DIRECTORY_NAME);
28
+ const [sourceInfo, targetInfo] = await Promise.all([inspect(legacy), inspect(target)]);
29
+ if (targetInfo && !targetInfo.isDirectory()) {
30
+ throw new Error(`bot-team: storage destination must be a real directory: ${target}`);
31
+ }
32
+ if (sourceInfo?.isSymbolicLink()) {
33
+ const destination = path.resolve(root, await readlink(legacy));
34
+ if (destination !== target || !targetInfo) {
35
+ throw new Error(`bot-team: unexpected or broken storage link: ${legacy} -> ${destination}`);
36
+ }
37
+ return target;
38
+ }
39
+ if (sourceInfo && !sourceInfo.isDirectory()) {
40
+ throw new Error(`bot-team: storage source is not a directory: ${legacy}`);
41
+ }
42
+ if (sourceInfo && targetInfo) {
43
+ throw new Error(`bot-team: both storage directories exist; resolve the conflict before enabling the plugin: ${legacy}, ${target}`);
44
+ }
45
+
46
+ if (sourceInfo) {
47
+ // Both paths are validated siblings; rename preserves all records and metadata.
48
+ await rename(legacy, target);
49
+ } else {
50
+ // Also resumes an interrupted migration that moved the directory already.
51
+ await mkdir(target, { recursive: true });
52
+ }
53
+ try {
54
+ // Windows junctions need an absolute target; POSIX links stay relocatable.
55
+ await symlink(process.platform === 'win32' ? target : STORAGE_DIRECTORY_NAME, legacy, process.platform === 'win32' ? 'junction' : 'dir');
56
+ } catch (error) {
57
+ // Restore the original location if creating the compatibility link failed.
58
+ // Never overwrite a source directory recreated by another process.
59
+ if (sourceInfo && !(await inspect(legacy))) {
60
+ try {
61
+ await rename(target, legacy);
62
+ } catch (rollbackError) {
63
+ throw new AggregateError([error, rollbackError], `bot-team: storage link creation and rollback failed; data remains at ${target}`);
64
+ }
65
+ }
66
+ throw error;
67
+ }
68
+ return target;
69
+ }
70
+
71
+ /**
72
+ * Isolate the installed DSH 0.2 JSON adapter's implementation-specific fields.
73
+ * The public domain API has no directory getter: resolve its configured route
74
+ * and require a recognized JSON backend instead of guessing DSH_HOME. This must
75
+ * precede open(), whose legacy single-file bootstrap can itself write records.
76
+ */
77
+ export async function openPluginStorageDomain(storageDomain, spec, logger) {
78
+ const unsupported = () => new Error('bot-team: cannot resolve the JSON storage directory; this layout requires the DSH 0.2 per-record JSON backend');
79
+ const backendName = storageDomain.config?.routes?.[spec.name] ?? storageDomain.config?.backend;
80
+ const registry = storageDomain.ctx?.storage?.backend;
81
+ if (spec.name !== 'bot_team' || spec.layout !== 'per-record'
82
+ || typeof backendName !== 'string' || !backendName
83
+ || typeof registry?.get !== 'function' || typeof storageDomain.get !== 'function') {
84
+ throw unsupported();
85
+ }
86
+ const backend = registry.get(backendName);
87
+ if (backend?.constructor?.name !== 'JsonStorageBackend'
88
+ || typeof backend.root !== 'string' || !path.isAbsolute(backend.root)) {
89
+ throw unsupported();
90
+ }
91
+ // The previous plugin instance must finish close() and its write chain first.
92
+ // Do not close a domain owned by another still-active instance.
93
+ if (storageDomain.get(spec.name)) {
94
+ throw new Error('bot-team: storage domain is still open; disable the previous plugin instance before relocating its data');
95
+ }
96
+ const legacyDirectory = path.join(backend.root, spec.name);
97
+ const directory = await relocateStorageDirectory(legacyDirectory);
98
+ const domain = await storageDomain.open(spec);
99
+ const unit = domain.unit;
100
+ if (unit?.constructor?.name !== 'PerRecordJsonUnit'
101
+ || unit.descriptor?.name !== spec.name
102
+ || unit.descriptor?.layout !== 'per-record'
103
+ || unit.dir !== legacyDirectory) {
104
+ await domain.close();
105
+ throw unsupported();
106
+ }
107
+ logger?.info?.(`bot-team: storage directory ${directory}`);
108
+ return domain;
109
+ }