github-issue-tower-defence-management 1.148.11 → 1.148.13
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/CHANGELOG.md +14 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +6 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +3 -3
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/entry-points/handlers/ownerReplyMarkerDirectoryResolve.js +64 -0
- package/bin/adapter/entry-points/handlers/ownerReplyMarkerDirectoryResolve.js.map +1 -0
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +71 -3
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
- package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js +22 -3
- package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +11 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +10 -2
- package/src/adapter/entry-points/handlers/ownerReplyMarkerDirectoryResolve.test.ts +55 -0
- package/src/adapter/entry-points/handlers/ownerReplyMarkerDirectoryResolve.ts +36 -0
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.queuedReply.test.ts +171 -0
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +90 -3
- package/src/domain/usecases/CreateNewStoryByLabelUseCase.test.ts +216 -0
- package/src/domain/usecases/CreateNewStoryByLabelUseCase.ts +44 -6
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +1 -0
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/ownerReplyMarkerDirectoryResolve.d.ts +2 -0
- package/types/adapter/entry-points/handlers/ownerReplyMarkerDirectoryResolve.d.ts.map +1 -0
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +3 -1
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
- package/types/domain/usecases/CreateNewStoryByLabelUseCase.d.ts.map +1 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { TranscriptOwnerCallStatusProvider } from './TranscriptOwnerCallStatusProvider';
|
|
5
|
+
import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSessionReminderSentinel';
|
|
6
|
+
|
|
7
|
+
describe('TranscriptOwnerCallStatusProvider owner reply typed mid-turn', () => {
|
|
8
|
+
let rootDirectory: string;
|
|
9
|
+
let markerDirectory: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'queued-reply-'));
|
|
13
|
+
markerDirectory = path.join(rootDirectory, 'markers');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fs.rmSync(rootDirectory, { force: true, recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const sessionId = 'e7a58bf9-4d13-4e27-b446-52c3b017ad79';
|
|
21
|
+
const callTimestamp = '2026-08-10T11:04:26.331Z';
|
|
22
|
+
const earlierReplyTimestamp = '2026-08-10T11:02:40.286Z';
|
|
23
|
+
const laterReplyTimestamp = '2026-08-10T11:05:53.576Z';
|
|
24
|
+
|
|
25
|
+
const writeTranscript = (lines: object[]): string => {
|
|
26
|
+
const filePath = path.join(rootDirectory, `${sessionId}.jsonl`);
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
filePath,
|
|
29
|
+
lines.map((line) => JSON.stringify(line)).join('\n'),
|
|
30
|
+
'utf8',
|
|
31
|
+
);
|
|
32
|
+
return filePath;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const writeReplyMarker = (timestamp: string): void => {
|
|
36
|
+
fs.mkdirSync(markerDirectory, { recursive: true });
|
|
37
|
+
fs.writeFileSync(
|
|
38
|
+
path.join(markerDirectory, `${sessionId}.reply_ts`),
|
|
39
|
+
`${timestamp}\n`,
|
|
40
|
+
'utf8',
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ownerCall = (timestamp: string): object => ({
|
|
45
|
+
type: 'assistant',
|
|
46
|
+
timestamp,
|
|
47
|
+
message: {
|
|
48
|
+
role: 'assistant',
|
|
49
|
+
content: [{ type: 'text', text: 'Please decide <<OWNER_CALL>>' }],
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const typedReply = (timestamp: string, text: string): object => ({
|
|
54
|
+
type: 'user',
|
|
55
|
+
timestamp,
|
|
56
|
+
promptSource: 'typed',
|
|
57
|
+
origin: { kind: 'human' },
|
|
58
|
+
message: { role: 'user', content: text },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const enqueued = (timestamp: string, content: string): object => ({
|
|
62
|
+
type: 'queue-operation',
|
|
63
|
+
operation: 'enqueue',
|
|
64
|
+
timestamp,
|
|
65
|
+
sessionId,
|
|
66
|
+
content,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const removed = (timestamp: string, content: string): object => ({
|
|
70
|
+
type: 'queue-operation',
|
|
71
|
+
operation: 'remove',
|
|
72
|
+
timestamp,
|
|
73
|
+
sessionId,
|
|
74
|
+
content,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const unansweredSecondsOf = async (
|
|
78
|
+
transcriptPath: string,
|
|
79
|
+
replyMarkerDirectory: string | null,
|
|
80
|
+
): Promise<number | undefined> => {
|
|
81
|
+
const provider = new TranscriptOwnerCallStatusProvider(
|
|
82
|
+
'<<OWNER_CALL>>',
|
|
83
|
+
replyMarkerDirectory,
|
|
84
|
+
);
|
|
85
|
+
const result =
|
|
86
|
+
await provider.listUnansweredOwnerCallEpochSecondsBySessionName(
|
|
87
|
+
new Map([['session', transcriptPath]]),
|
|
88
|
+
);
|
|
89
|
+
return result.get('session');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
it('treats a reply enqueued while the agent was working as an answer to the call', async () => {
|
|
93
|
+
const transcriptPath = writeTranscript([
|
|
94
|
+
typedReply(earlierReplyTimestamp, 'an earlier answer'),
|
|
95
|
+
ownerCall(callTimestamp),
|
|
96
|
+
enqueued(laterReplyTimestamp, 'CI が通っていないのに許可はできません'),
|
|
97
|
+
removed(
|
|
98
|
+
'2026-08-10T11:06:07.885Z',
|
|
99
|
+
'CI が通っていないのに許可はできません',
|
|
100
|
+
),
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
expect(await unansweredSecondsOf(transcriptPath, null)).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('keeps the call unanswered when the only later enqueue is a task notification', async () => {
|
|
107
|
+
const transcriptPath = writeTranscript([
|
|
108
|
+
typedReply(earlierReplyTimestamp, 'an earlier answer'),
|
|
109
|
+
ownerCall(callTimestamp),
|
|
110
|
+
enqueued(
|
|
111
|
+
laterReplyTimestamp,
|
|
112
|
+
'<task-notification><task-id>abc</task-id></task-notification>',
|
|
113
|
+
),
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
expect(await unansweredSecondsOf(transcriptPath, null)).toBe(
|
|
117
|
+
Math.floor(Date.parse(callTimestamp) / 1000),
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('keeps the call unanswered when the only later enqueue is an injected reminder', async () => {
|
|
122
|
+
const transcriptPath = writeTranscript([
|
|
123
|
+
typedReply(earlierReplyTimestamp, 'an earlier answer'),
|
|
124
|
+
ownerCall(callTimestamp),
|
|
125
|
+
enqueued(
|
|
126
|
+
laterReplyTimestamp,
|
|
127
|
+
`${SILENT_SESSION_REMINDER_SENTINEL} check yourself`,
|
|
128
|
+
),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
expect(await unansweredSecondsOf(transcriptPath, null)).toBe(
|
|
132
|
+
Math.floor(Date.parse(callTimestamp) / 1000),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('treats the reply time recorded by the status line marker as an answer to the call', async () => {
|
|
137
|
+
const transcriptPath = writeTranscript([
|
|
138
|
+
typedReply(earlierReplyTimestamp, 'an earlier answer'),
|
|
139
|
+
ownerCall(callTimestamp),
|
|
140
|
+
]);
|
|
141
|
+
writeReplyMarker(laterReplyTimestamp);
|
|
142
|
+
|
|
143
|
+
expect(
|
|
144
|
+
await unansweredSecondsOf(transcriptPath, markerDirectory),
|
|
145
|
+
).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps the call unanswered when the status line marker predates the call', async () => {
|
|
149
|
+
const transcriptPath = writeTranscript([
|
|
150
|
+
typedReply(earlierReplyTimestamp, 'an earlier answer'),
|
|
151
|
+
ownerCall(callTimestamp),
|
|
152
|
+
]);
|
|
153
|
+
writeReplyMarker(earlierReplyTimestamp);
|
|
154
|
+
|
|
155
|
+
expect(await unansweredSecondsOf(transcriptPath, markerDirectory)).toBe(
|
|
156
|
+
Math.floor(Date.parse(callTimestamp) / 1000),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('keeps the call unanswered when no marker file exists for the session', async () => {
|
|
161
|
+
const transcriptPath = writeTranscript([
|
|
162
|
+
typedReply(earlierReplyTimestamp, 'an earlier answer'),
|
|
163
|
+
ownerCall(callTimestamp),
|
|
164
|
+
]);
|
|
165
|
+
fs.mkdirSync(markerDirectory, { recursive: true });
|
|
166
|
+
|
|
167
|
+
expect(await unansweredSecondsOf(transcriptPath, markerDirectory)).toBe(
|
|
168
|
+
Math.floor(Date.parse(callTimestamp) / 1000),
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
2
3
|
import { OwnerCallStatusProvider } from '../../domain/usecases/adapter-interfaces/OwnerCallStatusProvider';
|
|
3
4
|
import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSessionReminderSentinel';
|
|
4
5
|
|
|
@@ -100,10 +101,49 @@ export const ownerCallMarkerFamilyResolve = (marker: string): string[] =>
|
|
|
100
101
|
]
|
|
101
102
|
: [marker];
|
|
102
103
|
|
|
104
|
+
// A reply the owner types while the agent is still working is NOT written as a `user` entry: the
|
|
105
|
+
// running turn consumes it from the queue, and the transcript keeps only
|
|
106
|
+
// {"type":"queue-operation","operation":"enqueue","content":"<the text>"} plus its `remove` twin.
|
|
107
|
+
// Reading `user` entries alone therefore misses every mid-turn reply, leaves the call outstanding
|
|
108
|
+
// for the rest of the session, and suppresses the stall reminder of a session the owner has
|
|
109
|
+
// already answered. The exclusions match the ones the status line applies to the same entries, so
|
|
110
|
+
// a system-injected enqueue never counts as an owner reply.
|
|
111
|
+
const INJECTED_ENQUEUE_CONTENT_MARKERS = [
|
|
112
|
+
SILENT_SESSION_REMINDER_SENTINEL,
|
|
113
|
+
'<system-reminder>',
|
|
114
|
+
'UserPromptSubmit hook',
|
|
115
|
+
'<task-notification>',
|
|
116
|
+
'SYSTEM NOTIFICATION',
|
|
117
|
+
'<local-command-stdout>',
|
|
118
|
+
'<local-command-caveat>',
|
|
119
|
+
'<command-name>',
|
|
120
|
+
'This session is being continued from a previous conversation',
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
const isOwnerEnqueuedReply = (parsed: Record<string, unknown>): boolean => {
|
|
124
|
+
if (readString(parsed, 'operation') !== 'enqueue') {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const content = readString(parsed, 'content');
|
|
128
|
+
if (content === null || content.length === 0) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return !INJECTED_ENQUEUE_CONTENT_MARKERS.some((injectedMarker) =>
|
|
132
|
+
content.includes(injectedMarker),
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const TRANSCRIPT_FILE_EXTENSION = '.jsonl';
|
|
137
|
+
const OWNER_REPLY_MARKER_FILE_EXTENSION = '.reply_ts';
|
|
138
|
+
const UNSAFE_SESSION_ID_CHARACTER_PATTERN = /[^A-Za-z0-9._-]/g;
|
|
139
|
+
|
|
103
140
|
export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvider {
|
|
104
141
|
private readonly ownerCallMarkerFamily: string[];
|
|
105
142
|
|
|
106
|
-
constructor(
|
|
143
|
+
constructor(
|
|
144
|
+
ownerCallMarker: string | null,
|
|
145
|
+
private readonly ownerReplyMarkerDirectory: string | null = null,
|
|
146
|
+
) {
|
|
107
147
|
this.ownerCallMarkerFamily =
|
|
108
148
|
ownerCallMarker === null || ownerCallMarker.length === 0
|
|
109
149
|
? []
|
|
@@ -181,13 +221,60 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
|
|
|
181
221
|
) {
|
|
182
222
|
lastOwnerReplyEpochMs = epochMs;
|
|
183
223
|
}
|
|
224
|
+
if (type === 'queue-operation' && isOwnerEnqueuedReply(parsed)) {
|
|
225
|
+
lastOwnerReplyEpochMs =
|
|
226
|
+
lastOwnerReplyEpochMs === null || epochMs > lastOwnerReplyEpochMs
|
|
227
|
+
? epochMs
|
|
228
|
+
: lastOwnerReplyEpochMs;
|
|
229
|
+
}
|
|
184
230
|
}
|
|
185
231
|
if (lastOwnerCallEpochMs === null) {
|
|
186
232
|
return null;
|
|
187
233
|
}
|
|
188
|
-
|
|
189
|
-
|
|
234
|
+
const markerReplyEpochMs = this.readOwnerReplyMarkerEpochMs(transcriptPath);
|
|
235
|
+
const resolvedReplyEpochMs =
|
|
236
|
+
markerReplyEpochMs !== null &&
|
|
237
|
+
(lastOwnerReplyEpochMs === null ||
|
|
238
|
+
markerReplyEpochMs > lastOwnerReplyEpochMs)
|
|
239
|
+
? markerReplyEpochMs
|
|
240
|
+
: lastOwnerReplyEpochMs;
|
|
241
|
+
return resolvedReplyEpochMs === null ||
|
|
242
|
+
lastOwnerCallEpochMs > resolvedReplyEpochMs
|
|
190
243
|
? lastOwnerCallEpochMs
|
|
191
244
|
: null;
|
|
192
245
|
};
|
|
246
|
+
|
|
247
|
+
// The owner sees only the status line, so the reply time it renders is the value the owner
|
|
248
|
+
// believes the fleet is acting on. The status line writes that value to a per-session marker
|
|
249
|
+
// file, and reading it here keeps this decision and the owner's own view of it from diverging.
|
|
250
|
+
// The transcript-derived value still stands on its own: an absent, unreadable, or older marker
|
|
251
|
+
// changes nothing, so a fresh host with no markers yet behaves exactly as before.
|
|
252
|
+
private readOwnerReplyMarkerEpochMs = (
|
|
253
|
+
transcriptPath: string,
|
|
254
|
+
): number | null => {
|
|
255
|
+
if (this.ownerReplyMarkerDirectory === null) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const fileName = path.basename(transcriptPath);
|
|
259
|
+
const sessionId = fileName.endsWith(TRANSCRIPT_FILE_EXTENSION)
|
|
260
|
+
? fileName.slice(0, -TRANSCRIPT_FILE_EXTENSION.length)
|
|
261
|
+
: fileName;
|
|
262
|
+
const safeSessionId = sessionId.replace(
|
|
263
|
+
UNSAFE_SESSION_ID_CHARACTER_PATTERN,
|
|
264
|
+
'_',
|
|
265
|
+
);
|
|
266
|
+
let markerContent: string;
|
|
267
|
+
try {
|
|
268
|
+
markerContent = fs.readFileSync(
|
|
269
|
+
path.join(
|
|
270
|
+
this.ownerReplyMarkerDirectory,
|
|
271
|
+
`${safeSessionId}${OWNER_REPLY_MARKER_FILE_EXTENSION}`,
|
|
272
|
+
),
|
|
273
|
+
'utf8',
|
|
274
|
+
);
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
return parseEpochMilliseconds(markerContent.split('\n')[0].trim());
|
|
279
|
+
};
|
|
193
280
|
}
|
|
@@ -96,6 +96,222 @@ describe('CreateNewStoryByLabelUseCase', () => {
|
|
|
96
96
|
);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
describe('logging', () => {
|
|
100
|
+
let logSpy: jest.SpyInstance;
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
lines.length = 0;
|
|
105
|
+
logSpy = jest
|
|
106
|
+
.spyOn(console, 'log')
|
|
107
|
+
.mockImplementation((...args: unknown[]) => {
|
|
108
|
+
lines.push(args.map((arg) => String(arg)).join(' '));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
logSpy.mockRestore();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const loggedLines = (): string[] => lines;
|
|
117
|
+
|
|
118
|
+
it('should record that the option list was left unchanged because every labelled issue title already names a story option', async () => {
|
|
119
|
+
const projectWithExistingOption: Project = {
|
|
120
|
+
...basicProject,
|
|
121
|
+
story: {
|
|
122
|
+
name: 'Story Field',
|
|
123
|
+
fieldId: 'storyFieldId',
|
|
124
|
+
databaseId: 123,
|
|
125
|
+
stories: [
|
|
126
|
+
{
|
|
127
|
+
id: 'existingNewStoryId',
|
|
128
|
+
name: 'New Feature Request',
|
|
129
|
+
color: 'RED',
|
|
130
|
+
description: '',
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
workflowManagementStory: { id: 'workflow1', name: 'Workflow Story' },
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
await useCase.run({
|
|
138
|
+
project: projectWithExistingOption,
|
|
139
|
+
cacheUsed: false,
|
|
140
|
+
org: 'testOrg',
|
|
141
|
+
repo: 'testRepo',
|
|
142
|
+
storyObjectMap: new Map([
|
|
143
|
+
[
|
|
144
|
+
'Story 1',
|
|
145
|
+
{
|
|
146
|
+
story: mock<StoryOption>(),
|
|
147
|
+
storyIssue: mock<Issue>(),
|
|
148
|
+
issues: [issueWithNewStoryLabel],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
]),
|
|
152
|
+
issues: [],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(mockProjectRepository.updateStoryList).not.toHaveBeenCalled();
|
|
156
|
+
expect(
|
|
157
|
+
loggedLines().some((line) =>
|
|
158
|
+
line.includes(
|
|
159
|
+
'every labelled issue title already names a story option',
|
|
160
|
+
),
|
|
161
|
+
),
|
|
162
|
+
).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should record that the run found no labelled issue so that the absence of work is distinguishable from the step never running', async () => {
|
|
166
|
+
const storyObjectWithoutNewStoryIssues: StoryObject = {
|
|
167
|
+
story: mock<StoryOption>(),
|
|
168
|
+
storyIssue: mock<Issue>(),
|
|
169
|
+
issues: [regularIssue],
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
await useCase.run({
|
|
173
|
+
project: basicProject,
|
|
174
|
+
cacheUsed: false,
|
|
175
|
+
org: 'testOrg',
|
|
176
|
+
repo: 'testRepo',
|
|
177
|
+
storyObjectMap: new Map([
|
|
178
|
+
['Story 1', storyObjectWithoutNewStoryIssues],
|
|
179
|
+
]),
|
|
180
|
+
issues: [regularIssue],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(
|
|
184
|
+
loggedLines().some((line) =>
|
|
185
|
+
line.includes('found 0 issues carrying the new story label'),
|
|
186
|
+
),
|
|
187
|
+
).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should record the project having no story field', async () => {
|
|
191
|
+
await useCase.run({
|
|
192
|
+
project: { ...basicProject, story: null },
|
|
193
|
+
cacheUsed: false,
|
|
194
|
+
org: 'testOrg',
|
|
195
|
+
repo: 'testRepo',
|
|
196
|
+
storyObjectMap: new Map([['Story 1', storyObjectWithNewStoryIssues]]),
|
|
197
|
+
issues: [],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(
|
|
201
|
+
loggedLines().some((line) =>
|
|
202
|
+
line.includes('the project has no story field'),
|
|
203
|
+
),
|
|
204
|
+
).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should record the labelled issues, the submitted options, the story link and the label removal', async () => {
|
|
208
|
+
mockProjectRepository.updateStoryList.mockResolvedValue([
|
|
209
|
+
{ id: 'story1', name: 'First Story', color: 'BLUE', description: '' },
|
|
210
|
+
{
|
|
211
|
+
id: 'newStoryId1',
|
|
212
|
+
name: 'New Feature Request',
|
|
213
|
+
color: 'RED',
|
|
214
|
+
description: '',
|
|
215
|
+
},
|
|
216
|
+
]);
|
|
217
|
+
const storyObject: StoryObject = {
|
|
218
|
+
story: {
|
|
219
|
+
...mock<StoryOption>(),
|
|
220
|
+
id: 'story1',
|
|
221
|
+
name: 'Existing Story',
|
|
222
|
+
color: 'BLUE',
|
|
223
|
+
},
|
|
224
|
+
storyIssue: mock<Issue>(),
|
|
225
|
+
issues: [issueWithNewStoryLabel],
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
await useCase.run({
|
|
229
|
+
project: basicProject,
|
|
230
|
+
cacheUsed: false,
|
|
231
|
+
org: 'testOrg',
|
|
232
|
+
repo: 'testRepo',
|
|
233
|
+
storyObjectMap: new Map([['Story 1', storyObject]]),
|
|
234
|
+
issues: [],
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const lines = loggedLines();
|
|
238
|
+
expect(
|
|
239
|
+
lines.some((line) =>
|
|
240
|
+
line.includes('found 1 issues carrying the new story label'),
|
|
241
|
+
),
|
|
242
|
+
).toBe(true);
|
|
243
|
+
expect(
|
|
244
|
+
lines.some(
|
|
245
|
+
(line) =>
|
|
246
|
+
line.includes('labelled issues') &&
|
|
247
|
+
line.includes(issueWithNewStoryLabel.url),
|
|
248
|
+
),
|
|
249
|
+
).toBe(true);
|
|
250
|
+
expect(
|
|
251
|
+
lines.some(
|
|
252
|
+
(line) =>
|
|
253
|
+
line.includes('submitting 1 new story options') &&
|
|
254
|
+
line.includes('New Feature Request'),
|
|
255
|
+
),
|
|
256
|
+
).toBe(true);
|
|
257
|
+
expect(
|
|
258
|
+
lines.some(
|
|
259
|
+
(line) =>
|
|
260
|
+
line.includes('linked') &&
|
|
261
|
+
line.includes(issueWithNewStoryLabel.url) &&
|
|
262
|
+
line.includes('newStoryId1'),
|
|
263
|
+
),
|
|
264
|
+
).toBe(true);
|
|
265
|
+
expect(
|
|
266
|
+
lines.some(
|
|
267
|
+
(line) =>
|
|
268
|
+
line.includes('removed the new story label') &&
|
|
269
|
+
line.includes(issueWithNewStoryLabel.url),
|
|
270
|
+
),
|
|
271
|
+
).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should record that an issue kept its label because no story option matches its title', async () => {
|
|
275
|
+
const issueWithUnmatchedTitle: Issue = {
|
|
276
|
+
...mock<Issue>(),
|
|
277
|
+
url: 'https://github.com/org/repo/issues/1401',
|
|
278
|
+
title: 'Unmatched Title',
|
|
279
|
+
number: 1401,
|
|
280
|
+
story: 'Existing Story',
|
|
281
|
+
labels: ['new-story'],
|
|
282
|
+
};
|
|
283
|
+
mockProjectRepository.updateStoryList.mockResolvedValue([
|
|
284
|
+
{ id: 'story1', name: 'First Story', color: 'BLUE', description: '' },
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
await useCase.run({
|
|
288
|
+
project: basicProject,
|
|
289
|
+
cacheUsed: false,
|
|
290
|
+
org: 'testOrg',
|
|
291
|
+
repo: 'testRepo',
|
|
292
|
+
storyObjectMap: new Map([
|
|
293
|
+
[
|
|
294
|
+
'Story 1',
|
|
295
|
+
{
|
|
296
|
+
story: mock<StoryOption>(),
|
|
297
|
+
storyIssue: mock<Issue>(),
|
|
298
|
+
issues: [issueWithUnmatchedTitle],
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
]),
|
|
302
|
+
issues: [],
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(
|
|
306
|
+
loggedLines().some(
|
|
307
|
+
(line) =>
|
|
308
|
+
line.includes('no story option matches the title') &&
|
|
309
|
+
line.includes(issueWithUnmatchedTitle.url),
|
|
310
|
+
),
|
|
311
|
+
).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
99
315
|
describe('run', () => {
|
|
100
316
|
it('should not process anything when project has no story field', async () => {
|
|
101
317
|
const projectWithoutStory: Project = {
|
|
@@ -4,6 +4,8 @@ import { StoryObjectMap } from '../entities/StoryObjectMap';
|
|
|
4
4
|
import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
|
|
5
5
|
import { Issue } from '../entities/Issue';
|
|
6
6
|
|
|
7
|
+
const LOG_PREFIX = '[CreateNewStoryByLabel]';
|
|
8
|
+
|
|
7
9
|
export class CreateNewStoryByLabelUseCase {
|
|
8
10
|
constructor(
|
|
9
11
|
readonly projectRepository: Pick<ProjectRepository, 'updateStoryList'>,
|
|
@@ -23,30 +25,60 @@ export class CreateNewStoryByLabelUseCase {
|
|
|
23
25
|
}): Promise<void> => {
|
|
24
26
|
const projectStory = input.project.story;
|
|
25
27
|
if (!projectStory) {
|
|
28
|
+
console.log(
|
|
29
|
+
`${LOG_PREFIX} the project has no story field, so no labelled issue is evaluated. project=${input.project.url}`,
|
|
30
|
+
);
|
|
26
31
|
return;
|
|
27
32
|
}
|
|
28
33
|
const newStoryIssues = this.findNewStoryIssues(
|
|
29
34
|
input.storyObjectMap,
|
|
30
35
|
input.issues,
|
|
31
36
|
);
|
|
37
|
+
console.log(
|
|
38
|
+
`${LOG_PREFIX} found ${newStoryIssues.length} issues carrying the new story label. project=${input.project.url}`,
|
|
39
|
+
);
|
|
32
40
|
if (newStoryIssues.length === 0) {
|
|
33
41
|
return;
|
|
34
42
|
}
|
|
43
|
+
console.log(
|
|
44
|
+
`${LOG_PREFIX} labelled issues: ${newStoryIssues
|
|
45
|
+
.map((issue) => issue.url)
|
|
46
|
+
.join(', ')}`,
|
|
47
|
+
);
|
|
35
48
|
const newStoryList = this.createNewStoryList(
|
|
36
49
|
projectStory,
|
|
37
50
|
input.storyObjectMap,
|
|
38
51
|
input.issues,
|
|
39
52
|
);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
const addedStories = newStoryList.filter((story) => story.id === null);
|
|
54
|
+
if (addedStories.length === 0) {
|
|
55
|
+
console.log(
|
|
56
|
+
`${LOG_PREFIX} every labelled issue title already names a story option, so the option list is left unchanged`,
|
|
57
|
+
);
|
|
58
|
+
} else {
|
|
59
|
+
console.log(
|
|
60
|
+
`${LOG_PREFIX} submitting ${addedStories.length} new story options: ${addedStories
|
|
61
|
+
.map((story) => story.name)
|
|
62
|
+
.join(', ')}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const savedNewStoryList =
|
|
66
|
+
addedStories.length === 0
|
|
67
|
+
? projectStory.stories
|
|
68
|
+
: await this.projectRepository.updateStoryList(
|
|
69
|
+
input.project,
|
|
70
|
+
newStoryList,
|
|
71
|
+
);
|
|
72
|
+
console.log(
|
|
73
|
+
`${LOG_PREFIX} the story option list holds ${savedNewStoryList.length} options`,
|
|
74
|
+
);
|
|
46
75
|
|
|
47
76
|
for (const issue of newStoryIssues) {
|
|
48
77
|
const linkedStory = savedNewStoryList.find((s) => s.name === issue.title);
|
|
49
78
|
if (!linkedStory) {
|
|
79
|
+
console.log(
|
|
80
|
+
`${LOG_PREFIX} no story option matches the title of ${issue.url}, so it keeps the new story label`,
|
|
81
|
+
);
|
|
50
82
|
continue;
|
|
51
83
|
}
|
|
52
84
|
await this.issueRepository.updateStory(
|
|
@@ -54,12 +86,18 @@ export class CreateNewStoryByLabelUseCase {
|
|
|
54
86
|
issue,
|
|
55
87
|
linkedStory.id,
|
|
56
88
|
);
|
|
89
|
+
console.log(
|
|
90
|
+
`${LOG_PREFIX} linked ${issue.url} to the story option ${linkedStory.id}`,
|
|
91
|
+
);
|
|
57
92
|
await this.issueRepository.updateLabels(
|
|
58
93
|
issue,
|
|
59
94
|
issue.labels.filter(
|
|
60
95
|
(label) => label.toLowerCase().replace('-', '') !== 'newstory',
|
|
61
96
|
),
|
|
62
97
|
);
|
|
98
|
+
console.log(
|
|
99
|
+
`${LOG_PREFIX} removed the new story label from ${issue.url}`,
|
|
100
|
+
);
|
|
63
101
|
}
|
|
64
102
|
};
|
|
65
103
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HandleScheduledEventUseCaseHandler.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"HandleScheduledEventUseCaseHandler.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts"],"names":[],"mappings":"AA4CA,OAAO,EAAE,KAAK,EAAE,MAAM,gCAAgC,CAAC;AACvD,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAoD3D,qBAAa,kCAAkC;IAC7C,MAAM,GACJ,gBAAgB,MAAM,EACtB,UAAU,OAAO,EACjB,6BAA4B,MAAM,EAAE,GAAG,IAAW,KACjD,OAAO,CAAC;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,MAAM,EAAE,KAAK,EAAE,CAAC;QAChB,SAAS,EAAE,OAAO,CAAC;QACnB,eAAe,EAAE,IAAI,EAAE,CAAC;KACzB,GAAG,IAAI,CAAC,CAowBP;CACH"}
|
|
@@ -7,6 +7,7 @@ export type NotifySilentTmuxSessionsParams = {
|
|
|
7
7
|
localCommandRunner: LocalCommandRunner;
|
|
8
8
|
processEnvironReader?: ProcessEnvironReader;
|
|
9
9
|
ownerCallMarker: string | null;
|
|
10
|
+
ownerReplyMarkerDirectory?: string | null;
|
|
10
11
|
subAgentOutputRootDirectory: string | null;
|
|
11
12
|
subAgentProcessMatchPattern: string | null;
|
|
12
13
|
subAgentTranscriptRootDirectory: string | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notifySilentTmuxSessions.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,gEAAgE,CAAC;AAGpG,OAAO,EAAE,oBAAoB,EAAE,MAAM,kEAAkE,CAAC;AACxG,OAAO,EAEL,qBAAqB,EAQtB,MAAM,0DAA0D,CAAC;AAoBlE,OAAO,EAEL,6BAA6B,EAC9B,MAAM,6DAA6D,CAAC;AAKrE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,OAAO,EAAE,OAAO,CAAC;IACjB,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,+BAA+B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,4BAA4B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,0BAA0B,EAAE,MAAM,CAAC;IACnC,+BAA+B,EAAE,MAAM,CAAC;IACxC,8BAA8B,EAAE,MAAM,CAAC;IACvC,+BAA+B,EAAE,MAAM,CAAC;IACxC,cAAc,EAAE,MAAM,CAAC;IACvB,qCAAqC,EAAE,MAAM,CAAC;IAC9C,8BAA8B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9C,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,qBAAqB,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACpD,+BAA+B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,4BAA4B,EAAE,MAAM,CAAC;IACrC,gBAAgB,EAAE,6BAA6B,CAAC;IAChD,6BAA6B,CAAC,EAAE,MAAM,CAAC;IACvC,GAAG,EAAE,IAAI,CAAC;CACX,CAAC;
|
|
1
|
+
{"version":3,"file":"notifySilentTmuxSessions.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,gEAAgE,CAAC;AAGpG,OAAO,EAAE,oBAAoB,EAAE,MAAM,kEAAkE,CAAC;AACxG,OAAO,EAEL,qBAAqB,EAQtB,MAAM,0DAA0D,CAAC;AAoBlE,OAAO,EAEL,6BAA6B,EAC9B,MAAM,6DAA6D,CAAC;AAKrE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,OAAO,EAAE,OAAO,CAAC;IACjB,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,yBAAyB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,+BAA+B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,4BAA4B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,0BAA0B,EAAE,MAAM,CAAC;IACnC,+BAA+B,EAAE,MAAM,CAAC;IACxC,8BAA8B,EAAE,MAAM,CAAC;IACvC,+BAA+B,EAAE,MAAM,CAAC;IACxC,cAAc,EAAE,MAAM,CAAC;IACvB,qCAAqC,EAAE,MAAM,CAAC;IAC9C,8BAA8B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9C,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,qBAAqB,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACpD,+BAA+B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,4BAA4B,EAAE,MAAM,CAAC;IACrC,gBAAgB,EAAE,6BAA6B,CAAC;IAChD,6BAA6B,CAAC,EAAE,MAAM,CAAC;IACvC,GAAG,EAAE,IAAI,CAAC;CACX,CAAC;AA2CF,eAAO,MAAM,wBAAwB,GACnC,QAAQ,8BAA8B,KACrC,OAAO,CAAC,IAAI,CAuFd,CAAC;AAEF,eAAO,MAAM,0CAA0C;;;;;;;;CAS7C,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ownerReplyMarkerDirectoryResolve.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/ownerReplyMarkerDirectoryResolve.ts"],"names":[],"mappings":"AAWA,eAAO,MAAM,gCAAgC,GAC3C,qBAAqB,MAAM,GAAG,IAAI,EAClC,aAAa,MAAM,CAAC,UAAU,EAC9B,QAAQ,MAAM,GAAG,IAAI,KACpB,MAAM,GAAG,IAoBX,CAAC"}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { OwnerCallStatusProvider } from '../../domain/usecases/adapter-interfaces/OwnerCallStatusProvider';
|
|
2
2
|
export declare const ownerCallMarkerFamilyResolve: (marker: string) => string[];
|
|
3
3
|
export declare class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvider {
|
|
4
|
+
private readonly ownerReplyMarkerDirectory;
|
|
4
5
|
private readonly ownerCallMarkerFamily;
|
|
5
|
-
constructor(ownerCallMarker: string | null);
|
|
6
|
+
constructor(ownerCallMarker: string | null, ownerReplyMarkerDirectory?: string | null);
|
|
6
7
|
listUnansweredOwnerCallEpochSecondsBySessionName: (transcriptPathBySessionName: Map<string, string>) => Promise<Map<string, number>>;
|
|
7
8
|
private findUnansweredOwnerCallEpochMs;
|
|
9
|
+
private readOwnerReplyMarkerEpochMs;
|
|
8
10
|
}
|
|
9
11
|
//# sourceMappingURL=TranscriptOwnerCallStatusProvider.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TranscriptOwnerCallStatusProvider.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"TranscriptOwnerCallStatusProvider.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,uBAAuB,EAAE,MAAM,kEAAkE,CAAC;AA6F3G,eAAO,MAAM,4BAA4B,GAAI,QAAQ,MAAM,KAAG,MAAM,EAMtD,CAAC;AAsCf,qBAAa,iCAAkC,YAAW,uBAAuB;IAK7E,OAAO,CAAC,QAAQ,CAAC,yBAAyB;IAJ5C,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAW;gBAG/C,eAAe,EAAE,MAAM,GAAG,IAAI,EACb,yBAAyB,GAAE,MAAM,GAAG,IAAW;IAQlE,gDAAgD,GAC9C,6BAA6B,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,KAC/C,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAqB7B;IAEF,OAAO,CAAC,8BAA8B,CAmEpC;IAOF,OAAO,CAAC,2BAA2B,CA2BjC;CACH"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CreateNewStoryByLabelUseCase.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/CreateNewStoryByLabelUseCase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAC;AAC3E,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"CreateNewStoryByLabelUseCase.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/CreateNewStoryByLabelUseCase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAC;AAC3E,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAI1C,qBAAa,4BAA4B;IAErC,QAAQ,CAAC,iBAAiB,EAAE,IAAI,CAAC,iBAAiB,EAAE,iBAAiB,CAAC;IACtE,QAAQ,CAAC,eAAe,EAAE,IAAI,CAC5B,eAAe,EACf,cAAc,GAAG,aAAa,CAC/B;gBAJQ,iBAAiB,EAAE,IAAI,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,EAC7D,eAAe,EAAE,IAAI,CAC5B,eAAe,EACf,cAAc,GAAG,aAAa,CAC/B;IAGH,GAAG,GAAU,OAAO;QAClB,OAAO,EAAE,OAAO,CAAC;QACjB,SAAS,EAAE,OAAO,CAAC;QACnB,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,cAAc,EAAE,cAAc,CAAC;QAC/B,MAAM,EAAE,KAAK,EAAE,CAAC;KACjB,KAAG,OAAO,CAAC,IAAI,CAAC,CA6Ef;IAEF,gBAAgB,GAAI,OAAO,KAAK,KAAG,OAAO,CAG7B;IAEb,kBAAkB,GAChB,gBAAgB,cAAc,EAC9B,QAAQ,KAAK,EAAE,KACd,KAAK,EAAE,CAeR;IAEF,kBAAkB,GAChB,cAAc,WAAW,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,EAC3C,gBAAgB,cAAc,EAC9B,QAAQ,KAAK,EAAE,KACd,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG;QAAE,EAAE,EAAE,WAAW,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;KAAE,CAAC,EAAE,CA8B/D;CACH"}
|