@myagentroam/node 0.11.0 → 0.11.2
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/dist/connector.js +20 -25
- package/dist/database.js +53 -31
- package/dist/migrations/v021.js +64 -0
- package/dist/service/node-run-event-bridge.js +0 -113
- package/dist/service/node-workspace-coordinator.js +21 -3
- package/dist/service/scheduled-session-coordinator.js +10 -12
- package/dist/service/skill-directory-service.js +2 -2
- package/dist/service/skill-install-service.js +15 -7
- package/dist/service/skill-node-operation-service.js +5 -4
- package/dist/service/workbench-event-projection.js +112 -0
- package/dist/service/workbench-event-service.js +24 -7
- package/dist/service/workspace-change-service.js +29 -13
- package/dist/service/workspace-file-service.js +3 -1
- package/dist/service/workspace-git-exclude-service.js +59 -32
- package/dist/service/workspace-service.js +34 -9
- package/dist/service/workspace-watch-snapshot-service.js +35 -5
- package/dist/service/workspace-workbench-service.js +23 -11
- package/dist/terminal.js +13 -12
- package/dist/workspace.js +27 -3
- package/package.json +4 -4
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { conversationSegmentIdentityForItem, conversationSegments } from './conversation-segment-service.js';
|
|
2
|
+
import { runConversationItemId } from '../runtime-state.js';
|
|
3
|
+
import { isNodeAgentRun, isNodeConversationTurn, isPlainRecord } from '../runner/codex/conversation-parser.js';
|
|
4
|
+
export function workbenchPayloadSessionId(getRun, category, payload) {
|
|
5
|
+
if (!isPlainRecord(payload))
|
|
6
|
+
return undefined;
|
|
7
|
+
if (category === 'session') {
|
|
8
|
+
if (isPlainRecord(payload.session)) {
|
|
9
|
+
const id = payload.session.id;
|
|
10
|
+
if (typeof id === 'string')
|
|
11
|
+
return id;
|
|
12
|
+
}
|
|
13
|
+
return typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
|
|
14
|
+
}
|
|
15
|
+
if (category === 'run' && isNodeAgentRun(payload.run))
|
|
16
|
+
return payload.run.sessionId;
|
|
17
|
+
if (category === 'conversation') {
|
|
18
|
+
if (isNodeConversationTurn(payload.turn))
|
|
19
|
+
return payload.turn.sessionId;
|
|
20
|
+
if (typeof payload.sessionId === 'string')
|
|
21
|
+
return payload.sessionId;
|
|
22
|
+
if (isPlainRecord(payload.event)) {
|
|
23
|
+
const runId = payload.event.runId;
|
|
24
|
+
return typeof runId === 'string' ? getRun(runId)?.sessionId : undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (category === 'queue') {
|
|
28
|
+
const id = payload.sessionId;
|
|
29
|
+
return typeof id === 'string' ? id : undefined;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
/** Projects normalized runtime facts without publishing or mutating them. */
|
|
34
|
+
export function projectWorkbenchPayload(queries, category, payload) {
|
|
35
|
+
if (category === 'conversation' &&
|
|
36
|
+
isPlainRecord(payload) &&
|
|
37
|
+
isPlainRecord(payload.event) &&
|
|
38
|
+
payload.event.eventType !== 'notification') {
|
|
39
|
+
const runId = typeof payload.event.runId === 'string' ? payload.event.runId : undefined;
|
|
40
|
+
const sequence = typeof payload.event.sequence === 'number' ? payload.event.sequence : undefined;
|
|
41
|
+
const turn = runId === undefined ? undefined : queries.conversationTurnForRun(runId);
|
|
42
|
+
if (turn === undefined)
|
|
43
|
+
return;
|
|
44
|
+
const rawPayload = isPlainRecord(payload.event.payload) ? payload.event.payload : undefined;
|
|
45
|
+
const text = payload.event.eventType === 'text.delta' && typeof rawPayload?.text === 'string'
|
|
46
|
+
? rawPayload.text
|
|
47
|
+
: undefined;
|
|
48
|
+
const nativeItemId = typeof rawPayload?.itemId === 'string'
|
|
49
|
+
? rawPayload.itemId
|
|
50
|
+
: typeof rawPayload?.approvalId === 'string'
|
|
51
|
+
? `approval:${rawPayload.approvalId}`
|
|
52
|
+
: undefined;
|
|
53
|
+
const item = text !== undefined && sequence !== undefined
|
|
54
|
+
? turn.items.find((candidate) => candidate.kind === 'assistant_message' && candidate.revision === sequence)
|
|
55
|
+
: nativeItemId === undefined
|
|
56
|
+
? undefined
|
|
57
|
+
: turn.items.find((candidate) => candidate.id ===
|
|
58
|
+
runConversationItemId(runId, nativeItemId, typeof rawPayload?.kind === 'string' ? rawPayload.kind : undefined));
|
|
59
|
+
if (text !== undefined && item?.kind === 'assistant_message') {
|
|
60
|
+
const segment = conversationSegmentIdentityForItem(turn, item.id, item.kind);
|
|
61
|
+
payload = {
|
|
62
|
+
sessionId: turn.sessionId,
|
|
63
|
+
delta: {
|
|
64
|
+
turnId: turn.id,
|
|
65
|
+
segmentId: segment.id,
|
|
66
|
+
segmentIndex: segment.index,
|
|
67
|
+
runId: turn.runId,
|
|
68
|
+
itemId: item.id,
|
|
69
|
+
text,
|
|
70
|
+
createdAt: typeof payload.event.createdAt === 'number'
|
|
71
|
+
? payload.event.createdAt
|
|
72
|
+
: (queries.now ?? Date.now)()
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
else if (item !== undefined) {
|
|
77
|
+
const segment = conversationSegmentIdentityForItem(turn, item.id, item.kind);
|
|
78
|
+
payload = {
|
|
79
|
+
sessionId: turn.sessionId,
|
|
80
|
+
runId: turn.runId,
|
|
81
|
+
...(['assistant_message', 'tool_call', 'command_execution', 'file_change'].includes(item.kind) &&
|
|
82
|
+
(rawPayload?.append === undefined || item.status !== 'IN_PROGRESS')
|
|
83
|
+
? { progress: queries.progress(turn.sessionId) }
|
|
84
|
+
: {}),
|
|
85
|
+
item: {
|
|
86
|
+
turnId: turn.id,
|
|
87
|
+
itemId: item.id,
|
|
88
|
+
kind: item.kind,
|
|
89
|
+
status: item.status,
|
|
90
|
+
payload: item.payload,
|
|
91
|
+
startedAt: item.startedAt,
|
|
92
|
+
completedAt: item.completedAt,
|
|
93
|
+
segmentId: segment.id,
|
|
94
|
+
segmentIndex: segment.index
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
if (payload.event.eventType !== 'run.started' && payload.event.eventType !== 'run.running')
|
|
100
|
+
return;
|
|
101
|
+
payload = { turn };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (category === 'conversation' && isPlainRecord(payload) && isNodeConversationTurn(payload.turn))
|
|
105
|
+
return { ...payload, segments: conversationSegments(payload.turn) };
|
|
106
|
+
if (category === 'run' && isPlainRecord(payload) && isNodeAgentRun(payload.run))
|
|
107
|
+
return {
|
|
108
|
+
...payload,
|
|
109
|
+
progress: queries.progress(payload.run.sessionId)
|
|
110
|
+
};
|
|
111
|
+
return payload;
|
|
112
|
+
}
|
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
import { createEnvelope } from '@myagentroam/protocol';
|
|
2
|
+
import { isPlainRecord } from '../runner/codex/conversation-parser.js';
|
|
3
|
+
import { projectWorkbenchPayload, workbenchPayloadSessionId } from './workbench-event-projection.js';
|
|
2
4
|
export class WorkbenchEventService {
|
|
3
|
-
|
|
4
|
-
accepts;
|
|
5
|
+
options;
|
|
5
6
|
sequence = 0;
|
|
6
|
-
constructor(
|
|
7
|
-
this.
|
|
8
|
-
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.options = options;
|
|
9
|
+
}
|
|
10
|
+
dispatch(category, payload) {
|
|
11
|
+
payload = projectWorkbenchPayload(this.options.projection, category, payload);
|
|
12
|
+
if (payload === undefined)
|
|
13
|
+
return;
|
|
14
|
+
const event = this.next(category, payload);
|
|
15
|
+
if (event === undefined)
|
|
16
|
+
return;
|
|
17
|
+
const notification = category === 'conversation' &&
|
|
18
|
+
isPlainRecord(payload) &&
|
|
19
|
+
isPlainRecord(payload.event) &&
|
|
20
|
+
payload.event.eventType === 'notification';
|
|
21
|
+
const sessionId = workbenchPayloadSessionId(this.options.projection.getRun, category, payload);
|
|
22
|
+
// A Watch owns pending buffering and observation drops as well as ready delivery.
|
|
23
|
+
if (!notification && sessionId !== undefined && this.options.emitRealtime(sessionId, event))
|
|
24
|
+
return;
|
|
25
|
+
this.publish(event);
|
|
9
26
|
}
|
|
10
27
|
next(category, payload) {
|
|
11
|
-
if (!this.accepts(category, payload))
|
|
28
|
+
if (!this.options.accepts(category, payload))
|
|
12
29
|
return undefined;
|
|
13
30
|
return { sequence: ++this.sequence, category, payload };
|
|
14
31
|
}
|
|
15
32
|
publish(event) {
|
|
16
|
-
this.send(createEnvelope('workbench.event', event));
|
|
33
|
+
this.options.send(createEnvelope('workbench.event', event));
|
|
17
34
|
}
|
|
18
35
|
snapshotSequence() {
|
|
19
36
|
return this.sequence;
|
|
@@ -2,7 +2,7 @@ import { lstat } from 'node:fs/promises';
|
|
|
2
2
|
import { basename, posix, resolve } from 'node:path';
|
|
3
3
|
import { publicErrorMessage } from '@myagentroam/protocol';
|
|
4
4
|
import { nodeLog } from '../operational.js';
|
|
5
|
-
import { GitCommandError, isWorkspaceChangeVisible, listGitWorktrees, listGitSubmodules, readCurrentChangesSummary, resolveGitRepository } from '../workspace.js';
|
|
5
|
+
import { GitCommandError, isWorkspaceChangeVisible, listGitWorktrees, listGitSubmodules, readCurrentChangesSummary, resolveGitRepository, resolveWorkspaceGitRoot } from '../workspace.js';
|
|
6
6
|
const CACHE_TTL_MS = 5_000;
|
|
7
7
|
const REPOSITORY_READ_CONCURRENCY = 4;
|
|
8
8
|
const WORKTREE_READ_CONCURRENCY = 4;
|
|
@@ -13,6 +13,9 @@ export class WorkspaceChangeService {
|
|
|
13
13
|
constructor(state) {
|
|
14
14
|
this.state = state;
|
|
15
15
|
}
|
|
16
|
+
revision(workspaceId) {
|
|
17
|
+
return this.state.changesVersions.get(workspaceId) ?? 0;
|
|
18
|
+
}
|
|
16
19
|
invalidate(workspaceId) {
|
|
17
20
|
this.state.changesReads.delete(workspaceId);
|
|
18
21
|
this.state.changesCache.delete(workspaceId);
|
|
@@ -34,7 +37,9 @@ export class WorkspaceChangeService {
|
|
|
34
37
|
return existing;
|
|
35
38
|
// Invalidation may arrive during this shared read; its old result must not repopulate the cache.
|
|
36
39
|
const version = this.state.changesVersions.get(workspace.id) ?? 0;
|
|
37
|
-
const request = this.state.changesLimiter
|
|
40
|
+
const request = this.state.changesLimiter
|
|
41
|
+
.run(async () => {
|
|
42
|
+
const repositoryRoot = await resolveWorkspaceGitRoot(workspace.path, workspace.includeParentRepository ?? true);
|
|
38
43
|
const startedAt = performance.now();
|
|
39
44
|
const errors = [];
|
|
40
45
|
const failure = (code, error) => ({
|
|
@@ -66,7 +71,7 @@ export class WorkspaceChangeService {
|
|
|
66
71
|
};
|
|
67
72
|
const readRepository = async (repositoryPath, repositoryOverride) => {
|
|
68
73
|
const repository = repositoryOverride ??
|
|
69
|
-
(await resolveGitRepository(
|
|
74
|
+
(await resolveGitRepository(repositoryRoot, repositoryPath, [repositoryPath]));
|
|
70
75
|
if (repositoryPath !== '' || repositoryOverride !== undefined) {
|
|
71
76
|
const marker = await lstat(resolve(repository, '.git'));
|
|
72
77
|
if (marker.isSymbolicLink() || (!marker.isFile() && !marker.isDirectory()))
|
|
@@ -89,19 +94,14 @@ export class WorkspaceChangeService {
|
|
|
89
94
|
changes
|
|
90
95
|
};
|
|
91
96
|
};
|
|
92
|
-
const root = await readRepository('')
|
|
93
|
-
if (error?.code === 'ENOENT' &&
|
|
94
|
-
error.syscall?.startsWith('spawn') === true)
|
|
95
|
-
throw new Error('GIT_NOT_FOUND');
|
|
96
|
-
throw error;
|
|
97
|
-
});
|
|
97
|
+
const root = await readRepository('');
|
|
98
98
|
const children = [];
|
|
99
99
|
let parents = [''];
|
|
100
100
|
let submodulesLimited = false;
|
|
101
101
|
while (parents.length > 0 && !submodulesLimited) {
|
|
102
102
|
const discovered = await mapWithConcurrency(parents, REPOSITORY_READ_CONCURRENCY, async (parent) => {
|
|
103
103
|
try {
|
|
104
|
-
const path = await resolveGitRepository(
|
|
104
|
+
const path = await resolveGitRepository(repositoryRoot, parent, [parent]);
|
|
105
105
|
return (await listGitSubmodules(path)).map((entry) => ({
|
|
106
106
|
...entry,
|
|
107
107
|
path: parent === '' ? entry.path : `${parent}/${entry.path}`
|
|
@@ -152,7 +152,7 @@ export class WorkspaceChangeService {
|
|
|
152
152
|
.map((entry) => entry.repositoryPath);
|
|
153
153
|
}
|
|
154
154
|
children.sort((left, right) => left.repositoryPath.localeCompare(right.repositoryPath));
|
|
155
|
-
const worktreeList = await listGitWorktrees(
|
|
155
|
+
const worktreeList = await listGitWorktrees(repositoryRoot).catch((error) => {
|
|
156
156
|
discoveryFailure('WORKTREE', '', 'GIT_WORKTREE_DISCOVERY_FAILED', error);
|
|
157
157
|
return { entries: [], truncated: false };
|
|
158
158
|
});
|
|
@@ -229,6 +229,13 @@ export class WorkspaceChangeService {
|
|
|
229
229
|
diffAvailable: true
|
|
230
230
|
});
|
|
231
231
|
const value = {
|
|
232
|
+
repositoryRoot,
|
|
233
|
+
workspace: {
|
|
234
|
+
id: workspace.id,
|
|
235
|
+
path: workspace.path,
|
|
236
|
+
kind: 'GIT_WORKSPACE',
|
|
237
|
+
gitSummary: { branch: root.branch, repositoryRoot }
|
|
238
|
+
},
|
|
232
239
|
partial: errors.length > 0 ||
|
|
233
240
|
children.some((group) => group.status === 'UNAVAILABLE') ||
|
|
234
241
|
linkedRepositories.some((group) => group.status === 'UNAVAILABLE') ||
|
|
@@ -240,7 +247,7 @@ export class WorkspaceChangeService {
|
|
|
240
247
|
{
|
|
241
248
|
...root,
|
|
242
249
|
source: 'ROOT',
|
|
243
|
-
physicalPath: currentWorktree?.path ??
|
|
250
|
+
physicalPath: currentWorktree?.path ?? repositoryRoot,
|
|
244
251
|
current: true,
|
|
245
252
|
...(currentWorktree === undefined ? {} : { head: currentWorktree.head }),
|
|
246
253
|
status: root.dirty ? 'DIRTY' : 'CLEAN',
|
|
@@ -267,8 +274,17 @@ export class WorkspaceChangeService {
|
|
|
267
274
|
durationMs: Math.round(performance.now() - startedAt)
|
|
268
275
|
});
|
|
269
276
|
if ((this.state.changesVersions.get(workspace.id) ?? 0) === version)
|
|
270
|
-
this.state.changesCache.set(workspace.id, {
|
|
277
|
+
this.state.changesCache.set(workspace.id, {
|
|
278
|
+
value,
|
|
279
|
+
expiresAt: Date.now() + CACHE_TTL_MS
|
|
280
|
+
});
|
|
271
281
|
return value;
|
|
282
|
+
})
|
|
283
|
+
.catch((error) => {
|
|
284
|
+
if (error?.code === 'ENOENT' &&
|
|
285
|
+
error.syscall?.startsWith('spawn') === true)
|
|
286
|
+
throw new Error('GIT_NOT_FOUND');
|
|
287
|
+
throw error;
|
|
272
288
|
});
|
|
273
289
|
this.state.changesReads.set(workspace.id, request);
|
|
274
290
|
try {
|
|
@@ -176,7 +176,9 @@ export class WorkspaceFileService {
|
|
|
176
176
|
return { size: metadata.size, revision: metadata.sourceRevision };
|
|
177
177
|
}
|
|
178
178
|
async write(workspace, input) {
|
|
179
|
-
if (
|
|
179
|
+
if (input.repositoryPath !== undefined ||
|
|
180
|
+
input.worktreePath !== undefined ||
|
|
181
|
+
typeof input.path !== 'string' ||
|
|
180
182
|
typeof input.content !== 'string' ||
|
|
181
183
|
typeof input.expectedRevision !== 'string')
|
|
182
184
|
throw new Error('FILE_WRITE_INVALID');
|
|
@@ -1,53 +1,91 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
-
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { GitCommandError, resolveWorkspaceGitRoot } from '../workspace.js';
|
|
6
7
|
const MAR_TEMP_DIRECTORY = '/.mar/';
|
|
7
8
|
const BLOCK_BEGIN = '# BEGIN MyAgentRoam';
|
|
8
9
|
const BLOCK_END = '# END MyAgentRoam';
|
|
9
10
|
const LEGACY_BLOCK_BEGIN = '# BEGIN MyAgentRoam Skills';
|
|
10
11
|
const LEGACY_BLOCK_END = '# END MyAgentRoam Skills';
|
|
11
|
-
export async function ensureWorkspaceGitExclude(workspace) {
|
|
12
|
-
await mutateGitExclude(workspace, () => undefined);
|
|
12
|
+
export async function ensureWorkspaceGitExclude(workspace, includeParentRepository = true) {
|
|
13
|
+
await mutateGitExclude(workspace, () => undefined, includeParentRepository);
|
|
13
14
|
}
|
|
14
|
-
export async function updateWorkspaceSkillGitExclude(workspace, name, add) {
|
|
15
|
-
await mutateGitExclude(workspace, (entries) => {
|
|
15
|
+
export async function updateWorkspaceSkillGitExclude(workspace, name, add, includeParentRepository = true) {
|
|
16
|
+
await mutateGitExclude(workspace, (entries, prefix) => {
|
|
16
17
|
for (const root of ['.agents', '.claude']) {
|
|
17
|
-
const line =
|
|
18
|
+
const line = `${prefix}${root}/skills/${name}/`;
|
|
18
19
|
if (add)
|
|
19
20
|
entries.add(line);
|
|
20
21
|
else
|
|
21
22
|
entries.delete(line);
|
|
22
23
|
}
|
|
23
|
-
});
|
|
24
|
+
}, includeParentRepository);
|
|
24
25
|
}
|
|
25
|
-
export async function rewriteWorkspaceGitExclude(workspace, names) {
|
|
26
|
-
await mutateGitExclude(workspace, (entries) => {
|
|
27
|
-
entries
|
|
26
|
+
export async function rewriteWorkspaceGitExclude(workspace, names, includeParentRepository = true) {
|
|
27
|
+
await mutateGitExclude(workspace, (entries, prefix) => {
|
|
28
|
+
for (const entry of entries)
|
|
29
|
+
if (['.agents', '.claude'].some((root) => entry.startsWith(`${prefix}${root}/skills/`)))
|
|
30
|
+
entries.delete(entry);
|
|
28
31
|
for (const name of names)
|
|
29
32
|
for (const root of ['.agents', '.claude'])
|
|
30
|
-
entries.add(
|
|
31
|
-
});
|
|
33
|
+
entries.add(`${prefix}${root}/skills/${name}/`);
|
|
34
|
+
}, includeParentRepository);
|
|
32
35
|
}
|
|
33
|
-
export async function hasBrokenWorkspaceSkillGitExclude(workspace, names) {
|
|
34
|
-
if (!(await exists(path.join(workspace, '.git'))))
|
|
35
|
-
return false;
|
|
36
|
+
export async function hasBrokenWorkspaceSkillGitExclude(workspace, names, includeParentRepository = true) {
|
|
36
37
|
try {
|
|
37
|
-
const
|
|
38
|
+
const target = await gitExcludeTarget(workspace, includeParentRepository);
|
|
39
|
+
if (target === undefined)
|
|
40
|
+
return false;
|
|
41
|
+
const { exclude, prefix } = target;
|
|
38
42
|
const content = await readFile(exclude, 'utf8');
|
|
39
43
|
return names
|
|
40
|
-
.flatMap((name) => [
|
|
44
|
+
.flatMap((name) => [`${prefix}.agents/skills/${name}/`, `${prefix}.claude/skills/${name}/`])
|
|
41
45
|
.some((entry) => !content.includes(entry));
|
|
42
46
|
}
|
|
43
47
|
catch {
|
|
44
48
|
return true;
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
const mutationTails = new Map();
|
|
52
|
+
async function gitExcludeTarget(workspace, includeParentRepository) {
|
|
53
|
+
let root;
|
|
54
|
+
try {
|
|
55
|
+
root = await resolveWorkspaceGitRoot(workspace, includeParentRepository);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if ((error instanceof GitCommandError && error.code === 128) ||
|
|
59
|
+
(error instanceof Error && error.message === 'WORKSPACE_NOT_GIT') ||
|
|
60
|
+
error.code === 'ENOENT')
|
|
61
|
+
return undefined;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
const relative = path
|
|
65
|
+
.relative(root, await realpath(workspace))
|
|
66
|
+
.split(path.sep)
|
|
67
|
+
.join('/');
|
|
68
|
+
const escaped = relative.replace(/[\\*?\[\]#! ]/g, '\\$&');
|
|
69
|
+
return { exclude: await resolveGitExclude(root), prefix: relative === '' ? '/' : `/${escaped}/` };
|
|
70
|
+
}
|
|
71
|
+
async function mutateGitExclude(workspace, mutate, includeParentRepository) {
|
|
72
|
+
const target = await gitExcludeTarget(workspace, includeParentRepository);
|
|
73
|
+
if (target === undefined)
|
|
49
74
|
return;
|
|
50
|
-
const exclude =
|
|
75
|
+
const { exclude, prefix } = target;
|
|
76
|
+
const request = (mutationTails.get(exclude) ?? Promise.resolve())
|
|
77
|
+
.catch(() => undefined)
|
|
78
|
+
.then(() => writeGitExclude(exclude, (entries) => mutate(entries, prefix)));
|
|
79
|
+
mutationTails.set(exclude, request);
|
|
80
|
+
try {
|
|
81
|
+
await request;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
if (mutationTails.get(exclude) === request)
|
|
85
|
+
mutationTails.delete(exclude);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function writeGitExclude(exclude, mutate) {
|
|
51
89
|
await mkdir(path.dirname(exclude), { recursive: true });
|
|
52
90
|
let content = '';
|
|
53
91
|
try {
|
|
@@ -62,7 +100,7 @@ async function mutateGitExclude(workspace, mutate) {
|
|
|
62
100
|
}
|
|
63
101
|
const pattern = managedBlockPattern();
|
|
64
102
|
const existing = [...content.matchAll(pattern)].map((match) => match[0]).join('\n');
|
|
65
|
-
const entries = new Set(existing.split(/\r?\n/u).filter((line) => line.startsWith('
|
|
103
|
+
const entries = new Set(existing.split(/\r?\n/u).filter((line) => line.startsWith('/')));
|
|
66
104
|
entries.delete(MAR_TEMP_DIRECTORY);
|
|
67
105
|
mutate(entries);
|
|
68
106
|
const block = entries.size === 0 ? '' : `${BLOCK_BEGIN}\n${[...entries].sort().join('\n')}\n${BLOCK_END}\n`;
|
|
@@ -97,17 +135,6 @@ async function resolveGitExclude(workspace) {
|
|
|
97
135
|
throw new Error('WORKSPACE_SKILL_GIT_EXCLUDE_FAILED');
|
|
98
136
|
}
|
|
99
137
|
}
|
|
100
|
-
async function exists(value) {
|
|
101
|
-
try {
|
|
102
|
-
await lstat(value);
|
|
103
|
-
return true;
|
|
104
|
-
}
|
|
105
|
-
catch (error) {
|
|
106
|
-
if (isMissing(error))
|
|
107
|
-
return false;
|
|
108
|
-
throw error;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
138
|
function isMissing(error) {
|
|
112
139
|
return (error instanceof Error && 'code' in error && error.code === 'ENOENT');
|
|
113
140
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { rmdir } from 'node:fs/promises';
|
|
1
|
+
import { realpath, rmdir } from 'node:fs/promises';
|
|
2
2
|
import { createWorkspaceDirectory, inspectWorkspace, listWorkspaceDirectories } from '../workspace.js';
|
|
3
3
|
import { ensureWorkspaceGitExclude } from './workspace-git-exclude-service.js';
|
|
4
4
|
import { workspaceGitSettingsSchema } from '@myagentroam/protocol';
|
|
@@ -7,11 +7,13 @@ export class WorkspaceService {
|
|
|
7
7
|
config;
|
|
8
8
|
runtime;
|
|
9
9
|
onRemoved;
|
|
10
|
-
|
|
10
|
+
onGitPolicyChanged;
|
|
11
|
+
constructor(database, config, runtime, onRemoved, onGitPolicyChanged = () => undefined) {
|
|
11
12
|
this.database = database;
|
|
12
13
|
this.config = config;
|
|
13
14
|
this.runtime = runtime;
|
|
14
15
|
this.onRemoved = onRemoved;
|
|
16
|
+
this.onGitPolicyChanged = onGitPolicyChanged;
|
|
15
17
|
}
|
|
16
18
|
operations() {
|
|
17
19
|
return {
|
|
@@ -36,14 +38,28 @@ export class WorkspaceService {
|
|
|
36
38
|
const path = typeof input.createDirectoryName === 'string'
|
|
37
39
|
? (createdPath = await createWorkspaceDirectory(input.path, input.createDirectoryName, this.config().allowedRoots))
|
|
38
40
|
: input.path;
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
+
const canonicalPath = await realpath(path);
|
|
42
|
+
const current = this.database().getWorkspaceByPath(canonicalPath);
|
|
43
|
+
const includeParentRepository = current?.includeParentRepository ?? true;
|
|
44
|
+
const inspected = await inspectWorkspace(canonicalPath, this.config().allowedRoots, includeParentRepository);
|
|
45
|
+
let latest = this.database().getWorkspaceByPath(canonicalPath);
|
|
46
|
+
if (latest !== undefined &&
|
|
47
|
+
(latest.includeParentRepository ?? true) !== includeParentRepository)
|
|
48
|
+
return { workspace: latest };
|
|
49
|
+
if (inspected.kind === 'GIT_WORKSPACE') {
|
|
50
|
+
await ensureWorkspaceGitExclude(inspected.path, includeParentRepository);
|
|
51
|
+
latest = this.database().getWorkspaceByPath(canonicalPath);
|
|
52
|
+
if (latest !== undefined &&
|
|
53
|
+
(latest.includeParentRepository ?? true) !== includeParentRepository)
|
|
54
|
+
return { workspace: latest };
|
|
55
|
+
}
|
|
41
56
|
return {
|
|
42
57
|
workspace: this.database().saveWorkspace({
|
|
43
58
|
path: inspected.path,
|
|
44
59
|
kind: inspected.kind,
|
|
45
60
|
gitSummary: inspected.gitSummary ?? null,
|
|
46
|
-
preserveGitConfig: input.preserveGitConfig === true
|
|
61
|
+
preserveGitConfig: input.preserveGitConfig === true,
|
|
62
|
+
includeParentRepository
|
|
47
63
|
})
|
|
48
64
|
};
|
|
49
65
|
}
|
|
@@ -80,13 +96,22 @@ export class WorkspaceService {
|
|
|
80
96
|
if (current === undefined)
|
|
81
97
|
throw new Error('WORKSPACE_NOT_FOUND');
|
|
82
98
|
if (!update)
|
|
83
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
preserveGitConfig: current.preserveGitConfig ?? true,
|
|
101
|
+
includeParentRepository: current.includeParentRepository ?? true
|
|
102
|
+
};
|
|
84
103
|
const parsed = workspaceGitSettingsSchema.safeParse({
|
|
85
|
-
preserveGitConfig: input.preserveGitConfig
|
|
104
|
+
preserveGitConfig: input.preserveGitConfig,
|
|
105
|
+
includeParentRepository: input.includeParentRepository
|
|
86
106
|
});
|
|
87
107
|
if (!parsed.success)
|
|
88
108
|
throw new Error('GIT_CONFIGURATION_INVALID');
|
|
89
|
-
const result = this.database().setWorkspaceGitPolicy(input.workspaceId, parsed.data.preserveGitConfig);
|
|
90
|
-
|
|
109
|
+
const result = this.database().setWorkspaceGitPolicy(input.workspaceId, parsed.data.preserveGitConfig, parsed.data.includeParentRepository);
|
|
110
|
+
if (result.includeParentRepository !== current.includeParentRepository)
|
|
111
|
+
this.onGitPolicyChanged(current.id);
|
|
112
|
+
return {
|
|
113
|
+
preserveGitConfig: result.preserveGitConfig,
|
|
114
|
+
includeParentRepository: result.includeParentRepository
|
|
115
|
+
};
|
|
91
116
|
}
|
|
92
117
|
}
|
|
@@ -15,15 +15,23 @@ export class WorkspaceWatchSnapshotService {
|
|
|
15
15
|
if (watch.topics.files !== undefined)
|
|
16
16
|
this.ensureFilesRefreshTimer();
|
|
17
17
|
await Promise.all(this.subscribedTopics(watch).map(async (topic) => {
|
|
18
|
+
const revision = this.readRevision(workspace.id, topic);
|
|
18
19
|
try {
|
|
19
20
|
const payload = await this.read(workspace, topic, watch);
|
|
20
21
|
if (!this.matches(workspace.id, watch))
|
|
21
22
|
return;
|
|
23
|
+
if (revision !== this.readRevision(workspace.id, topic)) {
|
|
24
|
+
void this.invalidate(workspace.id, topic);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
22
27
|
this.signatures.set(this.viewKey(workspace.id, topic, watch), signature(payload));
|
|
23
28
|
this.deliver(workspace, watch, topic, payload);
|
|
24
29
|
}
|
|
25
30
|
catch {
|
|
26
|
-
this.
|
|
31
|
+
if (revision === this.readRevision(workspace.id, topic))
|
|
32
|
+
this.fail(workspace.id, watch, topic);
|
|
33
|
+
else
|
|
34
|
+
void this.invalidate(workspace.id, topic);
|
|
27
35
|
}
|
|
28
36
|
}));
|
|
29
37
|
}
|
|
@@ -119,7 +127,22 @@ export class WorkspaceWatchSnapshotService {
|
|
|
119
127
|
const first = subscribers[0];
|
|
120
128
|
if (first === undefined)
|
|
121
129
|
return;
|
|
122
|
-
const
|
|
130
|
+
const revision = this.readRevision(workspaceId, topic);
|
|
131
|
+
let payload;
|
|
132
|
+
try {
|
|
133
|
+
payload = await this.read(workspace, topic, first);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (revision !== this.readRevision(workspaceId, topic)) {
|
|
137
|
+
void this.invalidate(workspaceId, topic);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
if (revision !== this.readRevision(workspaceId, topic)) {
|
|
143
|
+
void this.invalidate(workspaceId, topic);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
123
146
|
const activeSubscribers = subscribers.filter((watch) => this.matches(workspaceId, watch));
|
|
124
147
|
if (activeSubscribers.length === 0)
|
|
125
148
|
return;
|
|
@@ -157,7 +180,7 @@ export class WorkspaceWatchSnapshotService {
|
|
|
157
180
|
read(workspace, topic, watch) {
|
|
158
181
|
const key = topic === 'files'
|
|
159
182
|
? `${workspace.id}:files:${JSON.stringify(watch.topics.files)}`
|
|
160
|
-
: `${workspace.id}:${topic}`;
|
|
183
|
+
: `${workspace.id}:${topic}:${this.readRevision(workspace.id, topic)}`;
|
|
161
184
|
const active = this.reads.get(key);
|
|
162
185
|
if (active !== undefined)
|
|
163
186
|
return active;
|
|
@@ -166,8 +189,12 @@ export class WorkspaceWatchSnapshotService {
|
|
|
166
189
|
: topic === 'changes'
|
|
167
190
|
? workspace.kind === 'GIT_WORKSPACE'
|
|
168
191
|
? this.options.changes.current(workspace)
|
|
169
|
-
: Promise.resolve(
|
|
170
|
-
|
|
192
|
+
: Promise.resolve(workspace.includeParentRepository === false
|
|
193
|
+
? { workspace, changes: [], repositories: [] }
|
|
194
|
+
: null)
|
|
195
|
+
: this.options.files
|
|
196
|
+
.list(workspace, watch.topics.files)
|
|
197
|
+
.then((files) => ({ files, workspace: this.options.requireWorkspace(workspace.id) }))).finally(() => {
|
|
171
198
|
if (this.reads.get(key) === read)
|
|
172
199
|
this.reads.delete(key);
|
|
173
200
|
});
|
|
@@ -181,6 +208,9 @@ export class WorkspaceWatchSnapshotService {
|
|
|
181
208
|
this.signatures.set(key, next);
|
|
182
209
|
return true;
|
|
183
210
|
}
|
|
211
|
+
readRevision(workspaceId, topic) {
|
|
212
|
+
return topic === 'changes' ? this.options.changes.revision(workspaceId) : 0;
|
|
213
|
+
}
|
|
184
214
|
viewKey(workspaceId, topic, watch) {
|
|
185
215
|
return topic === 'files'
|
|
186
216
|
? `${workspaceId}:files:${JSON.stringify(watch.topics.files)}`
|