@abseed/spectra-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/dist/answer.d.ts +40 -0
- package/dist/answer.js +72 -0
- package/dist/backlinks.d.ts +35 -0
- package/dist/backlinks.js +66 -0
- package/dist/changeset.d.ts +25 -0
- package/dist/changeset.js +157 -0
- package/dist/commit.d.ts +42 -0
- package/dist/commit.js +96 -0
- package/dist/conflicts.d.ts +29 -0
- package/dist/conflicts.js +64 -0
- package/dist/coverage.d.ts +83 -0
- package/dist/coverage.js +148 -0
- package/dist/expectationCheck.d.ts +43 -0
- package/dist/expectationCheck.js +86 -0
- package/dist/expectations.d.ts +66 -0
- package/dist/expectations.js +114 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/propose.d.ts +19 -0
- package/dist/propose.js +32 -0
- package/dist/raise.d.ts +26 -0
- package/dist/raise.js +42 -0
- package/dist/schema.d.ts +1284 -0
- package/dist/schema.js +222 -0
- package/dist/specStore.d.ts +156 -0
- package/dist/specStore.js +1 -0
- package/dist/transcriptStore.d.ts +83 -0
- package/dist/transcriptStore.js +1 -0
- package/dist/types.d.ts +299 -0
- package/dist/types.js +11 -0
- package/dist/valueType.d.ts +23 -0
- package/dist/valueType.js +41 -0
- package/package.json +35 -0
package/dist/schema.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime validation for files a human may have edited by hand. The goal is a
|
|
3
|
+
* readable message pointing at the offending field, never a stack trace.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { TERM_TYPES } from './types.js';
|
|
7
|
+
import { describeValueTypeError, isValueType } from './valueType.js';
|
|
8
|
+
const termName = z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1)
|
|
11
|
+
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'must be a bare identifier (letters, digits, underscore)');
|
|
12
|
+
const valueType = z.string().superRefine((raw, ctx) => {
|
|
13
|
+
if (!isValueType(raw)) {
|
|
14
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: describeValueTypeError(raw) });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
/** Identity, stamped server-side. `.strict()` schemas below must allow it or a record carrying it is rejected. */
|
|
18
|
+
const authorSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
kind: z.enum(['human', 'spec', 'coder']),
|
|
21
|
+
user: z.string().min(1).optional(),
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
24
|
+
/** Project identity, hand-editable in `specs/project.json`. Both fields are required and non-empty. */
|
|
25
|
+
export const projectInfoSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
name: z.string().min(1),
|
|
28
|
+
domain: z.string().min(1),
|
|
29
|
+
})
|
|
30
|
+
.strict();
|
|
31
|
+
export const attributeSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
name: z.string().min(1),
|
|
34
|
+
valueType,
|
|
35
|
+
default: z.unknown().optional(),
|
|
36
|
+
optional: z.boolean().optional(),
|
|
37
|
+
})
|
|
38
|
+
.strict();
|
|
39
|
+
export const termSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
name: termName,
|
|
42
|
+
type: z.enum(TERM_TYPES),
|
|
43
|
+
spec: z.string(),
|
|
44
|
+
parent: termName.nullable().default(null),
|
|
45
|
+
tags: z.array(z.string()).default([]),
|
|
46
|
+
attributes: z.array(attributeSchema).default([]),
|
|
47
|
+
})
|
|
48
|
+
.strict();
|
|
49
|
+
const opSchema = z.discriminatedUnion('op', [
|
|
50
|
+
z
|
|
51
|
+
.object({
|
|
52
|
+
op: z.literal('add_entity'),
|
|
53
|
+
term: termName,
|
|
54
|
+
termType: z.enum(TERM_TYPES).optional(),
|
|
55
|
+
parent: termName.nullable().optional(),
|
|
56
|
+
spec: z.string(),
|
|
57
|
+
tags: z.array(z.string()).optional(),
|
|
58
|
+
attributes: z.array(attributeSchema).optional(),
|
|
59
|
+
})
|
|
60
|
+
.strict(),
|
|
61
|
+
z.object({ op: z.literal('remove_entity'), term: termName }).strict(),
|
|
62
|
+
z.object({ op: z.literal('add_attribute'), term: termName, attribute: attributeSchema }).strict(),
|
|
63
|
+
z
|
|
64
|
+
.object({ op: z.literal('remove_attribute'), term: termName, attribute: z.string().min(1) })
|
|
65
|
+
.strict(),
|
|
66
|
+
z.object({ op: z.literal('modify_spec'), term: termName, spec: z.string() }).strict(),
|
|
67
|
+
]);
|
|
68
|
+
export const changesetSchema = z
|
|
69
|
+
.object({
|
|
70
|
+
id: z.string().min(1),
|
|
71
|
+
summary: z.string(),
|
|
72
|
+
ops: z.array(opSchema),
|
|
73
|
+
tests: z.array(z.string()).default([]),
|
|
74
|
+
fromQuestion: z.string().min(1).optional(),
|
|
75
|
+
appliedAt: z.string().optional(),
|
|
76
|
+
implementedAt: z.string().nullable().optional(),
|
|
77
|
+
author: authorSchema.optional(),
|
|
78
|
+
})
|
|
79
|
+
.strict();
|
|
80
|
+
export const proposalSchema = z
|
|
81
|
+
.object({
|
|
82
|
+
summary: z.string(),
|
|
83
|
+
ops: z.array(opSchema),
|
|
84
|
+
tests: z.array(z.string()).default([]),
|
|
85
|
+
})
|
|
86
|
+
.strict();
|
|
87
|
+
export const questionSchema = z
|
|
88
|
+
.object({
|
|
89
|
+
id: z.string().min(1),
|
|
90
|
+
asks: z.string().min(1),
|
|
91
|
+
because: z.string().min(1),
|
|
92
|
+
raisedBy: z
|
|
93
|
+
.object({
|
|
94
|
+
pass: z.string().min(1),
|
|
95
|
+
file: z.string().optional(),
|
|
96
|
+
terms: z.array(termName).default([]),
|
|
97
|
+
})
|
|
98
|
+
.strict(),
|
|
99
|
+
options: z
|
|
100
|
+
.array(z
|
|
101
|
+
.object({
|
|
102
|
+
label: z.string().min(1),
|
|
103
|
+
detail: z.string().optional(),
|
|
104
|
+
proposal: proposalSchema.nullable().default(null),
|
|
105
|
+
})
|
|
106
|
+
.strict())
|
|
107
|
+
.default([]),
|
|
108
|
+
author: authorSchema.optional(),
|
|
109
|
+
status: z.enum(['draft', 'ready']).default('ready'),
|
|
110
|
+
rev: z.number().int().positive().default(1),
|
|
111
|
+
answer: z
|
|
112
|
+
.object({
|
|
113
|
+
chose: z.string().nullable(),
|
|
114
|
+
note: z.string(),
|
|
115
|
+
answeredAt: z.string(),
|
|
116
|
+
changesetId: z.string().optional(),
|
|
117
|
+
author: authorSchema.optional(),
|
|
118
|
+
})
|
|
119
|
+
.strict()
|
|
120
|
+
.nullable()
|
|
121
|
+
.default(null),
|
|
122
|
+
})
|
|
123
|
+
.strict()
|
|
124
|
+
.superRefine((question, ctx) => {
|
|
125
|
+
// An answer naming an option that does not exist would silently lose the decision.
|
|
126
|
+
const chose = question.answer?.chose;
|
|
127
|
+
if (chose && !question.options.some((option) => option.label === chose)) {
|
|
128
|
+
ctx.addIssue({
|
|
129
|
+
code: z.ZodIssueCode.custom,
|
|
130
|
+
path: ['answer', 'chose'],
|
|
131
|
+
message: `no option labelled "${chose}"`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
export const expectationSchema = z
|
|
136
|
+
.object({
|
|
137
|
+
id: z.string().min(1),
|
|
138
|
+
kind: z.enum(['functional', 'non-functional']),
|
|
139
|
+
author: authorSchema.optional(),
|
|
140
|
+
status: z.enum(['draft', 'ready']).default('ready'),
|
|
141
|
+
rev: z.number().int().positive().default(1),
|
|
142
|
+
terms: z.array(termName).default([]),
|
|
143
|
+
given: z.string().default(''),
|
|
144
|
+
expect: z.string().min(1),
|
|
145
|
+
raisedBy: z
|
|
146
|
+
.object({
|
|
147
|
+
pass: z.string().min(1),
|
|
148
|
+
from: z.string().min(1).optional(),
|
|
149
|
+
file: z.string().optional(),
|
|
150
|
+
})
|
|
151
|
+
.strict(),
|
|
152
|
+
supersededBy: z.string().min(1).nullable().default(null),
|
|
153
|
+
retiredBecause: z.string().min(1).optional(),
|
|
154
|
+
contested: z
|
|
155
|
+
.array(z
|
|
156
|
+
.object({
|
|
157
|
+
kind: z.enum(['unknown-term', 'duplicate', 'overlaps', 'contradicts', 'restates']),
|
|
158
|
+
subject: z.string().min(1),
|
|
159
|
+
detail: z.string().min(1),
|
|
160
|
+
quote: z.string().optional(),
|
|
161
|
+
})
|
|
162
|
+
.strict())
|
|
163
|
+
.default([]),
|
|
164
|
+
})
|
|
165
|
+
.strict()
|
|
166
|
+
.superRefine((expectation, ctx) => {
|
|
167
|
+
// A functional expectation with no terms is coverage that can never be counted — it
|
|
168
|
+
// would sit in the file and show up against nothing. Non-functional ones are exempt:
|
|
169
|
+
// "the app survives a refresh" legitimately scopes to no term at all.
|
|
170
|
+
if (expectation.kind === 'functional' && expectation.terms.length === 0) {
|
|
171
|
+
ctx.addIssue({
|
|
172
|
+
code: z.ZodIssueCode.custom,
|
|
173
|
+
path: ['terms'],
|
|
174
|
+
message: 'a functional expectation must name at least one glossary term',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
// Self-supersession would make the expectation both live and retired, and the coverage
|
|
178
|
+
// pass would have to pick one.
|
|
179
|
+
if (expectation.supersededBy === expectation.id) {
|
|
180
|
+
ctx.addIssue({
|
|
181
|
+
code: z.ZodIssueCode.custom,
|
|
182
|
+
path: ['supersededBy'],
|
|
183
|
+
message: 'an expectation cannot supersede itself',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
function formatIssues(error) {
|
|
188
|
+
return error.issues.map((issue) => {
|
|
189
|
+
const path = issue.path.join('.');
|
|
190
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
export function parseTerm(data) {
|
|
194
|
+
const result = termSchema.safeParse(data);
|
|
195
|
+
return result.success
|
|
196
|
+
? { ok: true, value: result.data }
|
|
197
|
+
: { ok: false, errors: formatIssues(result.error) };
|
|
198
|
+
}
|
|
199
|
+
export function parseChangeset(data) {
|
|
200
|
+
const result = changesetSchema.safeParse(data);
|
|
201
|
+
return result.success
|
|
202
|
+
? { ok: true, value: result.data }
|
|
203
|
+
: { ok: false, errors: formatIssues(result.error) };
|
|
204
|
+
}
|
|
205
|
+
export function parseQuestion(data) {
|
|
206
|
+
const result = questionSchema.safeParse(data);
|
|
207
|
+
return result.success
|
|
208
|
+
? { ok: true, value: result.data }
|
|
209
|
+
: { ok: false, errors: formatIssues(result.error) };
|
|
210
|
+
}
|
|
211
|
+
export function parseExpectation(data) {
|
|
212
|
+
const result = expectationSchema.safeParse(data);
|
|
213
|
+
return result.success
|
|
214
|
+
? { ok: true, value: result.data }
|
|
215
|
+
: { ok: false, errors: formatIssues(result.error) };
|
|
216
|
+
}
|
|
217
|
+
export function parseProjectInfo(data) {
|
|
218
|
+
const result = projectInfoSchema.safeParse(data);
|
|
219
|
+
return result.success
|
|
220
|
+
? { ok: true, value: result.data }
|
|
221
|
+
: { ok: false, errors: formatIssues(result.error) };
|
|
222
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage seam for the glossary (GH #3).
|
|
3
|
+
*
|
|
4
|
+
* It lives in `@abseed/spectra-core`, the pure package, on purpose: this interface is the stable public
|
|
5
|
+
* boundary an out-of-repo store implements, and an implementor should depend on it without pulling
|
|
6
|
+
* in a server's Express/agent machinery. It references only domain types (from `./types.js` and
|
|
7
|
+
* `./schema.js`), so it belongs with them. The built-in filesystem and SQL backends live in
|
|
8
|
+
* `@abseed/spectra-server`; a hosted/networked backend is just another implementation of this contract.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: specs are a shared human artifact, not a byproduct of the code. To make
|
|
11
|
+
* them centrally visible to a team (and later multi-tenant/hosted), the content of `specs/`
|
|
12
|
+
* has to be able to live somewhere other than the local filesystem. This interface is the
|
|
13
|
+
* one place that decides *where*; `FileSystemSpecStore` (today's behaviour) and a future
|
|
14
|
+
* `SqlSpecStore` are peers behind it. "Hosted" is a deployment axis, not a third backend.
|
|
15
|
+
*
|
|
16
|
+
* The boundary, stated so we do not blur it:
|
|
17
|
+
* - SpecStore is PERSISTENCE ONLY — reads, id allocation, and writes. Domain logic stays
|
|
18
|
+
* in the caller: the changeset engine (`applyOps`), validation (`parseTerm`/…),
|
|
19
|
+
* diagnostics, and version-guard decisions all remain in commit.ts / propose.ts /
|
|
20
|
+
* answer.ts / raise.ts / expectations.ts, which compose a SpecStore rather than living
|
|
21
|
+
* inside it.
|
|
22
|
+
* - Records are addressed by DOMAIN ID (`term.name`, `changeset.id`, `q-…`, `e-…`). No
|
|
23
|
+
* filename or path is used to *address* anything — the FS impl keeps its id→file map
|
|
24
|
+
* private, which is what lets a SQL row id stand in with no change to the caller. The one
|
|
25
|
+
* value that crosses the line is `StoredAt`, returned by the create methods purely so the
|
|
26
|
+
* caller can echo "where it landed" in a response; it is opaque and never parsed.
|
|
27
|
+
* - STATE IS DATA, NOT LOCATION. Today "applied" means a file sits in `applied/` and
|
|
28
|
+
* "retired" means it sits in `retired/`; here those are state transitions named as
|
|
29
|
+
* methods. The reads still return the partitioned view; only the FS impl backs it with
|
|
30
|
+
* folders.
|
|
31
|
+
* - `problems` survives: a record that will not load is reported, not thrown. It is an FS
|
|
32
|
+
* truth today (hand-edited files) but an API backend can return partial data too. What
|
|
33
|
+
* does NOT survive is "re-read from disk every call" — that is an FS detail.
|
|
34
|
+
*
|
|
35
|
+
* Not in scope: `data/transcripts.db` (chat history, its own store) and the version guard /
|
|
36
|
+
* `app/specs.snapshot.json` (tabled — a `version()` method belongs here eventually, once the
|
|
37
|
+
* snapshot is reworked into a queryable authority; deliberately left out of this first slice).
|
|
38
|
+
*/
|
|
39
|
+
import type { Answer, Changeset, Expectation, Op, ProjectInfo, Question, SourceProblem, Term } from './types.js';
|
|
40
|
+
export interface Glossary {
|
|
41
|
+
terms: Term[];
|
|
42
|
+
problems: SourceProblem[];
|
|
43
|
+
}
|
|
44
|
+
export interface PendingChangesets {
|
|
45
|
+
changesets: Changeset[];
|
|
46
|
+
problems: SourceProblem[];
|
|
47
|
+
/**
|
|
48
|
+
* What has landed and what was turned down, newest first. Each entry is a distinct set of
|
|
49
|
+
* ops, so a changeset applied in two partial passes appears twice — the honest count.
|
|
50
|
+
*/
|
|
51
|
+
applied: Changeset[];
|
|
52
|
+
rejected: Changeset[];
|
|
53
|
+
}
|
|
54
|
+
export interface QuestionFeed {
|
|
55
|
+
questions: Question[];
|
|
56
|
+
problems: SourceProblem[];
|
|
57
|
+
}
|
|
58
|
+
export interface ExpectationFeed {
|
|
59
|
+
/** Published, live expectations — what is currently expected to hold. Excludes drafts. */
|
|
60
|
+
expectations: Expectation[];
|
|
61
|
+
/**
|
|
62
|
+
* Drafts — live but not yet published, so they count toward nothing (coverage, the versioned
|
|
63
|
+
* contract, the agents' view) and are returned only for their author's own authoring UI.
|
|
64
|
+
*/
|
|
65
|
+
drafts: Expectation[];
|
|
66
|
+
/** Superseded ones, kept so a test citing a retired id still resolves and the reason survives. */
|
|
67
|
+
retired: Expectation[];
|
|
68
|
+
problems: SourceProblem[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Where the store put a newly created record. Opaque to callers — a filename for the
|
|
72
|
+
* filesystem backend, a row id for SQL — echoed back in responses, never parsed.
|
|
73
|
+
*/
|
|
74
|
+
export type StoredAt = string;
|
|
75
|
+
/**
|
|
76
|
+
* One atomic application (#1, Trap 1). commit.ts runs the engine to produce the post-image,
|
|
77
|
+
* then hands the whole thing here as ONE call so the term writes and the pending→applied move
|
|
78
|
+
* commit together. On the filesystem this is the `writeAtomic` dance (which can half-land
|
|
79
|
+
* across files); on SQL it becomes a real transaction.
|
|
80
|
+
*/
|
|
81
|
+
export interface CommitApplication {
|
|
82
|
+
/** The pending changeset being (partially) applied. */
|
|
83
|
+
changesetId: string;
|
|
84
|
+
/** Full post-image of the glossary the engine produced — the store reconciles disk to this. */
|
|
85
|
+
nextTerms: Term[];
|
|
86
|
+
/** The ops that landed, recorded into the applied record. */
|
|
87
|
+
appliedOps: Op[];
|
|
88
|
+
/** Ops left unselected: they stay pending. Empty means the pending changeset is removed. */
|
|
89
|
+
remainingOps: Op[];
|
|
90
|
+
/** Caller's clock, so this stays testable. */
|
|
91
|
+
appliedAt: string;
|
|
92
|
+
}
|
|
93
|
+
/** What changed, in the store's own terms (filenames for FS, opaque strings for SQL). */
|
|
94
|
+
export interface CommitResult {
|
|
95
|
+
written: string[];
|
|
96
|
+
deleted: string[];
|
|
97
|
+
/** Where the applied record now lives — the old relative-path `resolvedTo`, kept verbatim. */
|
|
98
|
+
resolvedTo: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The result of a mutation guarded by optimistic concurrency.
|
|
102
|
+
*
|
|
103
|
+
* `conflict` carries the revision the record is actually at, never a verdict — the caller
|
|
104
|
+
* compares it to what it expected, the same way `/api/specs/version` reports two numbers. When
|
|
105
|
+
* the caller supplies no `expectedRev`, the write proceeds and only ever returns `ok` or
|
|
106
|
+
* `not-found`; the conflict case appears only for a caller that opted into the check.
|
|
107
|
+
*/
|
|
108
|
+
export type MutationResult = {
|
|
109
|
+
ok: true;
|
|
110
|
+
rev: number;
|
|
111
|
+
at: StoredAt;
|
|
112
|
+
} | {
|
|
113
|
+
ok: false;
|
|
114
|
+
reason: 'not-found';
|
|
115
|
+
} | {
|
|
116
|
+
ok: false;
|
|
117
|
+
reason: 'conflict';
|
|
118
|
+
currentRev: number;
|
|
119
|
+
};
|
|
120
|
+
export interface SpecStore {
|
|
121
|
+
/**
|
|
122
|
+
* Who this glossary is — its display name and one-line domain. Glossary content, not
|
|
123
|
+
* deployment config: the filesystem backend reads it from `specs/project.json`, a SQL backend
|
|
124
|
+
* from a row, a hosted one per tenant. A missing or malformed source degrades to a neutral
|
|
125
|
+
* default rather than throwing, the same way an unreadable record becomes a `problem`.
|
|
126
|
+
*/
|
|
127
|
+
projectInfo(): Promise<ProjectInfo>;
|
|
128
|
+
readTerms(): Promise<Glossary>;
|
|
129
|
+
readChangesets(): Promise<PendingChangesets>;
|
|
130
|
+
readQuestions(): Promise<QuestionFeed>;
|
|
131
|
+
readExpectations(): Promise<ExpectationFeed>;
|
|
132
|
+
findChangeset(id: string): Promise<Changeset | null>;
|
|
133
|
+
findQuestion(id: string): Promise<Question | null>;
|
|
134
|
+
findExpectation(id: string): Promise<Expectation | null>;
|
|
135
|
+
nextChangesetId(): Promise<string>;
|
|
136
|
+
nextQuestionId(): Promise<string>;
|
|
137
|
+
nextExpectationId(): Promise<string>;
|
|
138
|
+
addChangeset(changeset: Changeset): Promise<StoredAt>;
|
|
139
|
+
addQuestion(question: Question): Promise<StoredAt>;
|
|
140
|
+
addExpectation(expectation: Expectation): Promise<StoredAt>;
|
|
141
|
+
/** Atomic: reconcile terms to the post-image and move the changeset pending → applied. */
|
|
142
|
+
commitApplication(application: CommitApplication): Promise<CommitResult>;
|
|
143
|
+
/** Move a pending changeset to rejected. Returns where it landed, or null if none matched. */
|
|
144
|
+
rejectChangeset(id: string): Promise<string | null>;
|
|
145
|
+
/** Record code written against an applied changeset. Where it lives, or null if none matched. */
|
|
146
|
+
markImplemented(id: string, at: string): Promise<StoredAt | null>;
|
|
147
|
+
/**
|
|
148
|
+
* Write an answer into an open question (open → answered), in place, bumping its rev. When
|
|
149
|
+
* `expectedRev` is given and the question has moved past it, the write is refused as a conflict.
|
|
150
|
+
*/
|
|
151
|
+
writeAnswer(questionId: string, answer: Answer, expectedRev?: number): Promise<MutationResult>;
|
|
152
|
+
/** Move a live expectation to retired (bumps its rev). `expectedRev` guards it, as above. */
|
|
153
|
+
retireExpectation(id: string, retired: Expectation, expectedRev?: number): Promise<MutationResult>;
|
|
154
|
+
/** Rewrite a live expectation in place, bumping its rev — e.g. after a recheck or a publish. */
|
|
155
|
+
rewriteExpectation(expectation: Expectation, expectedRev?: number): Promise<MutationResult>;
|
|
156
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage seam for agent-conversation transcripts — the interface only.
|
|
3
|
+
*
|
|
4
|
+
* Like {@link SpecStore}, it lives in `@abseed/spectra-core`, the pure package, so an out-of-repo store can
|
|
5
|
+
* implement it without depending on a server's Express/agent machinery: the cloud backs it with D1,
|
|
6
|
+
* the open server with node:sqlite ({@link SqliteTranscriptStore} in `@abseed/spectra-server`), and both
|
|
7
|
+
* satisfy this one contract. The concrete backend, the plugin loader, and the on-disk defaults stay
|
|
8
|
+
* in the server; only the shape an implementor must match is here.
|
|
9
|
+
*
|
|
10
|
+
* Every read and write is async. A node:sqlite backend is synchronous underneath and resolves
|
|
11
|
+
* immediately, but the interface awaits because a store that talks to a database over the wire — the
|
|
12
|
+
* kind a multi-instance/hosted deploy needs — cannot answer synchronously, and the interface, not one
|
|
13
|
+
* backend, is the boundary implementors depend on. `close` is the one exception: tearing down a
|
|
14
|
+
* connection has nothing to await for a networked store.
|
|
15
|
+
*
|
|
16
|
+
* One instance serves every project, keyed by `projectId` per call (unlike SpecStore, which binds to
|
|
17
|
+
* one project): the runner is a long-lived singleton and sessions carry globally-unique ids, so only
|
|
18
|
+
* the operations that *scope* — creating and listing sessions — need the project; the rest resolve a
|
|
19
|
+
* session by its id.
|
|
20
|
+
*/
|
|
21
|
+
import type { AuthorKind } from './types.js';
|
|
22
|
+
/**
|
|
23
|
+
* `tool_call` rows carry a status so a run interrupted mid-flight can be reasoned about later — on
|
|
24
|
+
* resume, a call left `started` may or may not have taken effect.
|
|
25
|
+
*/
|
|
26
|
+
export type EventKind = 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'error' | 'approval';
|
|
27
|
+
export type ToolStatus = 'started' | 'completed' | 'failed';
|
|
28
|
+
export interface TranscriptEvent {
|
|
29
|
+
id: number;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
/** Who produced the event — human or one of the agents. `kind` says what it is; this says who. */
|
|
32
|
+
author: AuthorKind;
|
|
33
|
+
kind: EventKind;
|
|
34
|
+
/** Plain text, kept searchable. For tool events, a one-line summary. */
|
|
35
|
+
text: string | null;
|
|
36
|
+
/** Structured detail as JSON — tool input/output, error causes. */
|
|
37
|
+
payload: unknown;
|
|
38
|
+
toolCallId: string | null;
|
|
39
|
+
status: ToolStatus | null;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
}
|
|
42
|
+
export interface Session {
|
|
43
|
+
id: string;
|
|
44
|
+
/** The project this conversation belongs to. Sessions are listed and reached per project. */
|
|
45
|
+
projectId: string;
|
|
46
|
+
/**
|
|
47
|
+
* The user who owns this conversation, or `null` for an unattributed one. Sessions are per-user:
|
|
48
|
+
* a hosted deployment stamps the authenticated user here and lists each user only their own. On a
|
|
49
|
+
* single-user install there is no account, so it is `null` and listing is not narrowed — the owner
|
|
50
|
+
* of the durable record (who edited the glossary) lives on the changeset's `author.user`, not here.
|
|
51
|
+
*/
|
|
52
|
+
ownerId: string | null;
|
|
53
|
+
title: string;
|
|
54
|
+
createdAt: string;
|
|
55
|
+
updatedAt: string;
|
|
56
|
+
}
|
|
57
|
+
export interface NewEvent {
|
|
58
|
+
author: AuthorKind;
|
|
59
|
+
kind: EventKind;
|
|
60
|
+
text?: string | null;
|
|
61
|
+
payload?: unknown;
|
|
62
|
+
toolCallId?: string | null;
|
|
63
|
+
status?: ToolStatus | null;
|
|
64
|
+
}
|
|
65
|
+
export interface TranscriptStore {
|
|
66
|
+
createSession(id: string, projectId: string, ownerId: string | null, title: string, now: string): Promise<Session>;
|
|
67
|
+
renameSession(id: string, title: string, now: string): Promise<void>;
|
|
68
|
+
getSession(id: string): Promise<Session | null>;
|
|
69
|
+
/** Sessions for a project, newest first. `ownerId` narrows to one user's; omit it for all of them. */
|
|
70
|
+
listSessions(projectId: string, ownerId?: string, limit?: number): Promise<Session[]>;
|
|
71
|
+
append(sessionId: string, event: NewEvent, now: string): Promise<number>;
|
|
72
|
+
settleApproval(approvalId: string, decision: 'allow' | 'deny', note: string | null): Promise<void>;
|
|
73
|
+
readApproval(approvalId: string): Promise<TranscriptEvent | null>;
|
|
74
|
+
settleToolCall(toolCallId: string, status: ToolStatus, output: unknown): Promise<void>;
|
|
75
|
+
readToolCall(toolCallId: string): Promise<TranscriptEvent | null>;
|
|
76
|
+
read(sessionId: string, afterId?: number): Promise<TranscriptEvent[]>;
|
|
77
|
+
search(query: string, limit?: number): Promise<Array<TranscriptEvent & {
|
|
78
|
+
title: string;
|
|
79
|
+
}>>;
|
|
80
|
+
deleteSession(id: string): Promise<void>;
|
|
81
|
+
pruneBefore(before: string): Promise<number>;
|
|
82
|
+
close(): void;
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|