@axiom-lattice/protocols 3.0.4 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +47 -0
- package/dist/index.d.mts +424 -150
- package/dist/index.d.ts +424 -150
- package/dist/index.js +266 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +261 -12
- package/dist/index.mjs.map +1 -1
- package/jest.config.js +19 -0
- package/package.json +5 -1
- package/src/A2AApiKeyStoreProtocol.ts +13 -5
- package/src/A2AProtocol.ts +34 -174
- package/src/AgentWebAppRuntimeProtocol.ts +168 -0
- package/src/AgentWebAppStoreProtocol.ts +104 -0
- package/src/SkillStoreProtocol.ts +21 -0
- package/src/TaskBeliefProtocol.ts +324 -0
- package/src/TaskStoreProtocol.ts +94 -3
- package/src/TaskWorkItemProtocol.ts +32 -0
- package/src/WorkspaceStoreProtocol.ts +21 -1
- package/src/__tests__/TaskBeliefProtocol.test.ts +421 -0
- package/src/__tests__/a2a-types.test.ts +30 -0
- package/src/__tests__/agent-web-app-types.test.ts +323 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/** A single canonical belief recorded in a task description. */
|
|
2
|
+
export interface TaskBeliefEntry {
|
|
3
|
+
key: string;
|
|
4
|
+
probability: number;
|
|
5
|
+
target: number;
|
|
6
|
+
basis: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The canonical belief snapshot embedded in task Markdown. */
|
|
10
|
+
export interface TaskBeliefState {
|
|
11
|
+
entries: TaskBeliefEntry[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Stable diagnostic identifiers returned by the Belief State parser. */
|
|
15
|
+
export type TaskBeliefDiagnosticCode =
|
|
16
|
+
| "MISSING_BELIEF_STATE"
|
|
17
|
+
| "DUPLICATE_BELIEF_STATE"
|
|
18
|
+
| "INVALID_BELIEF_HEADERS"
|
|
19
|
+
| "MALFORMED_BELIEF_ROW"
|
|
20
|
+
| "INVALID_BELIEF_KEY"
|
|
21
|
+
| "INVALID_BELIEF_PERCENT"
|
|
22
|
+
| "DUPLICATE_BELIEF_KEY";
|
|
23
|
+
|
|
24
|
+
/** A structured failure produced while parsing a task Belief State. */
|
|
25
|
+
export interface TaskBeliefParseFailure {
|
|
26
|
+
success: false;
|
|
27
|
+
code: TaskBeliefDiagnosticCode;
|
|
28
|
+
message: string;
|
|
29
|
+
line?: number;
|
|
30
|
+
key?: string;
|
|
31
|
+
column?: "probability" | "target";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A successfully parsed task Belief State. */
|
|
35
|
+
export interface TaskBeliefParseSuccess {
|
|
36
|
+
success: true;
|
|
37
|
+
state: TaskBeliefState;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The discriminated result of parsing a task Belief State. */
|
|
41
|
+
export type TaskBeliefParseResult = TaskBeliefParseSuccess | TaskBeliefParseFailure;
|
|
42
|
+
|
|
43
|
+
interface MarkdownLine {
|
|
44
|
+
text: string;
|
|
45
|
+
start: number;
|
|
46
|
+
end: number;
|
|
47
|
+
fenced: boolean;
|
|
48
|
+
lineNumber: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface SectionRange {
|
|
52
|
+
headingLine: number;
|
|
53
|
+
start: number;
|
|
54
|
+
end: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const BELIEF_HEADING = "## Belief State";
|
|
58
|
+
const ACCEPTANCE_HEADING = "## Acceptance Criteria";
|
|
59
|
+
const EXPECTED_HEADERS = ["Belief Key", "Probability", "Target", "Basis"];
|
|
60
|
+
const KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
61
|
+
const PERCENT_PATTERN = /^(?:100|[0-9]{1,2})%$/;
|
|
62
|
+
|
|
63
|
+
function markdownLines(markdown: string): MarkdownLine[] {
|
|
64
|
+
const lines: MarkdownLine[] = [];
|
|
65
|
+
let start = 0;
|
|
66
|
+
let fence: { marker: "`" | "~"; length: number } | undefined;
|
|
67
|
+
|
|
68
|
+
while (start <= markdown.length) {
|
|
69
|
+
const newline = markdown.indexOf("\n", start);
|
|
70
|
+
const end = newline === -1 ? markdown.length : newline + 1;
|
|
71
|
+
const textEnd = newline === -1 ? markdown.length : newline;
|
|
72
|
+
const rawText = markdown.slice(start, textEnd);
|
|
73
|
+
const text = rawText.endsWith("\r") ? rawText.slice(0, -1) : rawText;
|
|
74
|
+
const fenceMatch = text.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
75
|
+
const fenced = fence !== undefined;
|
|
76
|
+
|
|
77
|
+
if (!fence && fenceMatch) {
|
|
78
|
+
const marker = fenceMatch[1][0] as "`" | "~";
|
|
79
|
+
fence = { marker, length: fenceMatch[1].length };
|
|
80
|
+
} else if (fence) {
|
|
81
|
+
const closePattern = new RegExp(`^ {0,3}\\${fence.marker}{${fence.length},}[ \\t]*$`);
|
|
82
|
+
if (closePattern.test(text)) fence = undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
lines.push({
|
|
86
|
+
text,
|
|
87
|
+
start,
|
|
88
|
+
end,
|
|
89
|
+
fenced: fenced || fenceMatch !== null,
|
|
90
|
+
lineNumber: lines.length + 1,
|
|
91
|
+
});
|
|
92
|
+
if (newline === -1) break;
|
|
93
|
+
start = end;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sectionRanges(markdown: string, heading: string): SectionRange[] {
|
|
100
|
+
const lines = markdownLines(markdown);
|
|
101
|
+
const ranges: SectionRange[] = [];
|
|
102
|
+
|
|
103
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
104
|
+
const line = lines[index];
|
|
105
|
+
if (line.fenced || line.text.trimEnd().replace(/^ {0,3}/, "") !== heading) continue;
|
|
106
|
+
|
|
107
|
+
let end = markdown.length;
|
|
108
|
+
for (let next = index + 1; next < lines.length; next += 1) {
|
|
109
|
+
if (!lines[next].fenced && /^ {0,3}#{1,2}(?:[ \t]+|$)/.test(lines[next].text)) {
|
|
110
|
+
end = lines[next].start;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
ranges.push({ headingLine: index, start: line.start, end });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return ranges;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function splitTableRow(line: string): string[] | undefined {
|
|
121
|
+
const trimmed = line.trim();
|
|
122
|
+
const finalPipe = trimmed.length - 1;
|
|
123
|
+
if (!trimmed.startsWith("|") || !trimmed.endsWith("|") || isEscapedPipe(trimmed, finalPipe)) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cells: string[] = [];
|
|
128
|
+
let cell = "";
|
|
129
|
+
for (let index = 1; index < trimmed.length - 1; index += 1) {
|
|
130
|
+
const character = trimmed[index];
|
|
131
|
+
if (character === "|" && isEscapedPipe(trimmed, index)) {
|
|
132
|
+
cell += character;
|
|
133
|
+
} else if (character === "|") {
|
|
134
|
+
cells.push(unescapeTableCell(cell.trim()));
|
|
135
|
+
cell = "";
|
|
136
|
+
} else {
|
|
137
|
+
cell += character;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
cells.push(unescapeTableCell(cell.trim()));
|
|
141
|
+
return cells;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function unescapeTableCell(cell: string): string {
|
|
145
|
+
let unescaped = "";
|
|
146
|
+
for (let index = 0; index < cell.length; index += 1) {
|
|
147
|
+
if (cell[index] === "\\" && (cell[index + 1] === "\\" || cell[index + 1] === "|")) {
|
|
148
|
+
index += 1;
|
|
149
|
+
}
|
|
150
|
+
unescaped += cell[index];
|
|
151
|
+
}
|
|
152
|
+
return unescaped;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isEscapedPipe(line: string, pipeIndex: number): boolean {
|
|
156
|
+
let backslashes = 0;
|
|
157
|
+
for (let index = pipeIndex - 1; index >= 0 && line[index] === "\\"; index -= 1) {
|
|
158
|
+
backslashes += 1;
|
|
159
|
+
}
|
|
160
|
+
return backslashes % 2 === 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function failure(
|
|
164
|
+
code: TaskBeliefDiagnosticCode,
|
|
165
|
+
message: string,
|
|
166
|
+
details: Pick<TaskBeliefParseFailure, "line" | "key" | "column"> = {},
|
|
167
|
+
): TaskBeliefParseFailure {
|
|
168
|
+
return { success: false, code, message, ...details };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizedText(value: string): string {
|
|
172
|
+
return value.trim().replace(/\s+/g, " ");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateTaskBeliefState(state: TaskBeliefState): void {
|
|
176
|
+
const keys = new Set<string>();
|
|
177
|
+
for (const entry of state.entries) {
|
|
178
|
+
if (!KEY_PATTERN.test(entry.key)) {
|
|
179
|
+
throw new Error("Belief keys must be kebab-case without backticks or newlines.");
|
|
180
|
+
}
|
|
181
|
+
if (keys.has(entry.key)) {
|
|
182
|
+
throw new Error(`Belief key '${entry.key}' appears more than once.`);
|
|
183
|
+
}
|
|
184
|
+
for (const field of ["probability", "target"] as const) {
|
|
185
|
+
const value = entry[field];
|
|
186
|
+
if (!Number.isInteger(value) || value < 0 || value > 100) {
|
|
187
|
+
throw new Error(`Belief ${field} must be an integer from 0 to 100.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (entry.basis.trim().length === 0 || /[\r\n]/.test(entry.basis)) {
|
|
191
|
+
throw new Error("Belief basis must be a nonempty single line.");
|
|
192
|
+
}
|
|
193
|
+
keys.add(entry.key);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatTaskBeliefState(state: TaskBeliefState): string {
|
|
198
|
+
validateTaskBeliefState(state);
|
|
199
|
+
const rows = state.entries.map(({ key, probability, target, basis }) => {
|
|
200
|
+
const escapedBasis = normalizedText(basis).replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
201
|
+
return `| \`${key}\` | ${probability}% | ${target}% | ${escapedBasis} |`;
|
|
202
|
+
});
|
|
203
|
+
return [
|
|
204
|
+
BELIEF_HEADING,
|
|
205
|
+
"",
|
|
206
|
+
"| Belief Key | Probability | Target | Basis |",
|
|
207
|
+
"|---|---:|---:|---|",
|
|
208
|
+
...rows,
|
|
209
|
+
].join("\n");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Parses the unique non-code-fenced canonical Belief State section in Markdown. */
|
|
213
|
+
export function parseTaskBeliefState(markdown: string): TaskBeliefParseResult {
|
|
214
|
+
const sections = sectionRanges(markdown, BELIEF_HEADING);
|
|
215
|
+
if (sections.length === 0) {
|
|
216
|
+
return failure("MISSING_BELIEF_STATE", "Markdown does not contain a Belief State section.");
|
|
217
|
+
}
|
|
218
|
+
if (sections.length > 1) {
|
|
219
|
+
return failure("DUPLICATE_BELIEF_STATE", "Markdown contains more than one Belief State section.");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const lines = markdownLines(markdown);
|
|
223
|
+
const section = sections[0];
|
|
224
|
+
const content = lines
|
|
225
|
+
.slice(section.headingLine + 1)
|
|
226
|
+
.filter((line) => line.start < section.end && line.text.trim() !== "");
|
|
227
|
+
const header = content[0] && splitTableRow(content[0].text);
|
|
228
|
+
const separator = content[1] && splitTableRow(content[1].text);
|
|
229
|
+
if (
|
|
230
|
+
!header ||
|
|
231
|
+
header.length !== EXPECTED_HEADERS.length ||
|
|
232
|
+
header.some((cell, index) => cell !== EXPECTED_HEADERS[index]) ||
|
|
233
|
+
!separator ||
|
|
234
|
+
separator.length !== EXPECTED_HEADERS.length ||
|
|
235
|
+
separator.some((cell) => !/^:?-{3,}:?$/.test(cell))
|
|
236
|
+
) {
|
|
237
|
+
return failure("INVALID_BELIEF_HEADERS", "Belief State must use the canonical four-column headers.");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const entries: TaskBeliefEntry[] = [];
|
|
241
|
+
const keys = new Set<string>();
|
|
242
|
+
for (const line of content.slice(2)) {
|
|
243
|
+
const cells = splitTableRow(line.text);
|
|
244
|
+
if (!cells || cells.length !== 4 || cells[3].length === 0) {
|
|
245
|
+
return failure("MALFORMED_BELIEF_ROW", "Belief State contains a malformed row.", {
|
|
246
|
+
line: line.lineNumber,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const keyMatch = cells[0].match(/^`([^`]+)`$/);
|
|
251
|
+
if (!keyMatch || !KEY_PATTERN.test(keyMatch[1])) {
|
|
252
|
+
return failure("INVALID_BELIEF_KEY", "Belief keys must be backtick-wrapped kebab-case.", {
|
|
253
|
+
line: line.lineNumber,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const key = keyMatch[1];
|
|
257
|
+
if (keys.has(key)) {
|
|
258
|
+
return failure("DUPLICATE_BELIEF_KEY", `Belief key '${key}' appears more than once.`, {
|
|
259
|
+
line: line.lineNumber,
|
|
260
|
+
key,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const [index, column] of [[1, "probability"], [2, "target"]] as const) {
|
|
265
|
+
if (!PERCENT_PATTERN.test(cells[index])) {
|
|
266
|
+
return failure("INVALID_BELIEF_PERCENT", `Belief ${column} must be an integer from 0% to 100%.`, {
|
|
267
|
+
line: line.lineNumber,
|
|
268
|
+
column,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
keys.add(key);
|
|
274
|
+
entries.push({
|
|
275
|
+
key,
|
|
276
|
+
probability: Number.parseInt(cells[1], 10),
|
|
277
|
+
target: Number.parseInt(cells[2], 10),
|
|
278
|
+
basis: cells[3],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { success: true, state: { entries } };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Compares two Belief States while ignoring entry order and insignificant whitespace. */
|
|
286
|
+
export function taskBeliefStatesEqual(left: TaskBeliefState, right: TaskBeliefState): boolean {
|
|
287
|
+
if (left.entries.length !== right.entries.length) return false;
|
|
288
|
+
|
|
289
|
+
const byKey = new Map(right.entries.map((entry) => [entry.key, entry]));
|
|
290
|
+
return left.entries.every((entry) => {
|
|
291
|
+
const other = byKey.get(entry.key);
|
|
292
|
+
return other !== undefined &&
|
|
293
|
+
entry.probability === other.probability &&
|
|
294
|
+
entry.target === other.target &&
|
|
295
|
+
normalizedText(entry.basis) === normalizedText(other.basis);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Replaces a unique Belief State section, or inserts one after Acceptance Criteria content.
|
|
301
|
+
*
|
|
302
|
+
* @throws {Error} If the state is noncanonical or the Markdown contains duplicate sections.
|
|
303
|
+
*/
|
|
304
|
+
export function replaceTaskBeliefState(markdown: string, state: TaskBeliefState): string {
|
|
305
|
+
const replacement = formatTaskBeliefState(state);
|
|
306
|
+
const sections = sectionRanges(markdown, BELIEF_HEADING);
|
|
307
|
+
if (sections.length > 1) {
|
|
308
|
+
throw new Error("Cannot replace duplicate Belief State sections.");
|
|
309
|
+
}
|
|
310
|
+
if (sections.length === 1) {
|
|
311
|
+
const section = sections[0];
|
|
312
|
+
const originalSection = markdown.slice(section.start, section.end);
|
|
313
|
+
const trailingWhitespace = originalSection.match(/\s*$/)?.[0] ?? "";
|
|
314
|
+
return markdown.slice(0, section.start) + replacement + trailingWhitespace + markdown.slice(section.end);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const acceptance = sectionRanges(markdown, ACCEPTANCE_HEADING)[0];
|
|
318
|
+
const insertion = acceptance?.end ?? markdown.length;
|
|
319
|
+
const before = markdown.slice(0, insertion);
|
|
320
|
+
const after = markdown.slice(insertion);
|
|
321
|
+
const leadingBreaks = before.length === 0 ? "" : before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
|
|
322
|
+
const trailingBreaks = after.length === 0 ? "" : after.startsWith("\n") ? "\n" : "\n\n";
|
|
323
|
+
return before + leadingBreaks + replacement + trailingBreaks + after;
|
|
324
|
+
}
|
package/src/TaskStoreProtocol.ts
CHANGED
|
@@ -122,6 +122,12 @@ export interface TaskItem {
|
|
|
122
122
|
* Create task request type
|
|
123
123
|
*/
|
|
124
124
|
export interface CreateTaskRequest {
|
|
125
|
+
/**
|
|
126
|
+
* Caller-provided task identifier. When set, the store uses this id instead of
|
|
127
|
+
* generating one — used for single-ID mapping with external systems (e.g. A2A).
|
|
128
|
+
*/
|
|
129
|
+
id?: string;
|
|
130
|
+
|
|
125
131
|
/**
|
|
126
132
|
* Task title
|
|
127
133
|
*/
|
|
@@ -226,6 +232,8 @@ export interface TaskFileRef {
|
|
|
226
232
|
uri: string;
|
|
227
233
|
/** Display name (useful when uri is a uuid or bare path) */
|
|
228
234
|
name?: string;
|
|
235
|
+
/** MIME type of the referenced file (e.g. "application/pdf"), when known */
|
|
236
|
+
mimeType?: string;
|
|
229
237
|
/** Who attached the file: the user (reference material) or an agent (artifact) */
|
|
230
238
|
addedBy?: "user" | "agent";
|
|
231
239
|
}
|
|
@@ -287,7 +295,7 @@ export interface UpdateTaskRequest {
|
|
|
287
295
|
/**
|
|
288
296
|
* Additional contextual data
|
|
289
297
|
*/
|
|
290
|
-
context?: Record<string, unknown
|
|
298
|
+
context?: Record<string, unknown> | null;
|
|
291
299
|
|
|
292
300
|
/**
|
|
293
301
|
* Owner type
|
|
@@ -312,12 +320,12 @@ export interface UpdateTaskRequest {
|
|
|
312
320
|
/**
|
|
313
321
|
* Task result output
|
|
314
322
|
*/
|
|
315
|
-
result?: string;
|
|
323
|
+
result?: string | null;
|
|
316
324
|
|
|
317
325
|
/**
|
|
318
326
|
* Reason for task failure (when status is 'failed')
|
|
319
327
|
*/
|
|
320
|
-
failureReason?: string;
|
|
328
|
+
failureReason?: string | null;
|
|
321
329
|
|
|
322
330
|
/** File references attached to this task */
|
|
323
331
|
files?: TaskFileRef[];
|
|
@@ -424,6 +432,89 @@ export interface TaskStore {
|
|
|
424
432
|
*/
|
|
425
433
|
update(tenantId: string, id: string, updates: UpdateTaskRequest): Promise<TaskItem | null>;
|
|
426
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Atomically update a task only when its current status is expected.
|
|
437
|
+
*
|
|
438
|
+
* Storage implementations must evaluate the status predicate in the same
|
|
439
|
+
* atomic mutation that applies `updates`; callers must not emulate this with
|
|
440
|
+
* a separate read followed by {@link update}.
|
|
441
|
+
*
|
|
442
|
+
* @param tenantId Tenant identifier.
|
|
443
|
+
* @param id Task identifier.
|
|
444
|
+
* @param updates Partial task data to update.
|
|
445
|
+
* @param expectedStatuses Current statuses that permit the update.
|
|
446
|
+
* @returns The updated task, or `null` when the task is missing or its status is not expected.
|
|
447
|
+
*/
|
|
448
|
+
updateIfStatusIn(
|
|
449
|
+
tenantId: string,
|
|
450
|
+
id: string,
|
|
451
|
+
updates: UpdateTaskRequest,
|
|
452
|
+
expectedStatuses: TaskItem["status"][],
|
|
453
|
+
): Promise<TaskItem | null>;
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Atomically updates a task only when its status and update timestamp match a read snapshot.
|
|
457
|
+
*
|
|
458
|
+
* Implementations must normalize `Date` and string timestamps to the same stable ISO
|
|
459
|
+
* representation and evaluate both predicates in the mutation itself.
|
|
460
|
+
*
|
|
461
|
+
* @param tenantId Tenant identifier.
|
|
462
|
+
* @param id Task identifier.
|
|
463
|
+
* @param updates Partial task data to update.
|
|
464
|
+
* @param expectedStatuses Current statuses that permit the update.
|
|
465
|
+
* @param expectedUpdatedAt Update timestamp captured from the validated task snapshot.
|
|
466
|
+
* @returns The updated task, or `null` when the task is missing or either snapshot predicate differs.
|
|
467
|
+
*/
|
|
468
|
+
updateIfStatusAndUpdatedAt(
|
|
469
|
+
tenantId: string,
|
|
470
|
+
id: string,
|
|
471
|
+
updates: UpdateTaskRequest,
|
|
472
|
+
expectedStatuses: TaskItem["status"][],
|
|
473
|
+
expectedUpdatedAt: Date | string,
|
|
474
|
+
): Promise<TaskItem | null>;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Atomically updates a child only when both child and parent snapshots match.
|
|
478
|
+
*
|
|
479
|
+
* @param tenantId Tenant identifier shared by the child and parent.
|
|
480
|
+
* @param id Child task identifier.
|
|
481
|
+
* @param updates Partial child task data to update.
|
|
482
|
+
* @param expectedStatuses Child statuses that permit the update.
|
|
483
|
+
* @param expectedUpdatedAt Child update timestamp captured during validation.
|
|
484
|
+
* @param parentId Parent task identifier captured during validation.
|
|
485
|
+
* @param expectedParentUpdatedAt Parent update timestamp captured during validation.
|
|
486
|
+
* @returns The updated child, or `null` when either task is missing or either snapshot differs.
|
|
487
|
+
*/
|
|
488
|
+
updateIfStatusUpdatedAtAndParentUpdatedAt(
|
|
489
|
+
tenantId: string,
|
|
490
|
+
id: string,
|
|
491
|
+
updates: UpdateTaskRequest,
|
|
492
|
+
expectedStatuses: TaskItem["status"][],
|
|
493
|
+
expectedUpdatedAt: Date | string,
|
|
494
|
+
parentId: string,
|
|
495
|
+
expectedParentUpdatedAt: Date | string,
|
|
496
|
+
): Promise<TaskItem | null>;
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Atomically update a task unless its current status is blocked.
|
|
500
|
+
*
|
|
501
|
+
* Storage implementations must evaluate the status predicate in the same
|
|
502
|
+
* atomic mutation that applies `updates`; callers must not emulate this with
|
|
503
|
+
* a separate read followed by {@link update}.
|
|
504
|
+
*
|
|
505
|
+
* @param tenantId Tenant identifier.
|
|
506
|
+
* @param id Task identifier.
|
|
507
|
+
* @param updates Partial task data to update.
|
|
508
|
+
* @param blockedStatuses Current statuses that prevent the update.
|
|
509
|
+
* @returns The updated task, or `null` when the task is missing or blocked.
|
|
510
|
+
*/
|
|
511
|
+
updateIfStatusNotIn(
|
|
512
|
+
tenantId: string,
|
|
513
|
+
id: string,
|
|
514
|
+
updates: UpdateTaskRequest,
|
|
515
|
+
blockedStatuses: TaskItem["status"][],
|
|
516
|
+
): Promise<TaskItem | null>;
|
|
517
|
+
|
|
427
518
|
/**
|
|
428
519
|
* Delete a task by ID
|
|
429
520
|
* @param tenantId Tenant identifier
|
|
@@ -17,6 +17,8 @@ export interface TaskWorkItem {
|
|
|
17
17
|
summary?: string;
|
|
18
18
|
detail?: Record<string, unknown>;
|
|
19
19
|
attempt?: number;
|
|
20
|
+
/** Deterministic task-scoped identity used for idempotent event replay. */
|
|
21
|
+
eventKey?: string;
|
|
20
22
|
createdAt: Date;
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -33,12 +35,23 @@ export interface CreateWorkItemRequest {
|
|
|
33
35
|
attempt?: number;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Work-item creation request requiring a deterministic event identity.
|
|
40
|
+
*
|
|
41
|
+
* Event keys are unique within a tenant and task, not globally.
|
|
42
|
+
*/
|
|
43
|
+
export interface CreateWorkItemIfAbsentRequest extends CreateWorkItemRequest {
|
|
44
|
+
/** Deterministic task-scoped identity used for idempotent event replay. */
|
|
45
|
+
eventKey: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
export interface TaskWorkItemListFilter {
|
|
37
49
|
tenantId: string;
|
|
38
50
|
taskId: string;
|
|
39
51
|
workspaceId?: string;
|
|
40
52
|
projectId?: string;
|
|
41
53
|
action?: string;
|
|
54
|
+
order?: 'asc' | 'desc';
|
|
42
55
|
limit?: number;
|
|
43
56
|
offset?: number;
|
|
44
57
|
}
|
|
@@ -46,4 +59,23 @@ export interface TaskWorkItemListFilter {
|
|
|
46
59
|
export interface TaskWorkItemStore {
|
|
47
60
|
create(params: CreateWorkItemRequest): Promise<TaskWorkItem>;
|
|
48
61
|
list(filter: TaskWorkItemListFilter): Promise<TaskWorkItem[]>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Find an event by deterministic identity without list pagination.
|
|
65
|
+
*
|
|
66
|
+
* @param tenantId Tenant identifier.
|
|
67
|
+
* @param taskId Task identifier that scopes the event key.
|
|
68
|
+
* @param eventKey Deterministic event identity.
|
|
69
|
+
* @returns The matching item, or `null` when absent.
|
|
70
|
+
*/
|
|
71
|
+
findByEventKey(tenantId: string, taskId: string, eventKey: string): Promise<TaskWorkItem | null>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Atomically create an event unless its task-scoped key already exists.
|
|
75
|
+
* Existing events are returned unchanged, preserving immutable replay.
|
|
76
|
+
*
|
|
77
|
+
* @param params Work-item fields including the required event key.
|
|
78
|
+
* @returns The existing or newly created work item.
|
|
79
|
+
*/
|
|
80
|
+
createIfAbsentByEventKey(params: CreateWorkItemIfAbsentRequest): Promise<TaskWorkItem>;
|
|
49
81
|
}
|
|
@@ -50,6 +50,13 @@ export interface WorkspaceStore {
|
|
|
50
50
|
deleteWorkspace(tenantId: string, id: string): Promise<boolean>;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Project kind classification
|
|
55
|
+
*
|
|
56
|
+
* Defaults to "business" for legacy rows and omitted input.
|
|
57
|
+
*/
|
|
58
|
+
export type ProjectKind = "business" | "training" | "personal";
|
|
59
|
+
|
|
53
60
|
/**
|
|
54
61
|
* Project type definition
|
|
55
62
|
*/
|
|
@@ -61,6 +68,8 @@ export interface Project {
|
|
|
61
68
|
description?: string;
|
|
62
69
|
/** Application-specific configuration stored as JSON */
|
|
63
70
|
config?: Record<string, unknown>;
|
|
71
|
+
/** Project classification; defaults to "business" when omitted */
|
|
72
|
+
kind?: ProjectKind;
|
|
64
73
|
createdAt: Date;
|
|
65
74
|
updatedAt: Date;
|
|
66
75
|
}
|
|
@@ -73,6 +82,8 @@ export interface CreateProjectRequest {
|
|
|
73
82
|
description?: string;
|
|
74
83
|
/** Application-specific configuration stored as JSON (optional) */
|
|
75
84
|
config?: Record<string, unknown>;
|
|
85
|
+
/** Project classification; defaults to "business" when omitted */
|
|
86
|
+
kind?: ProjectKind;
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
/**
|
|
@@ -87,6 +98,15 @@ export interface UpdateProjectRequest {
|
|
|
87
98
|
description?: string;
|
|
88
99
|
/** Application-specific configuration stored as JSON (replaces existing if provided) */
|
|
89
100
|
config?: Record<string, unknown>;
|
|
101
|
+
/** Project classification */
|
|
102
|
+
kind?: ProjectKind;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Filter options for listing projects within a workspace
|
|
107
|
+
*/
|
|
108
|
+
export interface ProjectFilter {
|
|
109
|
+
kind?: ProjectKind;
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
/**
|
|
@@ -94,7 +114,7 @@ export interface UpdateProjectRequest {
|
|
|
94
114
|
* Provides CRUD operations for project data
|
|
95
115
|
*/
|
|
96
116
|
export interface ProjectStore {
|
|
97
|
-
getProjectsByWorkspace(tenantId: string, workspaceId: string): Promise<Project[]>;
|
|
117
|
+
getProjectsByWorkspace(tenantId: string, workspaceId: string, filter?: ProjectFilter): Promise<Project[]>;
|
|
98
118
|
getProjectById(tenantId: string, id: string): Promise<Project | null>;
|
|
99
119
|
createProject(tenantId: string, workspaceId: string, id: string, data: CreateProjectRequest): Promise<Project>;
|
|
100
120
|
updateProject(tenantId: string, id: string, updates: UpdateProjectRequest): Promise<Project | null>;
|