@xaccefy/pi-casefile 0.2.9 → 0.3.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/package.json +1 -1
- package/src/index.ts +39 -16
- package/src/ledger.ts +114 -41
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
formatCases,
|
|
31
31
|
getCaseById,
|
|
32
32
|
getCasefilePath,
|
|
33
|
+
LINK_KIND_VALUES,
|
|
33
34
|
linkCasesResult,
|
|
34
35
|
PRIORITY_VALUES,
|
|
35
36
|
promoteFindingResult,
|
|
@@ -46,10 +47,13 @@ import { runPoc } from "./poc-runner.ts";
|
|
|
46
47
|
|
|
47
48
|
// ── Schemas ───────────────────────────────────────────────────────────
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
50
|
+
// Provider-safe string enums: Type.String({ enum }) serializes as { type: "string", enum: [...] }.
|
|
51
|
+
// Do NOT use Type.Union(Type.Literal...) → anyOf/const (providers drop optional anyOf fields,
|
|
52
|
+
// so status-only / severity-only updates arrive empty and silently no-op).
|
|
53
|
+
const CaseStatusSchema = Type.String({ enum: [...STATUS_VALUES] });
|
|
54
|
+
const CaseConfidenceSchema = Type.String({ enum: [...CONFIDENCE_VALUES] });
|
|
55
|
+
const CaseSeveritySchema = Type.String({ enum: [...SEVERITY_VALUES] });
|
|
56
|
+
const CasePrioritySchema = Type.String({ enum: [...PRIORITY_VALUES] });
|
|
53
57
|
|
|
54
58
|
const CommonFields = {
|
|
55
59
|
status: Type.Optional(CaseStatusSchema),
|
|
@@ -146,12 +150,10 @@ const SearchSchema = Type.Object(
|
|
|
146
150
|
{
|
|
147
151
|
query: Type.String({ description: "Text to search across cases" }),
|
|
148
152
|
field: Type.Optional(
|
|
149
|
-
Type.
|
|
150
|
-
SEARCH_FIELD_VALUES
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
},
|
|
154
|
-
),
|
|
153
|
+
Type.String({
|
|
154
|
+
enum: [...SEARCH_FIELD_VALUES],
|
|
155
|
+
description: "Restrict search to a specific field",
|
|
156
|
+
}),
|
|
155
157
|
),
|
|
156
158
|
status: Type.Optional(CaseStatusSchema),
|
|
157
159
|
confidence: Type.Optional(CaseConfidenceSchema),
|
|
@@ -177,6 +179,13 @@ const LinkSchema = Type.Object(
|
|
|
177
179
|
{
|
|
178
180
|
source_id: Type.String({ description: "First case ID" }),
|
|
179
181
|
target_id: Type.String({ description: "Second case ID to link" }),
|
|
182
|
+
kind: Type.Optional(
|
|
183
|
+
Type.String({
|
|
184
|
+
enum: [...LINK_KIND_VALUES],
|
|
185
|
+
description:
|
|
186
|
+
"Relationship kind from source to target: duplicate | related | blocks | depends-on | caused-by | supersedes | mitigates | same-root-cause. Defaults to related.",
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
180
189
|
},
|
|
181
190
|
{ additionalProperties: false },
|
|
182
191
|
);
|
|
@@ -967,19 +976,28 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
967
976
|
pi.registerTool({
|
|
968
977
|
name: "CaseLink",
|
|
969
978
|
label: "Link Cases",
|
|
970
|
-
description:
|
|
979
|
+
description:
|
|
980
|
+
"Bidirectionally link two cases. Use to build exploit chains. Optional `kind` records the relationship (duplicate | related | blocks | depends-on | caused-by | supersedes | mitigates | same-root-cause).",
|
|
971
981
|
promptSnippet: "Link two cases into an exploit chain",
|
|
982
|
+
promptGuidelines: [
|
|
983
|
+
"Use CaseLink to bidirectionally link two cases. Pass `kind` to record how they relate (duplicate, blocks, caused-by, supersedes, etc.); omit it for a plain chain link (defaults to related).",
|
|
984
|
+
],
|
|
972
985
|
parameters: LinkSchema,
|
|
973
986
|
|
|
974
987
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
975
|
-
const result = linkCasesResult(
|
|
988
|
+
const result = linkCasesResult(
|
|
989
|
+
params.source_id as string,
|
|
990
|
+
params.target_id as string,
|
|
991
|
+
params.kind as string | undefined,
|
|
992
|
+
);
|
|
976
993
|
const { source, target } = result;
|
|
994
|
+
const kindLabel = result.kind ? ` [${result.kind}]` : "";
|
|
977
995
|
return {
|
|
978
996
|
content: [
|
|
979
997
|
{
|
|
980
998
|
type: "text",
|
|
981
999
|
text: result.changed
|
|
982
|
-
? `Linked:\n ${formatCase(source)}\n ↔\n ${formatCase(target)}`
|
|
1000
|
+
? `Linked${kindLabel}:\n ${formatCase(source)}\n ↔\n ${formatCase(target)}`
|
|
983
1001
|
: `Link unchanged: ${result.reason ?? "no material change"}\n ${formatCase(source)}\n ↔\n ${formatCase(target)}`,
|
|
984
1002
|
},
|
|
985
1003
|
],
|
|
@@ -988,16 +1006,18 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
988
1006
|
target,
|
|
989
1007
|
changed: result.changed,
|
|
990
1008
|
reason: result.reason,
|
|
1009
|
+
kind: result.kind,
|
|
991
1010
|
},
|
|
992
1011
|
};
|
|
993
1012
|
},
|
|
994
1013
|
|
|
995
1014
|
renderCall(args, theme) {
|
|
1015
|
+
const kind = args.kind ? ` [${args.kind}]` : "";
|
|
996
1016
|
return new Text(
|
|
997
1017
|
theme.fg("toolTitle", theme.bold("CaseLink ")) +
|
|
998
1018
|
theme.fg(
|
|
999
1019
|
"dim",
|
|
1000
|
-
`${(args.source_id as string) ?? ""} ↔ ${(args.target_id as string) ?? ""}`,
|
|
1020
|
+
`${(args.source_id as string) ?? ""} ↔ ${(args.target_id as string) ?? ""}${kind}`,
|
|
1001
1021
|
),
|
|
1002
1022
|
0,
|
|
1003
1023
|
0,
|
|
@@ -1006,11 +1026,12 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1006
1026
|
|
|
1007
1027
|
renderResult(result, _options, theme) {
|
|
1008
1028
|
const details = result.details as
|
|
1009
|
-
| { source?: CaseRecord; target?: CaseRecord; changed?: boolean }
|
|
1029
|
+
| { source?: CaseRecord; target?: CaseRecord; changed?: boolean; kind?: string }
|
|
1010
1030
|
| undefined;
|
|
1011
1031
|
if (!details?.source || !details?.target) {
|
|
1012
1032
|
return new Text("Linked", 0, 0);
|
|
1013
1033
|
}
|
|
1034
|
+
const kindLabel = details.kind ? ` [${details.kind}]` : "";
|
|
1014
1035
|
return new Text(
|
|
1015
1036
|
theme.fg(
|
|
1016
1037
|
details.changed === false ? "warning" : "success",
|
|
@@ -1018,7 +1039,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1018
1039
|
) +
|
|
1019
1040
|
theme.fg("accent", details.source.id) +
|
|
1020
1041
|
" ↔ " +
|
|
1021
|
-
theme.fg("accent", details.target.id)
|
|
1042
|
+
theme.fg("accent", details.target.id) +
|
|
1043
|
+
kindLabel,
|
|
1022
1044
|
0,
|
|
1023
1045
|
0,
|
|
1024
1046
|
);
|
|
@@ -1054,6 +1076,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1054
1076
|
target,
|
|
1055
1077
|
changed: result.changed,
|
|
1056
1078
|
reason: result.reason,
|
|
1079
|
+
kind: result.kind,
|
|
1057
1080
|
},
|
|
1058
1081
|
};
|
|
1059
1082
|
},
|
package/src/ledger.ts
CHANGED
|
@@ -36,6 +36,38 @@ export type CaseSeverity = (typeof SEVERITY_VALUES)[number];
|
|
|
36
36
|
export const PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "P4"] as const;
|
|
37
37
|
export type CasePriority = (typeof PRIORITY_VALUES)[number];
|
|
38
38
|
|
|
39
|
+
/** Typed relationship kinds for CaseLink. Input values accepted by the tool. */
|
|
40
|
+
export const LINK_KIND_VALUES = [
|
|
41
|
+
"duplicate",
|
|
42
|
+
"related",
|
|
43
|
+
"blocks",
|
|
44
|
+
"depends-on",
|
|
45
|
+
"caused-by",
|
|
46
|
+
"supersedes",
|
|
47
|
+
"mitigates",
|
|
48
|
+
"same-root-cause",
|
|
49
|
+
] as const;
|
|
50
|
+
export type CaseLinkKind = (typeof LINK_KIND_VALUES)[number];
|
|
51
|
+
|
|
52
|
+
/** Default kind when none is specified (preserves pre-kind behavior). */
|
|
53
|
+
export const DEFAULT_LINK_KIND: CaseLinkKind = "related";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Inverse of each kind, written to the reverse row so a case lists the
|
|
57
|
+
* relationship from its own perspective. Symmetric kinds map to themselves;
|
|
58
|
+
* directional kinds produce a display-only converse (never accepted as input).
|
|
59
|
+
*/
|
|
60
|
+
export const LINK_KIND_INVERSE: Record<CaseLinkKind, string> = {
|
|
61
|
+
duplicate: "duplicate",
|
|
62
|
+
related: "related",
|
|
63
|
+
blocks: "blocked-by",
|
|
64
|
+
"depends-on": "dependency-of",
|
|
65
|
+
"caused-by": "causes",
|
|
66
|
+
supersedes: "superseded-by",
|
|
67
|
+
mitigates: "mitigated-by",
|
|
68
|
+
"same-root-cause": "same-root-cause",
|
|
69
|
+
};
|
|
70
|
+
|
|
39
71
|
export const SEARCH_FIELD_VALUES = [
|
|
40
72
|
"title",
|
|
41
73
|
"summary",
|
|
@@ -81,7 +113,10 @@ export type CaseRecord = {
|
|
|
81
113
|
reportedAt?: string;
|
|
82
114
|
/** Path to the generated markdown report (set only by writeCaseReport). */
|
|
83
115
|
reportPath?: string;
|
|
116
|
+
/** Flat list of linked case IDs (back-compat; derived from linkedCases). */
|
|
84
117
|
linkedCaseIds: string[];
|
|
118
|
+
/** Linked cases with their relationship kind, from this case's perspective. */
|
|
119
|
+
linkedCases: { id: string; kind: string }[];
|
|
85
120
|
createdAt: string;
|
|
86
121
|
updatedAt: string;
|
|
87
122
|
};
|
|
@@ -133,6 +168,8 @@ export type CaseLinkResult = {
|
|
|
133
168
|
target: CaseRecord;
|
|
134
169
|
changed: boolean;
|
|
135
170
|
reason?: string;
|
|
171
|
+
/** Relationship kind as stated by the caller (source → target). */
|
|
172
|
+
kind: string;
|
|
136
173
|
};
|
|
137
174
|
|
|
138
175
|
export type CaseSearchOptions = {
|
|
@@ -259,11 +296,18 @@ function getDb(): DatabaseSync {
|
|
|
259
296
|
CREATE TABLE IF NOT EXISTS case_links (
|
|
260
297
|
source_id TEXT,
|
|
261
298
|
target_id TEXT,
|
|
299
|
+
kind TEXT NOT NULL DEFAULT 'related',
|
|
262
300
|
PRIMARY KEY (source_id, target_id),
|
|
263
301
|
FOREIGN KEY (source_id) REFERENCES cases(id) ON DELETE CASCADE,
|
|
264
302
|
FOREIGN KEY (target_id) REFERENCES cases(id) ON DELETE CASCADE
|
|
265
303
|
)
|
|
266
304
|
`);
|
|
305
|
+
// Pre-kind ledgers lack the column; add it idempotently. SQLite has no
|
|
306
|
+
// ADD COLUMN IF NOT EXISTS, so guard via pragma table_info.
|
|
307
|
+
const linkCols = db.prepare("PRAGMA table_info(case_links)").all() as { name: string }[];
|
|
308
|
+
if (!linkCols.some((c) => c.name === "kind")) {
|
|
309
|
+
db.exec("ALTER TABLE case_links ADD COLUMN kind TEXT NOT NULL DEFAULT 'related'");
|
|
310
|
+
}
|
|
267
311
|
|
|
268
312
|
// Indexes
|
|
269
313
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status)`);
|
|
@@ -276,7 +320,7 @@ function getDb(): DatabaseSync {
|
|
|
276
320
|
}
|
|
277
321
|
|
|
278
322
|
// Helper to map DB row to CaseRecord
|
|
279
|
-
function mapRow(row: any,
|
|
323
|
+
function mapRow(row: any, linkedCases: { id: string; kind: string }[] = []): CaseRecord {
|
|
280
324
|
/** Safely parse a JSON column; returns [] for arrays, undefined for objects. */
|
|
281
325
|
const safeParseArray = (raw: unknown): string[] => {
|
|
282
326
|
if (!raw) return [];
|
|
@@ -320,7 +364,8 @@ function mapRow(row: any, linkedCaseIds: string[] = []): CaseRecord {
|
|
|
320
364
|
pocVerified: safeParseObject(row.poc_verified_json),
|
|
321
365
|
reportedAt: row.reported_at || undefined,
|
|
322
366
|
reportPath: row.report_path || undefined,
|
|
323
|
-
|
|
367
|
+
linkedCases,
|
|
368
|
+
linkedCaseIds: linkedCases.map((l) => l.id),
|
|
324
369
|
createdAt: row.created_at,
|
|
325
370
|
updatedAt: row.updated_at,
|
|
326
371
|
};
|
|
@@ -335,14 +380,14 @@ export function readCasefile(): CaseRecord[] {
|
|
|
335
380
|
const stmt = db.prepare("SELECT * FROM cases");
|
|
336
381
|
const rows = stmt.all();
|
|
337
382
|
|
|
338
|
-
// Read all links to construct
|
|
339
|
-
const linkStmt = db.prepare("SELECT source_id, target_id FROM case_links");
|
|
340
|
-
const links = linkStmt.all() as { source_id: string; target_id: string }[];
|
|
383
|
+
// Read all links to construct linkedCases map
|
|
384
|
+
const linkStmt = db.prepare("SELECT source_id, target_id, kind FROM case_links");
|
|
385
|
+
const links = linkStmt.all() as { source_id: string; target_id: string; kind: string }[];
|
|
341
386
|
|
|
342
|
-
const linkMap = new Map<string, string[]>();
|
|
387
|
+
const linkMap = new Map<string, { id: string; kind: string }[]>();
|
|
343
388
|
for (const link of links) {
|
|
344
389
|
if (!linkMap.has(link.source_id)) linkMap.set(link.source_id, []);
|
|
345
|
-
linkMap.get(link.source_id)?.push(link.target_id);
|
|
390
|
+
linkMap.get(link.source_id)?.push({ id: link.target_id, kind: link.kind });
|
|
346
391
|
}
|
|
347
392
|
|
|
348
393
|
return rows.map((row: any) => mapRow(row, linkMap.get(row.id) ?? []));
|
|
@@ -354,12 +399,12 @@ export function getCaseById(id: string): CaseRecord | undefined {
|
|
|
354
399
|
const row = stmt.get(id);
|
|
355
400
|
if (!row) return undefined;
|
|
356
401
|
|
|
357
|
-
const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
|
|
358
|
-
const links = linkStmt.all(id) as { target_id: string }[];
|
|
402
|
+
const linkStmt = db.prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?");
|
|
403
|
+
const links = linkStmt.all(id) as { target_id: string; kind: string }[];
|
|
359
404
|
|
|
360
405
|
return mapRow(
|
|
361
406
|
row,
|
|
362
|
-
links.map((l) => l.target_id),
|
|
407
|
+
links.map((l) => ({ id: l.target_id, kind: l.kind })),
|
|
363
408
|
);
|
|
364
409
|
}
|
|
365
410
|
|
|
@@ -510,6 +555,7 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
510
555
|
pocVerified: input.pocVerified ?? existing?.pocVerified,
|
|
511
556
|
reportedAt: input.reportedAt ?? existing?.reportedAt,
|
|
512
557
|
reportPath: input.reportPath ?? existing?.reportPath,
|
|
558
|
+
linkedCases: existing?.linkedCases ?? [],
|
|
513
559
|
linkedCaseIds: existing?.linkedCaseIds ?? [],
|
|
514
560
|
createdAt: existing?.createdAt ?? timestamp,
|
|
515
561
|
updatedAt: timestamp,
|
|
@@ -543,11 +589,11 @@ function findDuplicateCaseInDb(
|
|
|
543
589
|
normalizeMatchText(row.bugClass as string) === bugClass
|
|
544
590
|
) {
|
|
545
591
|
const links = db
|
|
546
|
-
.prepare("SELECT target_id FROM case_links WHERE source_id = ?")
|
|
547
|
-
.all(row.id) as { target_id: string }[];
|
|
592
|
+
.prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?")
|
|
593
|
+
.all(row.id) as { target_id: string; kind: string }[];
|
|
548
594
|
return mapRow(
|
|
549
595
|
row,
|
|
550
|
-
links.map((l) => l.target_id),
|
|
596
|
+
links.map((l) => ({ id: l.target_id, kind: l.kind })),
|
|
551
597
|
);
|
|
552
598
|
}
|
|
553
599
|
}
|
|
@@ -709,7 +755,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
709
755
|
|
|
710
756
|
// Check material equality (we ignore links since links are mutated via CaseLink)
|
|
711
757
|
const norm = (r: CaseRecord) =>
|
|
712
|
-
JSON.stringify({ ...r, updatedAt: "", createdAt: "", linkedCaseIds: [] });
|
|
758
|
+
JSON.stringify({ ...r, updatedAt: "", createdAt: "", linkedCaseIds: [], linkedCases: [] });
|
|
713
759
|
if (norm(current) === norm(next)) {
|
|
714
760
|
const reason =
|
|
715
761
|
update.status && update.status === current.status
|
|
@@ -789,11 +835,15 @@ export function promoteFindingResult(id: string, verification: PocVerification):
|
|
|
789
835
|
|
|
790
836
|
// ── Link operations ──────────────────────────────────────────────────
|
|
791
837
|
|
|
792
|
-
export function linkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
|
|
838
|
+
export function linkCasesResult(sourceId: string, targetId: string, kind?: string): CaseLinkResult {
|
|
793
839
|
const db = getDb();
|
|
794
840
|
if (sourceId === targetId) {
|
|
795
841
|
throw new Error("Cannot link a case to itself");
|
|
796
842
|
}
|
|
843
|
+
const resolvedKind: CaseLinkKind =
|
|
844
|
+
kind && (LINK_KIND_VALUES as readonly string[]).includes(kind)
|
|
845
|
+
? (kind as CaseLinkKind)
|
|
846
|
+
: DEFAULT_LINK_KIND;
|
|
797
847
|
const source = getCaseById(sourceId);
|
|
798
848
|
const target = getCaseById(targetId);
|
|
799
849
|
if (!source) throw new Error(`Case not found: ${sourceId}`);
|
|
@@ -805,19 +855,30 @@ export function linkCasesResult(sourceId: string, targetId: string): CaseLinkRes
|
|
|
805
855
|
throw new Error(`Cannot link terminal case ${targetId} (${target.status})`);
|
|
806
856
|
}
|
|
807
857
|
|
|
808
|
-
const checkStmt = db.prepare("SELECT
|
|
809
|
-
const
|
|
858
|
+
const checkStmt = db.prepare("SELECT kind FROM case_links WHERE source_id = ? AND target_id = ?");
|
|
859
|
+
const existing = checkStmt.get(sourceId, targetId) as { kind: string } | undefined;
|
|
810
860
|
|
|
811
|
-
if (
|
|
812
|
-
return {
|
|
861
|
+
if (existing) {
|
|
862
|
+
return {
|
|
863
|
+
source,
|
|
864
|
+
target,
|
|
865
|
+
changed: false,
|
|
866
|
+
reason: "Cases are already linked",
|
|
867
|
+
kind: existing.kind,
|
|
868
|
+
};
|
|
813
869
|
}
|
|
814
870
|
|
|
815
|
-
// Atomic insert both directions
|
|
871
|
+
// Atomic insert both directions: source→target keeps the stated kind, the
|
|
872
|
+
// reverse row stores the inverse so each case lists the edge from its own
|
|
873
|
+
// perspective.
|
|
874
|
+
const inverseKind = LINK_KIND_INVERSE[resolvedKind];
|
|
816
875
|
db.exec("BEGIN");
|
|
817
876
|
try {
|
|
818
|
-
const linkStmt = db.prepare(
|
|
819
|
-
|
|
820
|
-
|
|
877
|
+
const linkStmt = db.prepare(
|
|
878
|
+
"INSERT INTO case_links (source_id, target_id, kind) VALUES (?, ?, ?)",
|
|
879
|
+
);
|
|
880
|
+
linkStmt.run(sourceId, targetId, resolvedKind);
|
|
881
|
+
linkStmt.run(targetId, sourceId, inverseKind);
|
|
821
882
|
|
|
822
883
|
const now = new Date().toISOString();
|
|
823
884
|
const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
|
|
@@ -835,7 +896,7 @@ export function linkCasesResult(sourceId: string, targetId: string): CaseLinkRes
|
|
|
835
896
|
|
|
836
897
|
const finalSource = getCaseById(sourceId)!;
|
|
837
898
|
const finalTarget = getCaseById(targetId)!;
|
|
838
|
-
return { source: finalSource, target: finalTarget, changed: true };
|
|
899
|
+
return { source: finalSource, target: finalTarget, changed: true, kind: resolvedKind };
|
|
839
900
|
}
|
|
840
901
|
|
|
841
902
|
export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
|
|
@@ -851,11 +912,11 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
|
|
|
851
912
|
throw new Error(`Cannot unlink terminal case ${targetId} (${target.status})`);
|
|
852
913
|
}
|
|
853
914
|
|
|
854
|
-
const checkStmt = db.prepare("SELECT
|
|
855
|
-
const
|
|
915
|
+
const checkStmt = db.prepare("SELECT kind FROM case_links WHERE source_id = ? AND target_id = ?");
|
|
916
|
+
const existing = checkStmt.get(sourceId, targetId) as { kind: string } | undefined;
|
|
856
917
|
|
|
857
|
-
if (!
|
|
858
|
-
return { source, target, changed: false, reason: "Cases are not linked" };
|
|
918
|
+
if (!existing) {
|
|
919
|
+
return { source, target, changed: false, reason: "Cases are not linked", kind: "related" };
|
|
859
920
|
}
|
|
860
921
|
|
|
861
922
|
db.exec("BEGIN");
|
|
@@ -881,7 +942,7 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
|
|
|
881
942
|
|
|
882
943
|
const finalSource = getCaseById(sourceId)!;
|
|
883
944
|
const finalTarget = getCaseById(targetId)!;
|
|
884
|
-
return { source: finalSource, target: finalTarget, changed: true };
|
|
945
|
+
return { source: finalSource, target: finalTarget, changed: true, kind: existing.kind };
|
|
885
946
|
}
|
|
886
947
|
|
|
887
948
|
// ── Search & Queries ─────────────────────────────────────────────────
|
|
@@ -986,18 +1047,20 @@ function buildCaseWhere(options: CaseSearchOptions): {
|
|
|
986
1047
|
};
|
|
987
1048
|
}
|
|
988
1049
|
|
|
989
|
-
/** Map DB rows to CaseRecords, attaching
|
|
1050
|
+
/** Map DB rows to CaseRecords, attaching linkedCases fetched in a single batch. */
|
|
990
1051
|
function mapRowsWithLinks(db: DatabaseSync, rows: any[]): CaseRecord[] {
|
|
991
1052
|
if (rows.length === 0) return [];
|
|
992
1053
|
const ids = rows.map((r) => r.id);
|
|
993
1054
|
const placeholders = ids.map(() => "?").join(",");
|
|
994
1055
|
const links = db
|
|
995
|
-
.prepare(
|
|
996
|
-
|
|
997
|
-
|
|
1056
|
+
.prepare(
|
|
1057
|
+
`SELECT source_id, target_id, kind FROM case_links WHERE source_id IN (${placeholders})`,
|
|
1058
|
+
)
|
|
1059
|
+
.all(...ids) as { source_id: string; target_id: string; kind: string }[];
|
|
1060
|
+
const linkMap = new Map<string, { id: string; kind: string }[]>();
|
|
998
1061
|
for (const l of links) {
|
|
999
1062
|
if (!linkMap.has(l.source_id)) linkMap.set(l.source_id, []);
|
|
1000
|
-
linkMap.get(l.source_id)!.push(l.target_id);
|
|
1063
|
+
linkMap.get(l.source_id)!.push({ id: l.target_id, kind: l.kind });
|
|
1001
1064
|
}
|
|
1002
1065
|
return rows.map((row) => mapRow(row, linkMap.get(row.id) ?? []));
|
|
1003
1066
|
}
|
|
@@ -1046,6 +1109,9 @@ export function countCases(): {
|
|
|
1046
1109
|
// ── Format helpers ───────────────────────────────────────────────────
|
|
1047
1110
|
|
|
1048
1111
|
export function formatCase(record: CaseRecord): string {
|
|
1112
|
+
const linkBits = record.linkedCases.map((l) =>
|
|
1113
|
+
l.kind && l.kind !== DEFAULT_LINK_KIND ? `${l.id}:${l.kind}` : l.id,
|
|
1114
|
+
);
|
|
1049
1115
|
const bits = [
|
|
1050
1116
|
`${record.id} [${record.status}/${record.confidence}] ${record.title}`,
|
|
1051
1117
|
record.priority ? `priority=${record.priority}` : undefined,
|
|
@@ -1055,7 +1121,7 @@ export function formatCase(record: CaseRecord): string {
|
|
|
1055
1121
|
record.endpoint ? `endpoint=${record.endpoint}` : undefined,
|
|
1056
1122
|
record.target ? `target=${record.target}` : undefined,
|
|
1057
1123
|
record.tags?.length ? `tags=${record.tags.join(",")}` : undefined,
|
|
1058
|
-
|
|
1124
|
+
linkBits.length ? `links=${linkBits.join(",")}` : undefined,
|
|
1059
1125
|
record.nextStep ? `next=${record.nextStep}` : undefined,
|
|
1060
1126
|
].filter(Boolean);
|
|
1061
1127
|
return bits.join(" | ");
|
|
@@ -1072,15 +1138,22 @@ export function formatCaseDetail(record: CaseRecord): string {
|
|
|
1072
1138
|
if (
|
|
1073
1139
|
!val ||
|
|
1074
1140
|
(Array.isArray(val) && !val.length) ||
|
|
1075
|
-
["id", "createdAt", "updatedAt"].includes(key)
|
|
1141
|
+
["id", "createdAt", "updatedAt", "linkedCaseIds"].includes(key)
|
|
1076
1142
|
)
|
|
1077
1143
|
continue;
|
|
1078
1144
|
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, " $1");
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1145
|
+
let display: string;
|
|
1146
|
+
if (key === "linkedCases") {
|
|
1147
|
+
display = (val as { id: string; kind: string }[])
|
|
1148
|
+
.map((l) => `${l.id} (${l.kind})`)
|
|
1149
|
+
.join(", ");
|
|
1150
|
+
} else if (Array.isArray(val)) {
|
|
1151
|
+
display = val.join(", ");
|
|
1152
|
+
} else if (typeof val === "object") {
|
|
1153
|
+
display = JSON.stringify(val);
|
|
1154
|
+
} else {
|
|
1155
|
+
display = String(val);
|
|
1156
|
+
}
|
|
1084
1157
|
lines.push(`${label.padEnd(12)} ${display}`);
|
|
1085
1158
|
}
|
|
1086
1159
|
return lines
|