@indigoai-us/hq-cloud 5.19.1 → 5.21.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/.github/workflows/ci.yml +8 -4
- package/.github/workflows/publish.yml +9 -3
- package/dist/bin/sync-runner.d.ts +9 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +58 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/entity-resolver.d.ts +53 -0
- package/dist/entity-resolver.d.ts.map +1 -0
- package/dist/entity-resolver.js +127 -0
- package/dist/entity-resolver.js.map +1 -0
- package/dist/entity-resolver.test.d.ts +10 -0
- package/dist/entity-resolver.test.d.ts.map +1 -0
- package/dist/entity-resolver.test.js +244 -0
- package/dist/entity-resolver.test.js.map +1 -0
- package/dist/index.d.ts +17 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas/signal-types.d.ts +16 -0
- package/dist/schemas/signal-types.d.ts.map +1 -0
- package/dist/schemas/signal-types.js +30 -0
- package/dist/schemas/signal-types.js.map +1 -0
- package/dist/schemas/signal-types.test.d.ts +2 -0
- package/dist/schemas/signal-types.test.d.ts.map +1 -0
- package/dist/schemas/signal-types.test.js +65 -0
- package/dist/schemas/signal-types.test.js.map +1 -0
- package/dist/schemas/source-channels.d.ts +15 -0
- package/dist/schemas/source-channels.d.ts.map +1 -0
- package/dist/schemas/source-channels.js +28 -0
- package/dist/schemas/source-channels.js.map +1 -0
- package/dist/schemas/source-channels.test.d.ts +2 -0
- package/dist/schemas/source-channels.test.d.ts.map +1 -0
- package/dist/schemas/source-channels.test.js +65 -0
- package/dist/schemas/source-channels.test.js.map +1 -0
- package/dist/signals/get.d.ts +13 -0
- package/dist/signals/get.d.ts.map +1 -0
- package/dist/signals/get.js +74 -0
- package/dist/signals/get.js.map +1 -0
- package/dist/signals/get.test.d.ts +5 -0
- package/dist/signals/get.test.d.ts.map +1 -0
- package/dist/signals/get.test.js +170 -0
- package/dist/signals/get.test.js.map +1 -0
- package/dist/signals/internals.d.ts +16 -0
- package/dist/signals/internals.d.ts.map +1 -0
- package/dist/signals/internals.js +39 -0
- package/dist/signals/internals.js.map +1 -0
- package/dist/signals/list.d.ts +10 -0
- package/dist/signals/list.d.ts.map +1 -0
- package/dist/signals/list.js +76 -0
- package/dist/signals/list.js.map +1 -0
- package/dist/signals/list.test.d.ts +9 -0
- package/dist/signals/list.test.d.ts.map +1 -0
- package/dist/signals/list.test.js +227 -0
- package/dist/signals/list.test.js.map +1 -0
- package/dist/signals/parse.d.ts +8 -0
- package/dist/signals/parse.d.ts.map +1 -0
- package/dist/signals/parse.js +8 -0
- package/dist/signals/parse.js.map +1 -0
- package/dist/signals/types.d.ts +69 -0
- package/dist/signals/types.d.ts.map +1 -0
- package/dist/signals/types.js +10 -0
- package/dist/signals/types.js.map +1 -0
- package/dist/sources/get.d.ts +11 -0
- package/dist/sources/get.d.ts.map +1 -0
- package/dist/sources/get.js +67 -0
- package/dist/sources/get.js.map +1 -0
- package/dist/sources/get.test.d.ts +5 -0
- package/dist/sources/get.test.d.ts.map +1 -0
- package/dist/sources/get.test.js +132 -0
- package/dist/sources/get.test.js.map +1 -0
- package/dist/sources/internals.d.ts +16 -0
- package/dist/sources/internals.d.ts.map +1 -0
- package/dist/sources/internals.js +39 -0
- package/dist/sources/internals.js.map +1 -0
- package/dist/sources/list.d.ts +10 -0
- package/dist/sources/list.d.ts.map +1 -0
- package/dist/sources/list.js +76 -0
- package/dist/sources/list.js.map +1 -0
- package/dist/sources/list.test.d.ts +8 -0
- package/dist/sources/list.test.d.ts.map +1 -0
- package/dist/sources/list.test.js +198 -0
- package/dist/sources/list.test.js.map +1 -0
- package/dist/sources/parse.d.ts +18 -0
- package/dist/sources/parse.d.ts.map +1 -0
- package/dist/sources/parse.js +35 -0
- package/dist/sources/parse.js.map +1 -0
- package/dist/sources/types.d.ts +62 -0
- package/dist/sources/types.d.ts.map +1 -0
- package/dist/sources/types.js +8 -0
- package/dist/sources/types.js.map +1 -0
- package/dist/telemetry.d.ts +87 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +349 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/telemetry.test.d.ts +11 -0
- package/dist/telemetry.test.d.ts.map +1 -0
- package/dist/telemetry.test.js +309 -0
- package/dist/telemetry.test.js.map +1 -0
- package/dist/vault-client.d.ts +43 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +28 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +5 -3
- package/src/bin/sync-runner.ts +73 -0
- package/src/entity-resolver.test.ts +315 -0
- package/src/entity-resolver.ts +180 -0
- package/src/index.ts +76 -0
- package/src/schemas/signal-types.test.ts +82 -0
- package/src/schemas/signal-types.ts +38 -0
- package/src/schemas/source-channels.test.ts +82 -0
- package/src/schemas/source-channels.ts +36 -0
- package/src/signals/get.test.ts +204 -0
- package/src/signals/get.ts +79 -0
- package/src/signals/internals.ts +46 -0
- package/src/signals/list.test.ts +283 -0
- package/src/signals/list.ts +92 -0
- package/src/signals/parse.ts +8 -0
- package/src/signals/types.ts +74 -0
- package/src/sources/get.test.ts +166 -0
- package/src/sources/get.ts +75 -0
- package/src/sources/internals.ts +46 -0
- package/src/sources/list.test.ts +247 -0
- package/src/sources/list.ts +95 -0
- package/src/sources/parse.ts +43 -0
- package/src/sources/types.ts +67 -0
- package/src/telemetry.test.ts +394 -0
- package/src/telemetry.ts +436 -0
- package/src/vault-client.ts +60 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
SOURCE_CHANNELS,
|
|
4
|
+
isSourceChannel,
|
|
5
|
+
assertSourceChannel,
|
|
6
|
+
InvalidSourceChannelError,
|
|
7
|
+
} from "./source-channels.js";
|
|
8
|
+
import type { SourceChannel } from "./source-channels.js";
|
|
9
|
+
|
|
10
|
+
describe("SOURCE_CHANNELS", () => {
|
|
11
|
+
it("contains the five canonical channels", () => {
|
|
12
|
+
expect([...SOURCE_CHANNELS]).toEqual([
|
|
13
|
+
"meeting",
|
|
14
|
+
"email",
|
|
15
|
+
"slack",
|
|
16
|
+
"linear",
|
|
17
|
+
"notion",
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("is frozen (readonly at runtime)", () => {
|
|
22
|
+
expect(Object.isFrozen(SOURCE_CHANNELS)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("isSourceChannel", () => {
|
|
27
|
+
it.each(SOURCE_CHANNELS)("returns true for '%s'", (channel) => {
|
|
28
|
+
expect(isSourceChannel(channel)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns false for an invalid string", () => {
|
|
32
|
+
expect(isSourceChannel("pigeon")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns false for non-string values", () => {
|
|
36
|
+
expect(isSourceChannel(42)).toBe(false);
|
|
37
|
+
expect(isSourceChannel(null)).toBe(false);
|
|
38
|
+
expect(isSourceChannel(undefined)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("assertSourceChannel", () => {
|
|
43
|
+
it.each(SOURCE_CHANNELS)("does not throw for '%s'", (channel) => {
|
|
44
|
+
expect(() => assertSourceChannel(channel)).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("throws InvalidSourceChannelError for invalid value", () => {
|
|
48
|
+
expect(() => assertSourceChannel("pigeon")).toThrow(InvalidSourceChannelError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("error message contains the offending value", () => {
|
|
52
|
+
expect(() => assertSourceChannel("pigeon")).toThrow("'pigeon'");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("error message lists all valid channels", () => {
|
|
56
|
+
try {
|
|
57
|
+
assertSourceChannel("pigeon");
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const msg = (err as Error).message;
|
|
60
|
+
for (const channel of SOURCE_CHANNELS) {
|
|
61
|
+
expect(msg).toContain(channel);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("error message matches snapshot", () => {
|
|
67
|
+
expect(() => assertSourceChannel("pigeon")).toThrowErrorMatchingInlineSnapshot(
|
|
68
|
+
`[InvalidSourceChannelError: Invalid source channel: 'pigeon'. Valid channels: meeting, email, slack, linear, notion]`,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("SourceChannel type", () => {
|
|
74
|
+
it("narrows correctly via type guard", () => {
|
|
75
|
+
const value: unknown = "meeting";
|
|
76
|
+
if (isSourceChannel(value)) {
|
|
77
|
+
// TypeScript narrows to SourceChannel — compile-time check
|
|
78
|
+
const _channel: SourceChannel = value;
|
|
79
|
+
expect(_channel).toBe("meeting");
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source channel schema — single source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Closed enum: extension requires a code change (PR).
|
|
5
|
+
* Matches the sources-pipeline SourceChannel type.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const SOURCE_CHANNELS = Object.freeze([
|
|
9
|
+
"meeting",
|
|
10
|
+
"email",
|
|
11
|
+
"slack",
|
|
12
|
+
"linear",
|
|
13
|
+
"notion",
|
|
14
|
+
] as const);
|
|
15
|
+
|
|
16
|
+
export type SourceChannel = (typeof SOURCE_CHANNELS)[number];
|
|
17
|
+
|
|
18
|
+
export class InvalidSourceChannelError extends Error {
|
|
19
|
+
override readonly name = "InvalidSourceChannelError";
|
|
20
|
+
|
|
21
|
+
constructor(value: unknown) {
|
|
22
|
+
super(
|
|
23
|
+
`Invalid source channel: '${String(value)}'. Valid channels: ${SOURCE_CHANNELS.join(", ")}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isSourceChannel(value: unknown): value is SourceChannel {
|
|
29
|
+
return typeof value === "string" && (SOURCE_CHANNELS as readonly string[]).includes(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function assertSourceChannel(value: unknown): asserts value is SourceChannel {
|
|
33
|
+
if (!isSourceChannel(value)) {
|
|
34
|
+
throw new InvalidSourceChannelError(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for signals/get.ts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import { GetObjectCommand, type S3Client } from "@aws-sdk/client-s3";
|
|
7
|
+
import { getSignal, SignalNotFoundError } from "./get.js";
|
|
8
|
+
import {
|
|
9
|
+
_setSignalsS3Factory,
|
|
10
|
+
_resetSignalsS3Factory,
|
|
11
|
+
} from "./internals.js";
|
|
12
|
+
import type { EntityContext } from "../types.js";
|
|
13
|
+
import { InvalidSignalTypeError } from "../schemas/signal-types.js";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Fixtures
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const ENTITY: EntityContext = {
|
|
20
|
+
uid: "cmp_test_001",
|
|
21
|
+
slug: "test",
|
|
22
|
+
bucketName: "hq-vault-test",
|
|
23
|
+
region: "us-east-1",
|
|
24
|
+
credentials: {
|
|
25
|
+
accessKeyId: "ASIATEST",
|
|
26
|
+
secretAccessKey: "secret",
|
|
27
|
+
sessionToken: "session",
|
|
28
|
+
},
|
|
29
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ACTION_ITEM_MD = `---
|
|
33
|
+
signal_id: sig_action_001
|
|
34
|
+
signal_type: action_item
|
|
35
|
+
source_ref: abc-meeting-001
|
|
36
|
+
citations:
|
|
37
|
+
- "Stefan to review the sources pipeline implementation by end of week"
|
|
38
|
+
- "Corey to prepare roadmap slides"
|
|
39
|
+
entity_refs:
|
|
40
|
+
- stefan@indigoai.us
|
|
41
|
+
- corey@indigoai.us
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
Stefan will review the sources pipeline implementation.
|
|
45
|
+
|
|
46
|
+
Corey will prepare updated roadmap slides.
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
function installStub(objects: Record<string, string>): void {
|
|
50
|
+
async function send(command: unknown): Promise<unknown> {
|
|
51
|
+
if (command instanceof GetObjectCommand) {
|
|
52
|
+
const { Key } = command.input as { Key: string };
|
|
53
|
+
const content = objects[Key];
|
|
54
|
+
if (content === undefined) {
|
|
55
|
+
const err = new Error(`NoSuchKey: ${Key}`);
|
|
56
|
+
(err as Error & { name: string }).name = "NoSuchKey";
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
async function* stream(): AsyncIterable<Uint8Array> {
|
|
60
|
+
yield Buffer.from(content, "utf-8");
|
|
61
|
+
}
|
|
62
|
+
return { Body: stream(), $metadata: {} };
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`unhandled command`);
|
|
65
|
+
}
|
|
66
|
+
_setSignalsS3Factory(() => ({ send } as unknown as S3Client));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
beforeEach(() => _resetSignalsS3Factory());
|
|
70
|
+
afterEach(() => _resetSignalsS3Factory());
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Tests
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe("getSignal", () => {
|
|
77
|
+
it("happy path: parses frontmatter, body, and surfaces typed cross-refs", async () => {
|
|
78
|
+
installStub({ "signals/action_item/foo.md": ACTION_ITEM_MD });
|
|
79
|
+
|
|
80
|
+
const doc = await getSignal({
|
|
81
|
+
entity: ENTITY,
|
|
82
|
+
signalType: "action_item",
|
|
83
|
+
signalId: "foo",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(doc.key).toBe("signals/action_item/foo.md");
|
|
87
|
+
expect(doc.frontmatter).toMatchObject({
|
|
88
|
+
signal_id: "sig_action_001",
|
|
89
|
+
signal_type: "action_item",
|
|
90
|
+
source_ref: "abc-meeting-001",
|
|
91
|
+
});
|
|
92
|
+
expect(doc.body).toContain("Stefan will review");
|
|
93
|
+
expect(doc.body).toContain("Corey will prepare");
|
|
94
|
+
|
|
95
|
+
// Typed cross-reference fields.
|
|
96
|
+
expect(doc.sourceRef).toBe("abc-meeting-001");
|
|
97
|
+
expect(doc.citations).toEqual([
|
|
98
|
+
"Stefan to review the sources pipeline implementation by end of week",
|
|
99
|
+
"Corey to prepare roadmap slides",
|
|
100
|
+
]);
|
|
101
|
+
expect(doc.entityRefs).toEqual([
|
|
102
|
+
"stefan@indigoai.us",
|
|
103
|
+
"corey@indigoai.us",
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("throws SignalNotFoundError on NoSuchKey", async () => {
|
|
108
|
+
installStub({});
|
|
109
|
+
|
|
110
|
+
await expect(
|
|
111
|
+
getSignal({ entity: ENTITY, signalType: "action_item", signalId: "missing" }),
|
|
112
|
+
).rejects.toBeInstanceOf(SignalNotFoundError);
|
|
113
|
+
|
|
114
|
+
await expect(
|
|
115
|
+
getSignal({ entity: ENTITY, signalType: "action_item", signalId: "missing" }),
|
|
116
|
+
).rejects.toThrow(/signals\/action_item\/missing\.md/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rejects an invalid signalType via assertSignalType BEFORE any S3 call", async () => {
|
|
120
|
+
let called = 0;
|
|
121
|
+
_setSignalsS3Factory(() => ({
|
|
122
|
+
send: async () => {
|
|
123
|
+
called++;
|
|
124
|
+
return {};
|
|
125
|
+
},
|
|
126
|
+
} as unknown as S3Client));
|
|
127
|
+
|
|
128
|
+
await expect(
|
|
129
|
+
getSignal({
|
|
130
|
+
entity: ENTITY,
|
|
131
|
+
// @ts-expect-error — invalid signal type intentional
|
|
132
|
+
signalType: "ramble",
|
|
133
|
+
signalId: "foo",
|
|
134
|
+
}),
|
|
135
|
+
).rejects.toBeInstanceOf(InvalidSignalTypeError);
|
|
136
|
+
expect(called).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns frontmatter:null for malformed (no frontmatter block) documents", async () => {
|
|
140
|
+
installStub({ "signals/action_item/plain.md": "Just a body, no frontmatter." });
|
|
141
|
+
|
|
142
|
+
const doc = await getSignal({
|
|
143
|
+
entity: ENTITY,
|
|
144
|
+
signalType: "action_item",
|
|
145
|
+
signalId: "plain",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(doc.frontmatter).toBeNull();
|
|
149
|
+
expect(doc.body).toBe("Just a body, no frontmatter.");
|
|
150
|
+
expect(doc.sourceRef).toBeUndefined();
|
|
151
|
+
expect(doc.citations).toBeUndefined();
|
|
152
|
+
expect(doc.entityRefs).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("omits cross-ref fields when frontmatter is present but lacks them", async () => {
|
|
156
|
+
const SPARSE = `---
|
|
157
|
+
signal_id: sig_summary_001
|
|
158
|
+
signal_type: summary
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
A summary with no source_ref, citations, or entity_refs.
|
|
162
|
+
`;
|
|
163
|
+
installStub({ "signals/summary/sparse.md": SPARSE });
|
|
164
|
+
|
|
165
|
+
const doc = await getSignal({
|
|
166
|
+
entity: ENTITY,
|
|
167
|
+
signalType: "summary",
|
|
168
|
+
signalId: "sparse",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(doc.frontmatter).toMatchObject({ signal_id: "sig_summary_001" });
|
|
172
|
+
expect(doc.sourceRef).toBeUndefined();
|
|
173
|
+
expect(doc.citations).toBeUndefined();
|
|
174
|
+
expect(doc.entityRefs).toBeUndefined();
|
|
175
|
+
expect(doc.body).toContain("A summary");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("filters non-string entries out of citations + entity_refs", async () => {
|
|
179
|
+
const MIXED = `---
|
|
180
|
+
signal_id: sig_test
|
|
181
|
+
signal_type: action_item
|
|
182
|
+
citations:
|
|
183
|
+
- "string one"
|
|
184
|
+
- 42
|
|
185
|
+
- "string two"
|
|
186
|
+
entity_refs:
|
|
187
|
+
- true
|
|
188
|
+
- "kept@example.com"
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
Body.
|
|
192
|
+
`;
|
|
193
|
+
installStub({ "signals/action_item/mixed.md": MIXED });
|
|
194
|
+
|
|
195
|
+
const doc = await getSignal({
|
|
196
|
+
entity: ENTITY,
|
|
197
|
+
signalType: "action_item",
|
|
198
|
+
signalId: "mixed",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(doc.citations).toEqual(["string one", "string two"]);
|
|
202
|
+
expect(doc.entityRefs).toEqual(["kept@example.com"]);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* getSignal — fetch a single signal document from an entity bucket and
|
|
3
|
+
* return parsed frontmatter + body, with `sourceRef`, `citations`, and
|
|
4
|
+
* `entityRefs` surfaced as typed fields.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
8
|
+
import { assertSignalType } from "../schemas/signal-types.js";
|
|
9
|
+
import { getS3Client, streamToString } from "./internals.js";
|
|
10
|
+
import { parseMarkdown } from "./parse.js";
|
|
11
|
+
import type { GetSignalOptions, SignalDocument } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export class SignalNotFoundError extends Error {
|
|
14
|
+
override readonly name = "SignalNotFoundError";
|
|
15
|
+
constructor(public readonly key: string) {
|
|
16
|
+
super(`Signal not found: ${key}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isNoSuchKey(err: unknown): boolean {
|
|
21
|
+
if (!err || typeof err !== "object") return false;
|
|
22
|
+
const name = (err as { name?: unknown }).name;
|
|
23
|
+
// AWS SDK v3 throws either a `NoSuchKey` exception or a `NotFound` head error.
|
|
24
|
+
return name === "NoSuchKey" || name === "NoSuchKeyException";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function asStringArray(value: unknown): string[] | undefined {
|
|
28
|
+
if (!Array.isArray(value)) return undefined;
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
for (const item of value) {
|
|
31
|
+
if (typeof item === "string") out.push(item);
|
|
32
|
+
}
|
|
33
|
+
return out.length > 0 ? out : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function getSignal(opts: GetSignalOptions): Promise<SignalDocument> {
|
|
37
|
+
// Validate signalType BEFORE any S3 call (acceptance criterion + agent-mitigation).
|
|
38
|
+
assertSignalType(opts.signalType);
|
|
39
|
+
|
|
40
|
+
const client = getS3Client(opts.entity);
|
|
41
|
+
const key = `signals/${opts.signalType}/${opts.signalId}.md`;
|
|
42
|
+
|
|
43
|
+
let body: string;
|
|
44
|
+
try {
|
|
45
|
+
const response = await client.send(
|
|
46
|
+
new GetObjectCommand({
|
|
47
|
+
Bucket: opts.entity.bucketName,
|
|
48
|
+
Key: key,
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
body = await streamToString(response.Body);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (isNoSuchKey(err)) {
|
|
54
|
+
throw new SignalNotFoundError(key);
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const parsed = parseMarkdown(body);
|
|
60
|
+
|
|
61
|
+
const document: SignalDocument = {
|
|
62
|
+
key,
|
|
63
|
+
frontmatter: parsed.frontmatter,
|
|
64
|
+
body: parsed.body,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (parsed.frontmatter) {
|
|
68
|
+
const sourceRef = parsed.frontmatter.source_ref;
|
|
69
|
+
if (typeof sourceRef === "string") {
|
|
70
|
+
document.sourceRef = sourceRef;
|
|
71
|
+
}
|
|
72
|
+
const citations = asStringArray(parsed.frontmatter.citations);
|
|
73
|
+
if (citations) document.citations = citations;
|
|
74
|
+
const entityRefs = asStringArray(parsed.frontmatter.entity_refs);
|
|
75
|
+
if (entityRefs) document.entityRefs = entityRefs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return document;
|
|
79
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal helpers shared between signals/list.ts and signals/get.ts.
|
|
3
|
+
*
|
|
4
|
+
* Independent S3 client factory hook (separate from sources/internals.ts)
|
|
5
|
+
* so signals tests can swap their stub without disturbing sources tests.
|
|
6
|
+
* Production code never calls the setter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
10
|
+
import type { EntityContext } from "../types.js";
|
|
11
|
+
|
|
12
|
+
function defaultFactory(ctx: EntityContext): S3Client {
|
|
13
|
+
return new S3Client({
|
|
14
|
+
region: ctx.region,
|
|
15
|
+
credentials: {
|
|
16
|
+
accessKeyId: ctx.credentials.accessKeyId,
|
|
17
|
+
secretAccessKey: ctx.credentials.secretAccessKey,
|
|
18
|
+
sessionToken: ctx.credentials.sessionToken,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let factory: (ctx: EntityContext) => S3Client = defaultFactory;
|
|
24
|
+
|
|
25
|
+
export function getS3Client(ctx: EntityContext): S3Client {
|
|
26
|
+
return factory(ctx);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Test hook: replace the S3 client factory. */
|
|
30
|
+
export function _setSignalsS3Factory(f: (ctx: EntityContext) => S3Client): void {
|
|
31
|
+
factory = f;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Test hook: restore the default S3 client factory. */
|
|
35
|
+
export function _resetSignalsS3Factory(): void {
|
|
36
|
+
factory = defaultFactory;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function streamToString(body: unknown): Promise<string> {
|
|
40
|
+
const stream = body as AsyncIterable<Uint8Array>;
|
|
41
|
+
const chunks: Buffer[] = [];
|
|
42
|
+
for await (const chunk of stream) {
|
|
43
|
+
chunks.push(Buffer.from(chunk));
|
|
44
|
+
}
|
|
45
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
46
|
+
}
|