@alexkroman1/aai-cli 10.0.1 → 11.0.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/dist/{_bundler-B31MqYaD.mjs → _bundler-C31rlcYz.mjs} +1 -1
- package/dist/{_dev-server-Bo5dSBzQ.mjs → _dev-server-DWvkNoIS.mjs} +3 -3
- package/dist/{_vite-env-BNveawd1.mjs → _vite-env-D9Qj6KPX.mjs} +4 -2
- package/dist/_workflow-determinism-f9J-5Zlj.mjs +206 -0
- package/dist/_workflow-determinism.d.ts +118 -0
- package/dist/{build-DKC2e9o_.mjs → build-BLlLOrng.mjs} +4 -2
- package/dist/cli.mjs +372 -353
- package/dist/{client-bundler-CWnG42cU.mjs → client-bundler-j5c46x0G.mjs} +3 -2
- package/dist/client-bundler.mjs +1 -1
- package/dist/{deploy-CchW5pmw.mjs → deploy-BzZ-r4iM.mjs} +6 -4
- package/dist/{dev-BfVl1uBA.mjs → dev-BDykFvcc.mjs} +1 -1
- package/dist/{eval-DwNwdqmS.mjs → eval-0qEOdm0l.mjs} +1 -1
- package/dist/{init-BRfoc2EK.mjs → init-BtJI306C.mjs} +1 -1
- package/dist/{login-BBuM1sxH.mjs → login-DeUETobb.mjs} +4 -1
- package/dist/scaffold/CLAUDE.md +190 -11
- package/dist/scaffold/package.json +4 -4
- package/dist/{studio-wVWNLREn.mjs → studio-BxAS_FQQ.mjs} +4 -1
- package/dist/templates/call-audit/agent.test.ts +105 -26
- package/dist/templates/call-audit/workflows/audit.ts +9 -26
- package/dist/templates/dispatch-center/client.tsx +36 -2
- package/dist/templates/infocom-adventure/client.tsx +37 -9
- package/dist/templates/link-digest/agent.eval.test.ts +1 -1
- package/dist/templates/link-digest/agent.test.ts +164 -8
- package/dist/templates/link-digest/workflows/digest.ts +63 -20
- package/dist/templates/podcast-digest/agent.eval.test.ts +7 -4
- package/dist/templates/podcast-digest/agent.test.ts +265 -24
- package/dist/templates/podcast-digest/workflows/digest.ts +2 -2
- package/dist/templates/podcast-digest/workflows/feeds.ts +78 -73
- package/dist/templates/recap-workflow/agent.test.ts +206 -20
- package/dist/templates/recap-workflow/workflows/recap.ts +17 -14
- package/dist/templates/redline/agent.test.ts +137 -11
- package/dist/templates/research-workflow/agent.eval.test.ts +1 -1
- package/dist/templates/research-workflow/agent.test.ts +131 -9
- package/dist/templates/research-workflow/workflows/research.ts +1 -1
- package/dist/templates/retail/client.tsx +30 -2
- package/dist/templates/spoken-summary/agent.eval.test.ts +4 -1
- package/dist/templates/spoken-summary/agent.test.ts +117 -8
- package/dist/templates/spoken-summary/workflows/summarize.ts +1 -1
- package/dist/templates/transcription-workflow/agent.test.ts +127 -13
- package/dist/templates/transcription-workflow/workflows/batch.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -3
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +4 -23
- package/dist/{test-CvwgeVSQ.mjs → test-CiLab-AA.mjs} +2 -1
- package/dist/worker-bundler.mjs +1 -1
- package/package.json +25 -24
|
@@ -2,18 +2,21 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* What this template promises, checked without a network.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* only durable once the Workflow DevKit's build has transformed it, so a unit
|
|
7
|
-
* test of it would exercise a plain async function and prove nothing about
|
|
8
|
-
* replay. What IS here:
|
|
5
|
+
* Four kinds of thing are checked:
|
|
9
6
|
*
|
|
10
7
|
* - The DECLARATION — the config a deploy validates and the schema a `start()`
|
|
11
8
|
* is checked against.
|
|
12
9
|
* - The PURE helpers, pulled out of the flow for exactly this reason.
|
|
13
|
-
* - The STEPS, directly. A step is an ordinary exported async function, so
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `
|
|
10
|
+
* - The STEPS, directly. A step is an ordinary exported async function, so its
|
|
11
|
+
* HTTP handling, its partial-failure policy and its `FatalError` guards are
|
|
12
|
+
* all reachable — `installStubStepFetch` answers the network and
|
|
13
|
+
* `stubGateway` answers the model.
|
|
14
|
+
* - The BODY, twice. `createWorkflowCtx` records what it asked for (the digest
|
|
15
|
+
* loop, the shrinking pending set, the sleep BETWEEN digests and never after
|
|
16
|
+
* the last), and `runWorkflow` runs it on the real replay engine — which is
|
|
17
|
+
* what this file used to say a unit test could not do, on the ground that a
|
|
18
|
+
* body "is only durable once the Workflow DevKit's build has transformed it".
|
|
19
|
+
* That stopped being true when the DevKit was replaced.
|
|
17
20
|
*
|
|
18
21
|
* The cases worth having are the ones where a mistake is SILENT: a schema that
|
|
19
22
|
* accepts a webhook pointing anywhere, a Slack payload in the shape the other
|
|
@@ -21,11 +24,18 @@
|
|
|
21
24
|
* that fails to transcribe taking the whole digest down with it.
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
|
-
import {
|
|
27
|
+
import { type FeedItem, parseFeed } from "@alexkroman1/aai/html";
|
|
28
|
+
import {
|
|
29
|
+
createWorkflowCtx,
|
|
30
|
+
parseSchemaInput,
|
|
31
|
+
schemaInputIssues,
|
|
32
|
+
stubGatewayRoute,
|
|
33
|
+
} from "@alexkroman1/aai/testing";
|
|
25
34
|
import {
|
|
26
35
|
installStubStepFetch,
|
|
27
36
|
installStubGateway as stubGateway,
|
|
28
37
|
} from "@alexkroman1/aai/testing/vitest";
|
|
38
|
+
import { runWorkflow } from "@alexkroman1/aai-runtime/testing";
|
|
29
39
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
30
40
|
import agentDef, { dailyDigest } from "./agent.ts";
|
|
31
41
|
import {
|
|
@@ -199,23 +209,53 @@ describe("podcast links", () => {
|
|
|
199
209
|
});
|
|
200
210
|
|
|
201
211
|
describe("reading a feed", () => {
|
|
202
|
-
|
|
212
|
+
/**
|
|
213
|
+
* One entry, through the REAL parse.
|
|
214
|
+
*
|
|
215
|
+
* `episodeFromItem` takes a `FeedItem` now rather than a slice of XML, and
|
|
216
|
+
* building one by hand would test the mapping against a literal somebody
|
|
217
|
+
* typed. Going through `parseFeed` keeps the fixture what a feed actually
|
|
218
|
+
* says, which is where the interesting cases (CDATA, date formats, a missing
|
|
219
|
+
* enclosure) live.
|
|
220
|
+
*/
|
|
221
|
+
const itemOf = (itemXml: string): FeedItem => {
|
|
222
|
+
const item = parseFeed(`<rss><channel>${itemXml}</channel></rss>`)?.items[0];
|
|
223
|
+
if (!item) throw new Error("fixture did not parse as one feed item");
|
|
224
|
+
return item;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const ITEM = itemOf(
|
|
203
228
|
"<item><title>An Episode</title><link>https://example.com/ep</link>" +
|
|
204
|
-
|
|
205
|
-
|
|
229
|
+
'<guid isPermaLink="false">guid-1</guid><pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>' +
|
|
230
|
+
'<enclosure url="https://example.com/a.mp3" type="audio/mpeg"/></item>',
|
|
231
|
+
);
|
|
206
232
|
|
|
207
233
|
test("reads the fields a digest needs off an item", () => {
|
|
208
234
|
expect(episodeFromItem(ITEM, "https://example.com/feed.xml", "Show", 0)).toMatchObject({
|
|
209
235
|
title: "An Episode",
|
|
210
236
|
url: "https://example.com/ep",
|
|
211
237
|
audioUrl: "https://example.com/a.mp3",
|
|
212
|
-
|
|
238
|
+
// ISO, not the RFC 822 the feed wrote — so an RSS and an Atom feed sort
|
|
239
|
+
// against each other instead of against two formats.
|
|
240
|
+
published: "2026-08-21T00:00:00.000Z",
|
|
213
241
|
});
|
|
214
242
|
});
|
|
215
243
|
|
|
244
|
+
test("HTML inside CDATA arrives as text, not as markup", () => {
|
|
245
|
+
// The case `stripCdata` got wrong: it peeled the wrapper and left the
|
|
246
|
+
// entity and the tags for the model to read.
|
|
247
|
+
const item = itemOf(
|
|
248
|
+
"<item><title><![CDATA[Fish & <b>Chips</b>]]></title>" +
|
|
249
|
+
'<enclosure url="https://example.com/a.mp3"/></item>',
|
|
250
|
+
);
|
|
251
|
+
expect(episodeFromItem(item, "https://example.com/feed.xml", "Show", 0)?.title).toBe(
|
|
252
|
+
"Fish & Chips",
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
216
256
|
/** A feed legitimately mixes text posts in; there is nothing to transcribe. */
|
|
217
257
|
test("drops an item with no audio rather than inventing a URL", () => {
|
|
218
|
-
const noAudio = "<item><title>A Post</title><link>https://example.com/p</link></item>";
|
|
258
|
+
const noAudio = itemOf("<item><title>A Post</title><link>https://example.com/p</link></item>");
|
|
219
259
|
expect(episodeFromItem(noAudio, "https://example.com/feed.xml", "Show", 0)).toBeUndefined();
|
|
220
260
|
});
|
|
221
261
|
|
|
@@ -227,13 +267,24 @@ describe("reading a feed", () => {
|
|
|
227
267
|
expect(stableEpisodeId("a", "b")).not.toBe(stableEpisodeId("a", "c"));
|
|
228
268
|
});
|
|
229
269
|
|
|
230
|
-
test("requires BOTH
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
expect(looksLikePodcastFeed(
|
|
270
|
+
test("requires BOTH a parseable feed and an enclosure to call it a podcast", () => {
|
|
271
|
+
const withAudio =
|
|
272
|
+
'<rss><channel><item><title>E</title><enclosure url="https://a.test/a.mp3"/></item></channel></rss>';
|
|
273
|
+
expect(looksLikePodcastFeed(withAudio)).toBe(true);
|
|
274
|
+
// A blog feed: a real feed, but nothing to transcribe.
|
|
275
|
+
expect(
|
|
276
|
+
looksLikePodcastFeed("<rss><channel><item><title>Post</title></item></channel></rss>"),
|
|
277
|
+
).toBe(false);
|
|
234
278
|
expect(looksLikePodcastFeed("<html></html>")).toBe(false);
|
|
235
279
|
});
|
|
236
280
|
|
|
281
|
+
test("an ATOM podcast feed counts, which the `<rss` test refused outright", () => {
|
|
282
|
+
const atom =
|
|
283
|
+
'<feed xmlns="http://www.w3.org/2005/Atom"><title>Show</title>' +
|
|
284
|
+
'<entry><title>E</title><enclosure url="https://a.test/a.mp3"/></entry></feed>';
|
|
285
|
+
expect(looksLikePodcastFeed(atom)).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
237
288
|
test("finds an advertised feed in either attribute order, and resolves it", () => {
|
|
238
289
|
const hrefFirst = '<link href="/feed.xml" type="application/rss+xml">';
|
|
239
290
|
const typeFirst = '<link type="application/rss+xml" href="/feed.xml">';
|
|
@@ -531,21 +582,43 @@ describe("discoverEpisodes", () => {
|
|
|
531
582
|
});
|
|
532
583
|
|
|
533
584
|
test("names the four accepted shapes when a feed carries no episode audio", async () => {
|
|
534
|
-
//
|
|
535
|
-
//
|
|
585
|
+
// Reached through APPLE, which resolves a feed URL from the lookup API and
|
|
586
|
+
// does not run it past `looksLikePodcastFeed` — so this is the one route
|
|
587
|
+
// left to a feed that resolves and yields nothing, and the case a bare "no
|
|
588
|
+
// episodes" would not explain. The plain-web route can no longer get here:
|
|
589
|
+
// the predicate asks whether some ITEM carries audio, which is the same
|
|
590
|
+
// question `readPodcastFeed` filters on, so a text-only feed is refused
|
|
591
|
+
// earlier with a message that names the URL.
|
|
536
592
|
stub({
|
|
593
|
+
"itunes.apple.com/lookup": {
|
|
594
|
+
body: { results: [{ feedUrl: "https://show.test/feed.xml", collectionName: "T" }] },
|
|
595
|
+
},
|
|
537
596
|
"show.test/feed.xml": {
|
|
538
|
-
body:
|
|
539
|
-
'<rss><channel><title>T</title><enclosure url="https://cdn.test/x.mp3"/>' +
|
|
540
|
-
"<item><title>No audio</title></item></channel></rss>",
|
|
597
|
+
body: "<rss><channel><title>T</title><item><title>No audio</title></item></channel></rss>",
|
|
541
598
|
},
|
|
542
599
|
});
|
|
543
600
|
|
|
544
|
-
await expect(
|
|
601
|
+
await expect(
|
|
602
|
+
discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 5),
|
|
603
|
+
).rejects.toThrow(
|
|
545
604
|
/Apple Podcasts show link, a Spotify show link, an RSS feed URL, or a podcast homepage/,
|
|
546
605
|
);
|
|
547
606
|
});
|
|
548
607
|
|
|
608
|
+
test("a text-only feed pasted directly is refused by NAME, not as an empty digest", async () => {
|
|
609
|
+
// The other half of the change above: this used to reach the generic "no
|
|
610
|
+
// episodes" message, and now names the URL the person actually pasted.
|
|
611
|
+
stub({
|
|
612
|
+
"show.test/feed.xml": {
|
|
613
|
+
body: "<rss><channel><title>T</title><item><title>No audio</title></item></channel></rss>",
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
await expect(discoverEpisodes("https://show.test/feed.xml", 5)).rejects.toThrow(
|
|
618
|
+
/Could not find a podcast RSS feed at https:\/\/show\.test\/feed\.xml/,
|
|
619
|
+
);
|
|
620
|
+
});
|
|
621
|
+
|
|
549
622
|
test("propagates a feed that will not load, so the run can retry it", async () => {
|
|
550
623
|
stub({ "show.test/feed.xml": { status: 503, body: "busy" } });
|
|
551
624
|
await expect(discoverEpisodes("https://show.test/feed.xml", 1)).rejects.toThrow(/503/);
|
|
@@ -820,3 +893,171 @@ describe("the body — the run that IS the schedule", () => {
|
|
|
820
893
|
for (const sleep of ctx.slept) expect(sleep.until).toBe(POLL_DELAY_MS);
|
|
821
894
|
});
|
|
822
895
|
});
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* The schedule, on the real replay engine.
|
|
899
|
+
*
|
|
900
|
+
* The block above drives the same body through `createWorkflowCtx`, which
|
|
901
|
+
* RECORDS a sleep rather than taking one and replays nothing — right for the
|
|
902
|
+
* loop's logic, and silent about the property this template is: a run that
|
|
903
|
+
* sleeps for a day between digests and comes back. `runWorkflow`
|
|
904
|
+
* (`@alexkroman1/aai-runtime/testing`) is the real engine over an in-memory
|
|
905
|
+
* journal, so the run genuinely parks, genuinely resumes off its journal, and
|
|
906
|
+
* genuinely does not redo the digest it already posted.
|
|
907
|
+
*
|
|
908
|
+
* Two sleeps are in play and they are different things, which is exactly what a
|
|
909
|
+
* recorded one cannot show: the POLL cadence inside `waitForTranscripts`, and
|
|
910
|
+
* the SCHEDULE interval between digests. Both are `ctx.sleep`, so both end with
|
|
911
|
+
* `advanceSleep()` — the same call `ctx.workflows.wakeUp` makes.
|
|
912
|
+
*/
|
|
913
|
+
describe("the run is DURABLE", () => {
|
|
914
|
+
const FEED = feedXml(itemXml("newest", "Thu, 03 Jan 2030 00:00:00 GMT"));
|
|
915
|
+
const SUMMARY = '{"summary":"Otters are clever.","keyPoints":["They use tools","They float"]}';
|
|
916
|
+
|
|
917
|
+
const SCHEDULE = {
|
|
918
|
+
...VALID,
|
|
919
|
+
slackWorkflowTextParam: "text",
|
|
920
|
+
maxEpisodesPerDigest: 1,
|
|
921
|
+
intervalEvery: 2,
|
|
922
|
+
intervalUnit: "hours" as const,
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* The feed, the provider, the model and Slack, behind ONE published
|
|
927
|
+
* `stepFetch`.
|
|
928
|
+
*
|
|
929
|
+
* Every step's HTTP goes through the slot, the model call included, so a
|
|
930
|
+
* gateway stub over `globalThis.fetch` beside these routes would be bypassed.
|
|
931
|
+
* `stubGatewayRoute` is the composition for it — the same note
|
|
932
|
+
* `link-digest/agent.test.ts` carries.
|
|
933
|
+
*
|
|
934
|
+
* `polls` is how many `GET /v2/transcript/:id` answers say "not yet" before
|
|
935
|
+
* the job is reported done, which is how a case decides whether the run has
|
|
936
|
+
* to sit through the poll cadence at all.
|
|
937
|
+
*/
|
|
938
|
+
function stubWorld({ polls = 0 }: { polls?: number } = {}) {
|
|
939
|
+
const model = stubGatewayRoute(SUMMARY);
|
|
940
|
+
const seen = { feeds: 0, submits: 0, slack: 0, polls: 0 };
|
|
941
|
+
installStubStepFetch((request) => {
|
|
942
|
+
const routed = model.route(request);
|
|
943
|
+
if (routed) return routed;
|
|
944
|
+
if (request.url.includes("hooks.slack.com")) {
|
|
945
|
+
seen.slack += 1;
|
|
946
|
+
return { status: 200, body: "ok" };
|
|
947
|
+
}
|
|
948
|
+
if (request.url.includes("/v2/transcript")) {
|
|
949
|
+
if (request.method === "POST") {
|
|
950
|
+
seen.submits += 1;
|
|
951
|
+
return { status: 200, body: { id: "t_1", status: "queued" } };
|
|
952
|
+
}
|
|
953
|
+
seen.polls += 1;
|
|
954
|
+
return seen.polls <= polls
|
|
955
|
+
? { status: 200, body: { id: "t_1", status: "processing" } }
|
|
956
|
+
: { status: 200, body: { id: "t_1", status: "completed", text: "Otters use tools." } };
|
|
957
|
+
}
|
|
958
|
+
seen.feeds += 1;
|
|
959
|
+
return { status: 200, body: FEED, headers: { "Content-Type": "application/xml" } };
|
|
960
|
+
});
|
|
961
|
+
return { model, seen };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
beforeEach(() => vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key"));
|
|
965
|
+
|
|
966
|
+
test("a one-digest run ends without sleeping, and posts once", async () => {
|
|
967
|
+
// The negative half of the schedule claim, and the cheap one: a run that has
|
|
968
|
+
// delivered everything it owes should END, not sleep for a day and then end.
|
|
969
|
+
const world = stubWorld();
|
|
970
|
+
const run = await runWorkflow(
|
|
971
|
+
dailyDigest,
|
|
972
|
+
{ ...SCHEDULE, daysToRun: 1 },
|
|
973
|
+
{ name: "dailyDigest" },
|
|
974
|
+
);
|
|
975
|
+
|
|
976
|
+
expect(run.status).toBe("completed");
|
|
977
|
+
expect(run.output?.digestsSent).toBe(1);
|
|
978
|
+
expect(run.deliveries).toBe(1);
|
|
979
|
+
expect(run.wakeAt).toBeUndefined();
|
|
980
|
+
expect(world.seen.slack).toBe(1);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
test("parks on the poll cadence, and the resume does not re-submit the episode", async () => {
|
|
984
|
+
// The transcription is already paid for; a resume that submitted again would
|
|
985
|
+
// buy it twice and get a different job id.
|
|
986
|
+
const world = stubWorld({ polls: 1 });
|
|
987
|
+
const run = await runWorkflow(
|
|
988
|
+
dailyDigest,
|
|
989
|
+
{ ...SCHEDULE, daysToRun: 1 },
|
|
990
|
+
{ name: "dailyDigest" },
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
expect(run.status).toBe("running");
|
|
994
|
+
expect(run.wakeAt).toBeGreaterThan(Date.now());
|
|
995
|
+
expect(world.seen.submits).toBe(1);
|
|
996
|
+
expect(run.steps.map((step) => step.key)).toEqual([
|
|
997
|
+
"discoverEpisodes#0",
|
|
998
|
+
"pollTranscript#0",
|
|
999
|
+
"submitTranscript#0",
|
|
1000
|
+
]);
|
|
1001
|
+
|
|
1002
|
+
await run.advanceSleep();
|
|
1003
|
+
expect(run.status).toBe("completed");
|
|
1004
|
+
expect(run.output?.digestsSent).toBe(1);
|
|
1005
|
+
// ONE submit across two walks — the first is read back out of the journal.
|
|
1006
|
+
expect(world.seen.submits).toBe(1);
|
|
1007
|
+
expect(world.seen.polls).toBe(2);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test("sleeps the SCHEDULE between digests, then runs the second one", async () => {
|
|
1011
|
+
const world = stubWorld();
|
|
1012
|
+
const started = Date.now();
|
|
1013
|
+
const run = await runWorkflow(
|
|
1014
|
+
dailyDigest,
|
|
1015
|
+
{ ...SCHEDULE, daysToRun: 2 },
|
|
1016
|
+
{ name: "dailyDigest" },
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
// Digest one is posted and the run is parked on the interval — two hours,
|
|
1020
|
+
// which nothing here waits for.
|
|
1021
|
+
expect(run.status).toBe("running");
|
|
1022
|
+
expect(run.wakeAt).toBeGreaterThanOrEqual(started + scheduleIntervalMs(2, "hours"));
|
|
1023
|
+
expect(world.seen.slack).toBe(1);
|
|
1024
|
+
expect(run.output).toBeUndefined();
|
|
1025
|
+
|
|
1026
|
+
await run.advanceSleep();
|
|
1027
|
+
expect(run.status).toBe("completed");
|
|
1028
|
+
expect(run.output?.digestsSent).toBe(2);
|
|
1029
|
+
expect(run.deliveries).toBe(2);
|
|
1030
|
+
// TWO posts, and the second digest did its own discovery — the loop's second
|
|
1031
|
+
// turn is `discoverEpisodes#1`, a different journal entry from the first, so
|
|
1032
|
+
// a schedule really re-reads the feed rather than replaying yesterday's.
|
|
1033
|
+
expect(world.seen.slack).toBe(2);
|
|
1034
|
+
expect(run.steps.map((step) => step.key)).toContain("discoverEpisodes#1");
|
|
1035
|
+
// And digest one's post is NOT made a second time: one `postDigest` entry
|
|
1036
|
+
// per digest, both journaled.
|
|
1037
|
+
expect(run.steps.filter((step) => step.name === "postDigest")).toHaveLength(2);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
test("a worker that dies before the digest is posted replays the transcription", async () => {
|
|
1041
|
+
// The expensive half. Transcribing and summarizing an episode is minutes of
|
|
1042
|
+
// provider time; a resume must reach Slack without paying for either again.
|
|
1043
|
+
const world = stubWorld();
|
|
1044
|
+
const run = await runWorkflow(
|
|
1045
|
+
dailyDigest,
|
|
1046
|
+
{ ...SCHEDULE, daysToRun: 1 },
|
|
1047
|
+
{ name: "dailyDigest", crashAt: "postDigest" },
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
expect(run.crashed).toBe(true);
|
|
1051
|
+
expect(world.seen.slack).toBe(0);
|
|
1052
|
+
expect(world.seen.submits).toBe(1);
|
|
1053
|
+
expect(world.model.calls).toHaveLength(1);
|
|
1054
|
+
|
|
1055
|
+
await run.restart();
|
|
1056
|
+
expect(run.status).toBe("completed");
|
|
1057
|
+
expect(run.output?.digestsSent).toBe(1);
|
|
1058
|
+
expect(world.seen.slack).toBe(1);
|
|
1059
|
+
// Neither the provider nor the model was asked a second time.
|
|
1060
|
+
expect(world.seen.submits).toBe(1);
|
|
1061
|
+
expect(world.model.calls).toHaveLength(1);
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
@@ -227,7 +227,7 @@ export async function dailyDigestFlow(
|
|
|
227
227
|
|
|
228
228
|
// Not after the last one: a run that has delivered everything it owes
|
|
229
229
|
// should end, not sleep for a day and then end.
|
|
230
|
-
if (digestNumber < totalDigests) await ctx.sleep(intervalMs);
|
|
230
|
+
if (digestNumber < totalDigests) await ctx.sleep("nextDigest", intervalMs);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
return {
|
|
@@ -284,7 +284,7 @@ async function waitForTranscripts(
|
|
|
284
284
|
if (state.transcriptStatus !== "submitted") settled.set(state.id, state);
|
|
285
285
|
}
|
|
286
286
|
pending = polled.filter((state) => state.transcriptStatus === "submitted");
|
|
287
|
-
if (pending.length > 0) await ctx.sleep(POLL_DELAY_MS);
|
|
287
|
+
if (pending.length > 0) await ctx.sleep("poll", POLL_DELAY_MS);
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
for (const job of pending) settled.set(job.id, gaveUpOn(job));
|
|
@@ -33,20 +33,41 @@
|
|
|
33
33
|
* somebody runs it. A template may import the SDK, `workflow`, `zod` and React.
|
|
34
34
|
* Anything else has to earn a place in the scaffold manifest first.
|
|
35
35
|
*
|
|
36
|
-
* ##
|
|
36
|
+
* ## A real parse, once the SDK could offer one
|
|
37
37
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
38
|
+
* The feed reading below used to be regular expressions over the raw body, on
|
|
39
|
+
* the argument that "there is no DOM in a step artifact and no XML parser in
|
|
40
|
+
* the scaffold". The second half was the load-bearing one and it stopped being
|
|
41
|
+
* true: `@alexkroman1/aai/html` publishes the parsers the SDK already carried
|
|
42
|
+
* for its own builtins, so a template can reach them under the same rule as
|
|
43
|
+
* `zod` — it is the SDK.
|
|
44
|
+
*
|
|
45
|
+
* Three things the patterns got wrong, all of them on feeds that exist:
|
|
46
|
+
*
|
|
47
|
+
* - `textBetween(xml, "<title>", "</title>")` read the FIRST `<title>` at any
|
|
48
|
+
* depth. In an Atom feed that is the channel's only by luck of ordering; the
|
|
49
|
+
* same read on an entry-first document answers with an episode's title.
|
|
50
|
+
* - `stripCdata` peeled `<![CDATA[…]]>` and left the HTML inside it. Show notes
|
|
51
|
+
* are HTML in CDATA as a matter of course, so `&` reached the model as an
|
|
52
|
+
* entity and `<p>` tags reached it as tags.
|
|
53
|
+
* - `<pubDate>` and `<published>` were tried in turn and the raw string handed
|
|
54
|
+
* on, so a caller had to parse a date whose format depended on the feed
|
|
55
|
+
* (RFC 822 from RSS, ISO from Atom).
|
|
56
|
+
*
|
|
57
|
+
* What the patterns got RIGHT is kept: {@link episodeFromItem} still drops an
|
|
58
|
+
* entry it cannot read a media URL from rather than inventing one, because a
|
|
59
|
+
* feed legitimately mixes text posts in with episodes.
|
|
60
|
+
*
|
|
61
|
+
* One regex remains, in {@link extractAppleSerializedFeed}, and it is a
|
|
62
|
+
* different kind of thing: it reaches into ONE vendor's `<script>` tag for a
|
|
63
|
+
* JSON blob, which is vendor knowledge with a short half-life rather than XML
|
|
64
|
+
* parsing. It is the section below's subject, not this one's.
|
|
45
65
|
*/
|
|
46
66
|
|
|
67
|
+
import { type FeedItem, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
|
|
47
68
|
import { report } from "@alexkroman1/aai/step";
|
|
48
69
|
import { FatalError, stepFetchOk } from "@alexkroman1/aai/step-errors";
|
|
49
|
-
import {
|
|
70
|
+
import { isRecord, omitUndefined, safeJsonParse } from "@alexkroman1/aai/utils";
|
|
50
71
|
|
|
51
72
|
/** How long any one of these lookups may take before it is a failure. */
|
|
52
73
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
@@ -190,8 +211,11 @@ async function resolveApplePodcastFeed(url: string): Promise<PodcastFeed> {
|
|
|
190
211
|
*/
|
|
191
212
|
async function resolveSpotifyPodcastFeed(url: string): Promise<PodcastFeed> {
|
|
192
213
|
const html = await fetchText(url);
|
|
193
|
-
|
|
194
|
-
|
|
214
|
+
// `pageMetadata` already prefers `og:title` over the `<title>` element and
|
|
215
|
+
// falls back to it, which is exactly the two-step this spelled out.
|
|
216
|
+
const meta = pageMetadata(html);
|
|
217
|
+
const title = meta.title ?? "";
|
|
218
|
+
const description = meta.description ?? "";
|
|
195
219
|
const query = `${title} ${description}`.replace(/\s+/g, " ").trim();
|
|
196
220
|
if (!query) throw new FatalError(`Could not read podcast metadata from ${url}.`);
|
|
197
221
|
|
|
@@ -212,45 +236,42 @@ async function resolveSpotifyPodcastFeed(url: string): Promise<PodcastFeed> {
|
|
|
212
236
|
/** Every item in the feed that has audio attached, newest first by the caller. */
|
|
213
237
|
async function readPodcastFeed(feed: PodcastFeed): Promise<Episode[]> {
|
|
214
238
|
const xml = feed.xml ?? (await fetchText(feed.feedUrl));
|
|
215
|
-
const
|
|
239
|
+
const parsed = parseFeed(xml);
|
|
240
|
+
// `parsed.title` is the CHANNEL's, which is the whole reason to parse: the
|
|
241
|
+
// `indexOf` read this replaces took the first `<title>` at any depth.
|
|
242
|
+
const podcastTitle = parsed?.title ?? feed.title;
|
|
216
243
|
|
|
217
|
-
return
|
|
218
|
-
.map((
|
|
244
|
+
return (parsed?.items ?? [])
|
|
245
|
+
.map((item, index) => episodeFromItem(item, feed.feedUrl, podcastTitle, index))
|
|
219
246
|
.filter((episode): episode is Episode => episode !== undefined);
|
|
220
247
|
}
|
|
221
248
|
|
|
222
|
-
/** One
|
|
249
|
+
/** One feed entry, or nothing when it carries no audio to transcribe. */
|
|
223
250
|
export function episodeFromItem(
|
|
224
|
-
item:
|
|
251
|
+
item: FeedItem,
|
|
225
252
|
feedUrl: string,
|
|
226
253
|
podcastTitle: string,
|
|
227
254
|
index: number,
|
|
228
255
|
): Episode | undefined {
|
|
229
|
-
const audioUrl = enclosureUrl
|
|
256
|
+
const audioUrl = item.enclosureUrl;
|
|
230
257
|
// Not an error: a feed legitimately mixes text posts in with episodes, and
|
|
231
258
|
// there is nothing for this run to do with one.
|
|
232
|
-
if (
|
|
259
|
+
if (audioUrl === undefined) return undefined;
|
|
233
260
|
|
|
234
|
-
const
|
|
235
|
-
textBetween(item, "<guid", "</guid>")?.replace(/^[^>]*>/, "") ?? "",
|
|
236
|
-
).trim();
|
|
237
|
-
const link = decodeHtmlEntities(stripCdata(textBetween(item, "<link>", "</link>") ?? audioUrl));
|
|
261
|
+
const link = item.link ?? audioUrl;
|
|
238
262
|
return {
|
|
239
|
-
id
|
|
263
|
+
// `item.id` is the feed's `<guid>` or Atom `<id>`, already falling back to
|
|
264
|
+
// the link and then the enclosure — which is the three-way fallback this
|
|
265
|
+
// function used to spell out.
|
|
266
|
+
id: stableEpisodeId(feedUrl, item.id ?? audioUrl),
|
|
240
267
|
feedUrl,
|
|
241
268
|
podcastTitle,
|
|
242
|
-
title:
|
|
243
|
-
stripCdata(textBetween(item, "<title>", "</title>") ?? `Episode ${index + 1}`),
|
|
244
|
-
),
|
|
269
|
+
title: item.title ?? `Episode ${index + 1}`,
|
|
245
270
|
url: link,
|
|
246
|
-
audioUrl
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
textBetween(item, "<published>", "</published>") ??
|
|
251
|
-
"",
|
|
252
|
-
),
|
|
253
|
-
),
|
|
271
|
+
audioUrl,
|
|
272
|
+
// ISO whatever the feed wrote, so `publishedAt` below sorts RSS and Atom
|
|
273
|
+
// against each other rather than against two different formats.
|
|
274
|
+
published: item.published ?? "",
|
|
254
275
|
};
|
|
255
276
|
}
|
|
256
277
|
|
|
@@ -371,21 +392,32 @@ export function titleMatchesSpotify(
|
|
|
371
392
|
);
|
|
372
393
|
}
|
|
373
394
|
|
|
374
|
-
/**
|
|
395
|
+
/** A feed document that actually carries audio — both halves are required. */
|
|
375
396
|
export function looksLikePodcastFeed(xml: string): boolean {
|
|
376
|
-
|
|
397
|
+
const parsed = parseFeed(xml);
|
|
398
|
+
// `parseFeed` answering at all is the "is this a feed" half, and it is
|
|
399
|
+
// stricter than the `<rss` test it replaces in the direction that matters:
|
|
400
|
+
// an HTML page mentioning `<rss` in prose is not a feed. It is also wider
|
|
401
|
+
// where being wide is right — an Atom podcast feed has no `<rss` root and
|
|
402
|
+
// was refused outright.
|
|
403
|
+
return parsed?.items.some((item) => item.enclosureUrl !== undefined) ?? false;
|
|
377
404
|
}
|
|
378
405
|
|
|
379
|
-
/** The feed a page advertises,
|
|
406
|
+
/** The first feed a page advertises, resolved against the page's own URL. */
|
|
380
407
|
export function discoverFeedUrl(html: string, pageUrl: string): string | undefined {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
408
|
+
// `pageMetadata` reads `<link>` off a parse, so attribute ORDER stops
|
|
409
|
+
// mattering — this needed one regex per order and still could not see a `>`
|
|
410
|
+
// inside a quoted href. It hands hrefs back exactly as the page wrote them,
|
|
411
|
+
// which is why the resolution is here: only the caller knows `pageUrl`.
|
|
412
|
+
const href = pageMetadata(html).feedUrls[0];
|
|
413
|
+
if (href === undefined) return undefined;
|
|
414
|
+
try {
|
|
415
|
+
return new URL(href, pageUrl).toString();
|
|
416
|
+
} catch {
|
|
417
|
+
// A page can advertise an href that is not a URL under any base. That is
|
|
418
|
+
// "no feed found", which the caller already has a sentence for.
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
389
421
|
}
|
|
390
422
|
|
|
391
423
|
/**
|
|
@@ -411,7 +443,7 @@ function publishedAt(episode: Episode): number {
|
|
|
411
443
|
function feedFrom(feedUrl: string, body: string): PodcastFeed {
|
|
412
444
|
return {
|
|
413
445
|
feedUrl,
|
|
414
|
-
title:
|
|
446
|
+
title: parseFeed(body)?.title ?? hostOf(feedUrl),
|
|
415
447
|
xml: body,
|
|
416
448
|
};
|
|
417
449
|
}
|
|
@@ -424,10 +456,6 @@ function hostOf(url: string): string {
|
|
|
424
456
|
}
|
|
425
457
|
}
|
|
426
458
|
|
|
427
|
-
function enclosureUrl(item: string): string | undefined {
|
|
428
|
-
return /<enclosure\b[^>]*\burl=["']([^"']+)["'][^>]*>/i.exec(item)?.[1];
|
|
429
|
-
}
|
|
430
|
-
|
|
431
459
|
function normalizeTitle(value: string): string {
|
|
432
460
|
return value
|
|
433
461
|
.toLowerCase()
|
|
@@ -458,29 +486,6 @@ function asString(value: unknown): string | undefined {
|
|
|
458
486
|
return typeof value === "string" ? value : undefined;
|
|
459
487
|
}
|
|
460
488
|
|
|
461
|
-
export function textBetween(text: string, start: string, end: string): string | undefined {
|
|
462
|
-
const from = text.indexOf(start);
|
|
463
|
-
if (from < 0) return undefined;
|
|
464
|
-
const to = text.indexOf(end, from + start.length);
|
|
465
|
-
return to < 0 ? undefined : text.slice(from + start.length, to);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
export function metaContent(html: string, property: string): string | undefined {
|
|
469
|
-
const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
470
|
-
const match =
|
|
471
|
-
new RegExp(`<meta[^>]+property=["']${escaped}["'][^>]+content=["']([^"']+)["']`, "i").exec(
|
|
472
|
-
html,
|
|
473
|
-
) ??
|
|
474
|
-
new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${escaped}["']`, "i").exec(
|
|
475
|
-
html,
|
|
476
|
-
);
|
|
477
|
-
return decodeHtmlEntities(match?.[1] ?? "").trim() || undefined;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function stripCdata(text: string): string {
|
|
481
|
-
return text.replace(/^<!\[CDATA\[/, "").replace(/\]\]>$/, "");
|
|
482
|
-
}
|
|
483
|
-
|
|
484
489
|
// ---- HTTP -------------------------------------------------------------------
|
|
485
490
|
|
|
486
491
|
/**
|