skilldrop-cli 0.16.21 → 0.16.23
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/bin/skilldrop.js +5 -3
- package/catalogue.json +1 -0
- package/model-routing.json +4 -0
- package/package.json +1 -1
- package/packs/solution-architect/skills/interactive-diagram/SKILL.md +160 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/eval_queries.json +12 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/evals.json +164 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/1/README.md +11 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/1/api/app.py +43 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/1/docker-compose.yml +20 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/1/web/checkout.js +9 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/1/worker/worker.py +23 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/10/build-4127-timing.json +179 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/AbstractSensor.java +36 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/AlertService.java +15 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Greenhouse.java +36 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Sensor.java +10 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/SoilMoistureSensor.java +25 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/TemperatureSensor.java +19 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Zone.java +26 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/3/schema.sql +35 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/4/README.md +26 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/4/config/default.json +29 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/clients/transcription.js +25 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/db.js +16 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/handlers/storageWebhook.js +43 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/jobs/transcribeEpisode.js +28 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/mailer.js +18 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/server.js +32 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/worker.js +32 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/6/notes.txt +7 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/6/trace-quote-slow.jsonl +13 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/7/oncall-notes.md +16 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/7/system-log.txt +14 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/8/run-4471.json +151 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/9/chat-export.txt +16 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/9/lesson-sync.log +24 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/9/ticket-8812.csv +6 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.html +203 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.json +48 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.md +31 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/checkout-trace.html +203 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/checkout-trace.json +110 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/incident-timeline.html +203 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/incident-timeline.json +21 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/inputs/checkout/api/checkout.py +41 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/inputs/checkout/docker-compose.yml +20 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/inputs/checkout/worker/fulfil.py +23 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/inputs/incident/oncall-notes.md +14 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/inputs/trace/checkout-trace.jsonl +9 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/leave-approval.html +203 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/leave-approval.json +28 -0
- package/packs/solution-architect/skills/interactive-diagram/manifest.json +39 -0
- package/packs/solution-architect/skills/interactive-diagram/reference.md +231 -0
- package/packs/solution-architect/skills/interactive-diagram/scripts/build_diagram.py +2605 -0
package/packs/solution-architect/skills/interactive-diagram/evals/files/10/build-4127-timing.json
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
{
|
|
2
|
+
"exporter": "Ridgeback CI timing export v2",
|
|
3
|
+
"pipeline": "lantern-nightly",
|
|
4
|
+
"build": "4127",
|
|
5
|
+
"started_at": "2026-10-18T02:00:00Z",
|
|
6
|
+
"result": "success",
|
|
7
|
+
"time_unit": "ms, relative to started_at",
|
|
8
|
+
"steps": [
|
|
9
|
+
{
|
|
10
|
+
"id": 1,
|
|
11
|
+
"parent_id": null,
|
|
12
|
+
"name": "build-4127",
|
|
13
|
+
"agent": "ci-coord-01",
|
|
14
|
+
"start_ms": 0,
|
|
15
|
+
"end_ms": 2760000
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": 2,
|
|
19
|
+
"parent_id": 1,
|
|
20
|
+
"name": "sync_sources",
|
|
21
|
+
"agent": "build-agent-07",
|
|
22
|
+
"start_ms": 0,
|
|
23
|
+
"end_ms": 140000,
|
|
24
|
+
"avg_cpu_pct": 21
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": 3,
|
|
28
|
+
"parent_id": 1,
|
|
29
|
+
"name": "compile",
|
|
30
|
+
"agent": "build-agent-07",
|
|
31
|
+
"start_ms": 140000,
|
|
32
|
+
"end_ms": 1040000
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": 4,
|
|
36
|
+
"parent_id": 3,
|
|
37
|
+
"name": "compile_engine",
|
|
38
|
+
"agent": "build-agent-07",
|
|
39
|
+
"start_ms": 140000,
|
|
40
|
+
"end_ms": 1040000,
|
|
41
|
+
"avg_cpu_pct": 94
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": 5,
|
|
45
|
+
"parent_id": 3,
|
|
46
|
+
"name": "compile_gameplay",
|
|
47
|
+
"agent": "build-agent-11",
|
|
48
|
+
"start_ms": 140000,
|
|
49
|
+
"end_ms": 620000,
|
|
50
|
+
"avg_cpu_pct": 91
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": 6,
|
|
54
|
+
"parent_id": 3,
|
|
55
|
+
"name": "compile_editor_tools",
|
|
56
|
+
"agent": "build-agent-12",
|
|
57
|
+
"start_ms": 140000,
|
|
58
|
+
"end_ms": 470000,
|
|
59
|
+
"avg_cpu_pct": 88
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": 7,
|
|
63
|
+
"parent_id": 3,
|
|
64
|
+
"name": "upload_debug_symbols",
|
|
65
|
+
"agent": "build-agent-11",
|
|
66
|
+
"start_ms": 620000,
|
|
67
|
+
"end_ms": 1460000,
|
|
68
|
+
"avg_cpu_pct": 6,
|
|
69
|
+
"blocking": false
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": 8,
|
|
73
|
+
"parent_id": 1,
|
|
74
|
+
"name": "cook_assets",
|
|
75
|
+
"agent": "build-agent-07",
|
|
76
|
+
"start_ms": 1040000,
|
|
77
|
+
"end_ms": 1400000
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": 9,
|
|
81
|
+
"parent_id": 8,
|
|
82
|
+
"name": "cook_textures",
|
|
83
|
+
"agent": "build-agent-07",
|
|
84
|
+
"start_ms": 1040000,
|
|
85
|
+
"end_ms": 1400000,
|
|
86
|
+
"avg_cpu_pct": 92
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": 10,
|
|
90
|
+
"parent_id": 8,
|
|
91
|
+
"name": "compile_shaders",
|
|
92
|
+
"agent": "build-agent-14",
|
|
93
|
+
"start_ms": 1040000,
|
|
94
|
+
"end_ms": 1220000,
|
|
95
|
+
"avg_cpu_pct": 95
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"id": 11,
|
|
99
|
+
"parent_id": 8,
|
|
100
|
+
"name": "cook_audio",
|
|
101
|
+
"agent": "build-agent-15",
|
|
102
|
+
"start_ms": 1040000,
|
|
103
|
+
"end_ms": 1160000,
|
|
104
|
+
"avg_cpu_pct": 63
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"id": 12,
|
|
108
|
+
"parent_id": 1,
|
|
109
|
+
"name": "bake_lighting",
|
|
110
|
+
"agent": "bake-agent-02",
|
|
111
|
+
"start_ms": 1400000,
|
|
112
|
+
"end_ms": 2040000,
|
|
113
|
+
"avg_cpu_pct": 97
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"id": 13,
|
|
117
|
+
"parent_id": 1,
|
|
118
|
+
"name": "package",
|
|
119
|
+
"agent": "build-agent-07",
|
|
120
|
+
"start_ms": 2040000,
|
|
121
|
+
"end_ms": 2760000
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"id": 14,
|
|
125
|
+
"parent_id": 13,
|
|
126
|
+
"name": "sign_binaries",
|
|
127
|
+
"agent": "sign-02",
|
|
128
|
+
"start_ms": 2580000,
|
|
129
|
+
"end_ms": 2670000,
|
|
130
|
+
"avg_cpu_pct": 41
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"id": 15,
|
|
134
|
+
"parent_id": 13,
|
|
135
|
+
"name": "make_installer",
|
|
136
|
+
"agent": "build-agent-07",
|
|
137
|
+
"start_ms": 2670000,
|
|
138
|
+
"end_ms": 2760000,
|
|
139
|
+
"avg_cpu_pct": 78
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
"events": [
|
|
143
|
+
{
|
|
144
|
+
"t_ms": 620000,
|
|
145
|
+
"step_id": 7,
|
|
146
|
+
"msg": "upload started in background (blocking=false); 3.8 GB of .pdb files for gameplay and editor_tools"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"t_ms": 1040000,
|
|
150
|
+
"step_id": 10,
|
|
151
|
+
"msg": "compile_shaders: 2,214 permutations, 1,980 from shader cache, 234 compiled (includes 61 for water_surface)"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"t_ms": 1400000,
|
|
155
|
+
"step_id": 12,
|
|
156
|
+
"msg": "bake_lighting: lightmap cache miss for 38 of 38 levels (cache key changed); baking all"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"t_ms": 1460000,
|
|
160
|
+
"step_id": 7,
|
|
161
|
+
"msg": "upload finished"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"t_ms": 2040000,
|
|
165
|
+
"step_id": 13,
|
|
166
|
+
"msg": "waiting for a free agent in pool 'sign-pool' (0 of 2 free)"
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"t_ms": 2580000,
|
|
170
|
+
"step_id": 13,
|
|
171
|
+
"msg": "agent sign-02 acquired after 540000 ms in queue"
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"t_ms": 2760000,
|
|
175
|
+
"step_id": 1,
|
|
176
|
+
"msg": "build succeeded"
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package org.example.greenhouse;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared range checking and calibration for every sensor.
|
|
5
|
+
* Subclasses only supply the raw value.
|
|
6
|
+
*/
|
|
7
|
+
public abstract class AbstractSensor implements Sensor {
|
|
8
|
+
protected final String id;
|
|
9
|
+
private final double min;
|
|
10
|
+
private final double max;
|
|
11
|
+
private final double offset;
|
|
12
|
+
|
|
13
|
+
protected AbstractSensor(String id, double min, double max, double offset) {
|
|
14
|
+
this.id = id;
|
|
15
|
+
this.min = min;
|
|
16
|
+
this.max = max;
|
|
17
|
+
this.offset = offset;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@Override
|
|
21
|
+
public String id() {
|
|
22
|
+
return id;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Override
|
|
26
|
+
public final double read() {
|
|
27
|
+
return rawValue() + offset;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Override
|
|
31
|
+
public boolean isOutOfRange(double value) {
|
|
32
|
+
return value < min || value > max;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
protected abstract double rawValue();
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
package org.example.greenhouse;
|
|
2
|
+
|
|
3
|
+
/** Shared by every greenhouse on the site; created once at startup. */
|
|
4
|
+
public class AlertService {
|
|
5
|
+
private int raised;
|
|
6
|
+
|
|
7
|
+
public void raise(String greenhouse, String zone, String sensorId, double value) {
|
|
8
|
+
raised++;
|
|
9
|
+
System.out.printf("ALERT %s/%s %s=%.1f%n", greenhouse, zone, sensorId, value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
public int raisedCount() {
|
|
13
|
+
return raised;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package org.example.greenhouse;
|
|
2
|
+
|
|
3
|
+
import java.util.ArrayList;
|
|
4
|
+
import java.util.List;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A greenhouse owns its zones and raises alerts through a shared AlertService.
|
|
8
|
+
* Watering for each zone is planned by {@link WateringSchedule}.
|
|
9
|
+
*/
|
|
10
|
+
public class Greenhouse {
|
|
11
|
+
private final String name;
|
|
12
|
+
private final List<Zone> zones = new ArrayList<>();
|
|
13
|
+
private final AlertService alerts;
|
|
14
|
+
|
|
15
|
+
public Greenhouse(String name, AlertService alerts) {
|
|
16
|
+
this.name = name;
|
|
17
|
+
this.alerts = alerts;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public Zone addZone(String label) {
|
|
21
|
+
Zone zone = new Zone(label);
|
|
22
|
+
zones.add(zone);
|
|
23
|
+
return zone;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public void checkAll() {
|
|
27
|
+
for (Zone zone : zones) {
|
|
28
|
+
for (Sensor sensor : zone.sensors()) {
|
|
29
|
+
double value = sensor.read();
|
|
30
|
+
if (sensor.isOutOfRange(value)) {
|
|
31
|
+
alerts.raise(name, zone.label(), sensor.id(), value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package org.example.greenhouse;
|
|
2
|
+
|
|
3
|
+
/** Volumetric soil moisture, percent. Fed by the ADC polling loop. */
|
|
4
|
+
public class SoilMoistureSensor extends AbstractSensor {
|
|
5
|
+
private final int adcChannel;
|
|
6
|
+
private volatile double lastSample;
|
|
7
|
+
|
|
8
|
+
public SoilMoistureSensor(String id, int adcChannel) {
|
|
9
|
+
super(id, 20.0, 60.0, 0.0);
|
|
10
|
+
this.adcChannel = adcChannel;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public int adcChannel() {
|
|
14
|
+
return adcChannel;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public void onSample(double percent) {
|
|
18
|
+
this.lastSample = percent;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Override
|
|
22
|
+
protected double rawValue() {
|
|
23
|
+
return lastSample;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package org.example.greenhouse;
|
|
2
|
+
|
|
3
|
+
/** Air temperature in degrees Celsius. */
|
|
4
|
+
public class TemperatureSensor extends AbstractSensor {
|
|
5
|
+
private volatile double lastCelsius;
|
|
6
|
+
|
|
7
|
+
public TemperatureSensor(String id, double offset) {
|
|
8
|
+
super(id, 12.0, 32.0, offset);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public void onSample(double celsius) {
|
|
12
|
+
this.lastCelsius = celsius;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Override
|
|
16
|
+
protected double rawValue() {
|
|
17
|
+
return lastCelsius;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package org.example.greenhouse;
|
|
2
|
+
|
|
3
|
+
import java.util.ArrayList;
|
|
4
|
+
import java.util.List;
|
|
5
|
+
|
|
6
|
+
/** One bench or bed. Sensors are created by the hardware layer and attached here. */
|
|
7
|
+
public class Zone {
|
|
8
|
+
private final String label;
|
|
9
|
+
private final List<Sensor> sensors = new ArrayList<>();
|
|
10
|
+
|
|
11
|
+
Zone(String label) {
|
|
12
|
+
this.label = label;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public String label() {
|
|
16
|
+
return label;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public void attach(Sensor sensor) {
|
|
20
|
+
sensors.add(sensor);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public List<Sensor> sensors() {
|
|
24
|
+
return List.copyOf(sensors);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- Clinic bookings
|
|
2
|
+
create table clinics (
|
|
3
|
+
id serial primary key,
|
|
4
|
+
name text not null,
|
|
5
|
+
timezone text not null default 'Europe/London'
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
create table practitioners (
|
|
9
|
+
id serial primary key,
|
|
10
|
+
clinic_id integer not null references clinics(id),
|
|
11
|
+
full_name text not null,
|
|
12
|
+
role text not null check (role in ('physio', 'osteo', 'massage'))
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
create table patients (
|
|
16
|
+
id serial primary key,
|
|
17
|
+
email text unique not null,
|
|
18
|
+
phone text
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
create table appointments (
|
|
22
|
+
id serial primary key,
|
|
23
|
+
practitioner_id integer not null references practitioners(id),
|
|
24
|
+
patient_id integer not null references patients(id),
|
|
25
|
+
starts_at timestamptz not null,
|
|
26
|
+
minutes integer not null default 45,
|
|
27
|
+
status text not null default 'booked',
|
|
28
|
+
booked_by_user_id integer -- staff login id from the old system; no table here
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
create table appointment_notes (
|
|
32
|
+
appointment_id integer primary key references appointments(id),
|
|
33
|
+
body text not null,
|
|
34
|
+
written_at timestamptz not null default now()
|
|
35
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# castbox-media
|
|
2
|
+
|
|
3
|
+
Backend for the Hollowbrook Podcast Network's hosting platform.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
When a creator finishes uploading an episode, the object store sends us a
|
|
8
|
+
webhook. We then transcode and transcribe the audio, index the transcript in
|
|
9
|
+
search so listeners can find episodes by what was said, and purge the CDN so
|
|
10
|
+
the new episode shows up in the RSS feed straight away. The creator gets an
|
|
11
|
+
email when processing starts.
|
|
12
|
+
|
|
13
|
+
## Stack
|
|
14
|
+
|
|
15
|
+
- Node 20, Express (API) and BullMQ (background jobs)
|
|
16
|
+
- PostgreSQL (`episodes`, `shows`, `creators`)
|
|
17
|
+
- Redis (job queue)
|
|
18
|
+
- Transcription vendor HTTP API (api.transcribe.example.test)
|
|
19
|
+
- Search service (search.internal.test)
|
|
20
|
+
- CDN in front of the RSS feeds (cdn.example.test)
|
|
21
|
+
- SMTP relay for creator emails
|
|
22
|
+
|
|
23
|
+
## Running
|
|
24
|
+
|
|
25
|
+
npm run api # node src/server.js
|
|
26
|
+
npm run worker # node src/worker.js
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"publicUrl": "https://media.hollowbrook.example.test",
|
|
3
|
+
"port": 8080,
|
|
4
|
+
"databaseUrl": "postgres://castbox:changeme@db.internal.test:5432/castbox",
|
|
5
|
+
"redisUrl": "redis://cache.internal.test:6379/0",
|
|
6
|
+
"storage": {
|
|
7
|
+
"bucket": "hollowbrook-uploads",
|
|
8
|
+
"publicBaseUrl": "https://uploads.example.test",
|
|
9
|
+
"webhookSecret": "PLACEHOLDER_WEBHOOK_SECRET"
|
|
10
|
+
},
|
|
11
|
+
"transcription": {
|
|
12
|
+
"baseUrl": "https://api.transcribe.example.test",
|
|
13
|
+
"apiKey": "PLACEHOLDER_TRANSCRIBE_KEY",
|
|
14
|
+
"language": "en"
|
|
15
|
+
},
|
|
16
|
+
"search": {
|
|
17
|
+
"url": "http://search.internal.test:7700",
|
|
18
|
+
"index": "episodes"
|
|
19
|
+
},
|
|
20
|
+
"cdn": {
|
|
21
|
+
"purgeUrl": "https://cdn.example.test/api/purge",
|
|
22
|
+
"token": "PLACEHOLDER_CDN_TOKEN"
|
|
23
|
+
},
|
|
24
|
+
"smtp": {
|
|
25
|
+
"host": "smtp.internal.test",
|
|
26
|
+
"port": 587,
|
|
27
|
+
"from": "studio@hollowbrook.example.test"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const config = require('../../config/default.json');
|
|
2
|
+
|
|
3
|
+
async function submitTranscription(audioUrl, episodeId) {
|
|
4
|
+
const response = await fetch(`${config.transcription.baseUrl}/v2/jobs`, {
|
|
5
|
+
method: 'POST',
|
|
6
|
+
headers: {
|
|
7
|
+
authorization: `Bearer ${config.transcription.apiKey}`,
|
|
8
|
+
'content-type': 'application/json',
|
|
9
|
+
},
|
|
10
|
+
body: JSON.stringify({
|
|
11
|
+
media_url: audioUrl,
|
|
12
|
+
language: config.transcription.language,
|
|
13
|
+
reference: String(episodeId),
|
|
14
|
+
callback_url: `${config.publicUrl}/webhooks/transcription`,
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error(`transcription submit failed: ${response.status}`);
|
|
20
|
+
}
|
|
21
|
+
const body = await response.json();
|
|
22
|
+
return body.job_id;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { submitTranscription };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const { Pool } = require('pg');
|
|
2
|
+
const IORedis = require('ioredis');
|
|
3
|
+
const { Queue } = require('bullmq');
|
|
4
|
+
const config = require('../config/default.json');
|
|
5
|
+
|
|
6
|
+
const pool = new Pool({ connectionString: config.databaseUrl });
|
|
7
|
+
const connection = new IORedis(config.redisUrl, { maxRetriesPerRequest: null });
|
|
8
|
+
|
|
9
|
+
const mediaQueue = new Queue('media', { connection });
|
|
10
|
+
|
|
11
|
+
async function query(text, params) {
|
|
12
|
+
const result = await pool.query(text, params);
|
|
13
|
+
return result.rows;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { query, mediaQueue, connection };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const config = require('../../config/default.json');
|
|
3
|
+
const { query, mediaQueue } = require('../db');
|
|
4
|
+
|
|
5
|
+
function validSignature(rawBody, header) {
|
|
6
|
+
if (!header) return false;
|
|
7
|
+
const expected = crypto
|
|
8
|
+
.createHmac('sha256', config.storage.webhookSecret)
|
|
9
|
+
.update(rawBody)
|
|
10
|
+
.digest('hex');
|
|
11
|
+
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function handleStorageWebhook(req, res) {
|
|
15
|
+
if (!validSignature(req.body, req.get('x-storage-signature'))) {
|
|
16
|
+
return res.status(401).json({ error: 'bad signature' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const event = JSON.parse(req.body.toString('utf8'));
|
|
20
|
+
if (event.type !== 'object.finalized') {
|
|
21
|
+
// Deletes, metadata updates etc. are acknowledged and ignored.
|
|
22
|
+
return res.status(204).end();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const [episode] = await query(
|
|
26
|
+
"UPDATE episodes SET status = 'uploaded', audio_url = $1 WHERE upload_key = $2 AND status = 'awaiting_upload' RETURNING id",
|
|
27
|
+
[`${config.storage.publicBaseUrl}/${event.object.key}`, event.object.key]
|
|
28
|
+
);
|
|
29
|
+
if (!episode) {
|
|
30
|
+
// Unknown key, or a retried webhook for an episode we already took.
|
|
31
|
+
return res.status(200).json({ ignored: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await mediaQueue.add(
|
|
35
|
+
'transcribe-episode',
|
|
36
|
+
{ episodeId: episode.id },
|
|
37
|
+
{ attempts: 3, backoff: { type: 'exponential', delay: 30000 } }
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return res.status(202).json({ queued: episode.id });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { handleStorageWebhook };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { query } = require('../db');
|
|
2
|
+
const { submitTranscription } = require('../clients/transcription');
|
|
3
|
+
const { sendProcessingEmail } = require('../mailer');
|
|
4
|
+
|
|
5
|
+
async function transcribeEpisode(job) {
|
|
6
|
+
const [episode] = await query(
|
|
7
|
+
`SELECT e.id, e.title, e.audio_url, c.email, c.notify_on_processing
|
|
8
|
+
FROM episodes e
|
|
9
|
+
JOIN shows s ON s.id = e.show_id
|
|
10
|
+
JOIN creators c ON c.id = s.creator_id
|
|
11
|
+
WHERE e.id = $1`,
|
|
12
|
+
[job.data.episodeId]
|
|
13
|
+
);
|
|
14
|
+
if (!episode) return;
|
|
15
|
+
|
|
16
|
+
const transcriptionJobId = await submitTranscription(episode.audio_url, episode.id);
|
|
17
|
+
|
|
18
|
+
await query(
|
|
19
|
+
"UPDATE episodes SET status = 'transcribing', transcription_job_id = $1 WHERE id = $2",
|
|
20
|
+
[transcriptionJobId, episode.id]
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
if (episode.notify_on_processing) {
|
|
24
|
+
await sendProcessingEmail(episode.email, episode.title);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { transcribeEpisode };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const nodemailer = require('nodemailer');
|
|
2
|
+
const config = require('../config/default.json');
|
|
3
|
+
|
|
4
|
+
const transport = nodemailer.createTransport({
|
|
5
|
+
host: config.smtp.host,
|
|
6
|
+
port: config.smtp.port,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
async function sendProcessingEmail(to, episodeTitle) {
|
|
10
|
+
await transport.sendMail({
|
|
11
|
+
from: config.smtp.from,
|
|
12
|
+
to,
|
|
13
|
+
subject: `"${episodeTitle}" is processing`,
|
|
14
|
+
text: `We've received "${episodeTitle}" and started processing it. We'll publish it once the transcript is ready.`,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { sendProcessingEmail };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const config = require('../config/default.json');
|
|
3
|
+
const { query } = require('./db');
|
|
4
|
+
const { handleStorageWebhook } = require('./handlers/storageWebhook');
|
|
5
|
+
|
|
6
|
+
const app = express();
|
|
7
|
+
|
|
8
|
+
// Raw body is needed so the storage webhook signature can be checked.
|
|
9
|
+
app.post('/webhooks/storage', express.raw({ type: 'application/json' }), handleStorageWebhook);
|
|
10
|
+
|
|
11
|
+
app.use(express.json());
|
|
12
|
+
|
|
13
|
+
app.get('/feeds/:showId.xml', async (req, res) => {
|
|
14
|
+
const [show] = await query('SELECT id, title, description FROM shows WHERE id = $1', [req.params.showId]);
|
|
15
|
+
if (!show) return res.status(404).end();
|
|
16
|
+
const episodes = await query(
|
|
17
|
+
"SELECT title, audio_url, published_at FROM episodes WHERE show_id = $1 AND status = 'published' ORDER BY published_at DESC",
|
|
18
|
+
[show.id]
|
|
19
|
+
);
|
|
20
|
+
res.type('application/rss+xml').send(renderRss(show, episodes));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.get('/healthz', (req, res) => res.send('ok'));
|
|
24
|
+
|
|
25
|
+
function renderRss(show, episodes) {
|
|
26
|
+
const items = episodes
|
|
27
|
+
.map((e) => `<item><title>${e.title}</title><enclosure url="${e.audio_url}" type="audio/mpeg"/></item>`)
|
|
28
|
+
.join('');
|
|
29
|
+
return `<?xml version="1.0"?><rss version="2.0"><channel><title>${show.title}</title>${items}</channel></rss>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
app.listen(config.port, () => console.log(`api listening on ${config.port}`));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { Worker } = require('bullmq');
|
|
2
|
+
const config = require('../config/default.json');
|
|
3
|
+
const { query, mediaQueue, connection } = require('./db');
|
|
4
|
+
const { transcribeEpisode } = require('./jobs/transcribeEpisode');
|
|
5
|
+
|
|
6
|
+
async function reindexSearch() {
|
|
7
|
+
const rows = await query(
|
|
8
|
+
"SELECT id, title, transcript FROM episodes WHERE status = 'published' AND updated_at > now() - interval '1 day'"
|
|
9
|
+
);
|
|
10
|
+
if (rows.length === 0) return;
|
|
11
|
+
await fetch(`${config.search.url}/indexes/${config.search.index}/documents`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'content-type': 'application/json' },
|
|
14
|
+
body: JSON.stringify(rows),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const handlers = {
|
|
19
|
+
'transcribe-episode': transcribeEpisode,
|
|
20
|
+
'nightly-reindex': reindexSearch,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const worker = new Worker('media', (job) => handlers[job.name](job), { connection });
|
|
24
|
+
|
|
25
|
+
worker.on('failed', async (job, err) => {
|
|
26
|
+
console.error(`job ${job.name} ${job.id} failed: ${err.message}`);
|
|
27
|
+
if (job.name === 'transcribe-episode' && job.attemptsMade >= job.opts.attempts) {
|
|
28
|
+
await query("UPDATE episodes SET status = 'failed' WHERE id = $1", [job.data.episodeId]);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
mediaQueue.add('nightly-reindex', {}, { repeat: { pattern: '0 3 * * *' } });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
quote api slowness - notes (Wed 14 Oct)
|
|
2
|
+
|
|
3
|
+
- trace above is one of the slow ones from this morning. the customer said the quote page hung for "just over 4 seconds".
|
|
4
|
+
- my bet is the rate limiter. ops told me the ratelimiter check against redis has been taking 300ms+ since the redis upgrade last week.
|
|
5
|
+
- also the two carrier calls add up to ~5s on their own so carriers are basically the whole story?
|
|
6
|
+
- rates team owns rates-svc and the carrier integrations. quote team owns quote-svc.
|
|
7
|
+
- want something I can send the rates team that shows where the time goes.
|