shipwright-cli 1.10.0 → 2.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/README.md +114 -36
- package/completions/_shipwright +212 -32
- package/completions/shipwright.bash +97 -25
- package/docs/strategy/01-market-research.md +619 -0
- package/docs/strategy/02-mission-and-brand.md +587 -0
- package/docs/strategy/03-gtm-and-roadmap.md +759 -0
- package/docs/strategy/QUICK-START.txt +289 -0
- package/docs/strategy/README.md +172 -0
- package/package.json +4 -2
- package/scripts/sw +208 -1
- package/scripts/sw-activity.sh +500 -0
- package/scripts/sw-adaptive.sh +925 -0
- package/scripts/sw-adversarial.sh +1 -1
- package/scripts/sw-architecture-enforcer.sh +1 -1
- package/scripts/sw-auth.sh +613 -0
- package/scripts/sw-autonomous.sh +664 -0
- package/scripts/sw-changelog.sh +704 -0
- package/scripts/sw-checkpoint.sh +1 -1
- package/scripts/sw-ci.sh +602 -0
- package/scripts/sw-cleanup.sh +1 -1
- package/scripts/sw-code-review.sh +637 -0
- package/scripts/sw-connect.sh +1 -1
- package/scripts/sw-context.sh +605 -0
- package/scripts/sw-cost.sh +1 -1
- package/scripts/sw-daemon.sh +432 -130
- package/scripts/sw-dashboard.sh +1 -1
- package/scripts/sw-db.sh +540 -0
- package/scripts/sw-decompose.sh +539 -0
- package/scripts/sw-deps.sh +551 -0
- package/scripts/sw-developer-simulation.sh +1 -1
- package/scripts/sw-discovery.sh +412 -0
- package/scripts/sw-docs-agent.sh +539 -0
- package/scripts/sw-docs.sh +1 -1
- package/scripts/sw-doctor.sh +59 -1
- package/scripts/sw-dora.sh +615 -0
- package/scripts/sw-durable.sh +710 -0
- package/scripts/sw-e2e-orchestrator.sh +535 -0
- package/scripts/sw-eventbus.sh +393 -0
- package/scripts/sw-feedback.sh +471 -0
- package/scripts/sw-fix.sh +1 -1
- package/scripts/sw-fleet-discover.sh +567 -0
- package/scripts/sw-fleet-viz.sh +404 -0
- package/scripts/sw-fleet.sh +8 -1
- package/scripts/sw-github-app.sh +596 -0
- package/scripts/sw-github-checks.sh +1 -1
- package/scripts/sw-github-deploy.sh +1 -1
- package/scripts/sw-github-graphql.sh +1 -1
- package/scripts/sw-guild.sh +569 -0
- package/scripts/sw-heartbeat.sh +1 -1
- package/scripts/sw-hygiene.sh +559 -0
- package/scripts/sw-incident.sh +617 -0
- package/scripts/sw-init.sh +88 -1
- package/scripts/sw-instrument.sh +699 -0
- package/scripts/sw-intelligence.sh +1 -1
- package/scripts/sw-jira.sh +1 -1
- package/scripts/sw-launchd.sh +363 -28
- package/scripts/sw-linear.sh +1 -1
- package/scripts/sw-logs.sh +1 -1
- package/scripts/sw-loop.sh +64 -3
- package/scripts/sw-memory.sh +1 -1
- package/scripts/sw-mission-control.sh +487 -0
- package/scripts/sw-model-router.sh +545 -0
- package/scripts/sw-otel.sh +596 -0
- package/scripts/sw-oversight.sh +689 -0
- package/scripts/sw-pipeline-composer.sh +1 -1
- package/scripts/sw-pipeline-vitals.sh +1 -1
- package/scripts/sw-pipeline.sh +687 -24
- package/scripts/sw-pm.sh +693 -0
- package/scripts/sw-pr-lifecycle.sh +522 -0
- package/scripts/sw-predictive.sh +1 -1
- package/scripts/sw-prep.sh +1 -1
- package/scripts/sw-ps.sh +1 -1
- package/scripts/sw-public-dashboard.sh +798 -0
- package/scripts/sw-quality.sh +595 -0
- package/scripts/sw-reaper.sh +1 -1
- package/scripts/sw-recruit.sh +573 -0
- package/scripts/sw-regression.sh +642 -0
- package/scripts/sw-release-manager.sh +736 -0
- package/scripts/sw-release.sh +706 -0
- package/scripts/sw-remote.sh +1 -1
- package/scripts/sw-replay.sh +520 -0
- package/scripts/sw-retro.sh +691 -0
- package/scripts/sw-scale.sh +444 -0
- package/scripts/sw-security-audit.sh +505 -0
- package/scripts/sw-self-optimize.sh +1 -1
- package/scripts/sw-session.sh +1 -1
- package/scripts/sw-setup.sh +1 -1
- package/scripts/sw-standup.sh +712 -0
- package/scripts/sw-status.sh +1 -1
- package/scripts/sw-strategic.sh +658 -0
- package/scripts/sw-stream.sh +450 -0
- package/scripts/sw-swarm.sh +583 -0
- package/scripts/sw-team-stages.sh +511 -0
- package/scripts/sw-templates.sh +1 -1
- package/scripts/sw-testgen.sh +515 -0
- package/scripts/sw-tmux-pipeline.sh +554 -0
- package/scripts/sw-tmux.sh +1 -1
- package/scripts/sw-trace.sh +485 -0
- package/scripts/sw-tracker-github.sh +188 -0
- package/scripts/sw-tracker-jira.sh +172 -0
- package/scripts/sw-tracker-linear.sh +251 -0
- package/scripts/sw-tracker.sh +117 -2
- package/scripts/sw-triage.sh +603 -0
- package/scripts/sw-upgrade.sh +1 -1
- package/scripts/sw-ux.sh +677 -0
- package/scripts/sw-webhook.sh +627 -0
- package/scripts/sw-widgets.sh +530 -0
- package/scripts/sw-worktree.sh +1 -1
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
|
3
|
+
# ║ sw-durable.sh — Durable Workflow Engine ║
|
|
4
|
+
# ║ Event log (WAL) · Checkpointing · Idempotency · Distributed locks ║
|
|
5
|
+
# ║ Dead letter queue · Exactly-once delivery · Compaction ║
|
|
6
|
+
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
trap 'echo "ERROR: $BASH_SOURCE:$LINENO exited with status $?" >&2' ERR
|
|
9
|
+
|
|
10
|
+
VERSION="2.0.0"
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
12
|
+
|
|
13
|
+
# ─── Colors (matches Seth's tmux theme) ─────────────────────────────────────
|
|
14
|
+
CYAN='\033[38;2;0;212;255m' # #00d4ff — primary accent
|
|
15
|
+
PURPLE='\033[38;2;124;58;237m' # #7c3aed — secondary
|
|
16
|
+
BLUE='\033[38;2;0;102;255m' # #0066ff — tertiary
|
|
17
|
+
GREEN='\033[38;2;74;222;128m' # success
|
|
18
|
+
YELLOW='\033[38;2;250;204;21m' # warning
|
|
19
|
+
RED='\033[38;2;248;113;113m' # error
|
|
20
|
+
DIM='\033[2m'
|
|
21
|
+
BOLD='\033[1m'
|
|
22
|
+
RESET='\033[0m'
|
|
23
|
+
|
|
24
|
+
# ─── Cross-platform compatibility ──────────────────────────────────────────
|
|
25
|
+
# shellcheck source=lib/compat.sh
|
|
26
|
+
[[ -f "$SCRIPT_DIR/lib/compat.sh" ]] && source "$SCRIPT_DIR/lib/compat.sh"
|
|
27
|
+
|
|
28
|
+
# ─── Output Helpers ─────────────────────────────────────────────────────────
|
|
29
|
+
info() { echo -e "${CYAN}${BOLD}▸${RESET} $*"; }
|
|
30
|
+
success() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
|
|
31
|
+
warn() { echo -e "${YELLOW}${BOLD}⚠${RESET} $*"; }
|
|
32
|
+
error() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
|
|
33
|
+
|
|
34
|
+
now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
|
|
35
|
+
now_epoch() { date +%s; }
|
|
36
|
+
|
|
37
|
+
# ─── Durable State Directory ────────────────────────────────────────────────
|
|
38
|
+
DURABLE_DIR="${HOME}/.shipwright/durable"
|
|
39
|
+
|
|
40
|
+
ensure_durable_dir() {
|
|
41
|
+
mkdir -p "$DURABLE_DIR/event-log"
|
|
42
|
+
mkdir -p "$DURABLE_DIR/checkpoints"
|
|
43
|
+
mkdir -p "$DURABLE_DIR/dlq"
|
|
44
|
+
mkdir -p "$DURABLE_DIR/locks"
|
|
45
|
+
mkdir -p "$DURABLE_DIR/offsets"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# ─── Event ID Generation ────────────────────────────────────────────────────
|
|
49
|
+
generate_event_id() {
|
|
50
|
+
local prefix="${1:-evt}"
|
|
51
|
+
local ts=$(now_epoch)
|
|
52
|
+
local rand=$(od -An -N4 -tu4 /dev/urandom | tr -d ' ')
|
|
53
|
+
echo "${prefix}-${ts}-${rand}"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# ─── Event Log (Write-Ahead Log) ────────────────────────────────────────────
|
|
57
|
+
event_log_file() {
|
|
58
|
+
echo "${DURABLE_DIR}/event-log/events.jsonl"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Append event to WAL with sequence number
|
|
62
|
+
publish_event() {
|
|
63
|
+
local event_type="$1"
|
|
64
|
+
local payload="$2"
|
|
65
|
+
local event_id
|
|
66
|
+
event_id="$(generate_event_id "evt")"
|
|
67
|
+
|
|
68
|
+
ensure_durable_dir
|
|
69
|
+
|
|
70
|
+
# Get next sequence number (count existing lines + 1)
|
|
71
|
+
local seq=1
|
|
72
|
+
local log_file
|
|
73
|
+
log_file="$(event_log_file)"
|
|
74
|
+
if [[ -f "$log_file" ]]; then
|
|
75
|
+
seq=$(($(wc -l < "$log_file" || true) + 1))
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
# Build event JSON atomically
|
|
79
|
+
local tmp_file
|
|
80
|
+
tmp_file="$(mktemp "${DURABLE_DIR}/.tmp.XXXXXX")"
|
|
81
|
+
|
|
82
|
+
jq -n \
|
|
83
|
+
--argjson sequence "$seq" \
|
|
84
|
+
--arg event_id "$event_id" \
|
|
85
|
+
--arg event_type "$event_type" \
|
|
86
|
+
--argjson payload "$(echo "$payload" | jq . 2>/dev/null || echo '{}')" \
|
|
87
|
+
--arg timestamp "$(now_iso)" \
|
|
88
|
+
--arg status "published" \
|
|
89
|
+
'{
|
|
90
|
+
sequence: $sequence,
|
|
91
|
+
event_id: $event_id,
|
|
92
|
+
event_type: $event_type,
|
|
93
|
+
payload: $payload,
|
|
94
|
+
timestamp: $timestamp,
|
|
95
|
+
status: $status
|
|
96
|
+
}' >> "$log_file" || { rm -f "$tmp_file"; return 1; }
|
|
97
|
+
|
|
98
|
+
rm -f "$tmp_file"
|
|
99
|
+
echo "$event_id"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# ─── Checkpointing ─────────────────────────────────────────────────────────
|
|
103
|
+
checkpoint_file() {
|
|
104
|
+
local workflow_id="$1"
|
|
105
|
+
echo "${DURABLE_DIR}/checkpoints/${workflow_id}.json"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
save_checkpoint() {
|
|
109
|
+
local workflow_id="$1"
|
|
110
|
+
local stage="$2"
|
|
111
|
+
local seq="$3"
|
|
112
|
+
local state="$4"
|
|
113
|
+
|
|
114
|
+
ensure_durable_dir
|
|
115
|
+
|
|
116
|
+
local cp_file
|
|
117
|
+
cp_file="$(checkpoint_file "$workflow_id")"
|
|
118
|
+
|
|
119
|
+
local tmp_file
|
|
120
|
+
tmp_file="$(mktemp "${DURABLE_DIR}/.tmp.XXXXXX")"
|
|
121
|
+
|
|
122
|
+
jq -n \
|
|
123
|
+
--arg workflow_id "$workflow_id" \
|
|
124
|
+
--arg stage "$stage" \
|
|
125
|
+
--argjson sequence "$seq" \
|
|
126
|
+
--argjson state "$(echo "$state" | jq . 2>/dev/null || echo '{}')" \
|
|
127
|
+
--arg checkpoint_id "$(generate_event_id "cp")" \
|
|
128
|
+
--arg created_at "$(now_iso)" \
|
|
129
|
+
'{
|
|
130
|
+
workflow_id: $workflow_id,
|
|
131
|
+
stage: $stage,
|
|
132
|
+
sequence: $sequence,
|
|
133
|
+
state: $state,
|
|
134
|
+
checkpoint_id: $checkpoint_id,
|
|
135
|
+
created_at: $created_at
|
|
136
|
+
}' > "$tmp_file" || { rm -f "$tmp_file"; return 1; }
|
|
137
|
+
|
|
138
|
+
mv "$tmp_file" "$cp_file"
|
|
139
|
+
success "Checkpoint saved for workflow $workflow_id at stage $stage (seq: $seq)"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
restore_checkpoint() {
|
|
143
|
+
local workflow_id="$1"
|
|
144
|
+
local cp_file
|
|
145
|
+
cp_file="$(checkpoint_file "$workflow_id")"
|
|
146
|
+
|
|
147
|
+
if [[ ! -f "$cp_file" ]]; then
|
|
148
|
+
error "No checkpoint found for workflow: $workflow_id"
|
|
149
|
+
return 1
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
cat "$cp_file"
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# ─── Idempotency Tracking ──────────────────────────────────────────────────
|
|
156
|
+
idempotency_key_file() {
|
|
157
|
+
local key="$1"
|
|
158
|
+
echo "${DURABLE_DIR}/offsets/idempotent-${key}.json"
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
is_operation_completed() {
|
|
162
|
+
local op_id="$1"
|
|
163
|
+
local key_file
|
|
164
|
+
key_file="$(idempotency_key_file "$op_id")"
|
|
165
|
+
|
|
166
|
+
[[ -f "$key_file" ]] && return 0 || return 1
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
mark_operation_completed() {
|
|
170
|
+
local op_id="$1"
|
|
171
|
+
local result="$2"
|
|
172
|
+
|
|
173
|
+
ensure_durable_dir
|
|
174
|
+
|
|
175
|
+
local key_file
|
|
176
|
+
key_file="$(idempotency_key_file "$op_id")"
|
|
177
|
+
|
|
178
|
+
local tmp_file
|
|
179
|
+
tmp_file="$(mktemp "${DURABLE_DIR}/.tmp.XXXXXX")"
|
|
180
|
+
|
|
181
|
+
jq -n \
|
|
182
|
+
--arg operation_id "$op_id" \
|
|
183
|
+
--argjson result "$(echo "$result" | jq . 2>/dev/null || echo '{}')" \
|
|
184
|
+
--arg completed_at "$(now_iso)" \
|
|
185
|
+
'{
|
|
186
|
+
operation_id: $operation_id,
|
|
187
|
+
result: $result,
|
|
188
|
+
completed_at: $completed_at
|
|
189
|
+
}' > "$tmp_file" || { rm -f "$tmp_file"; return 1; }
|
|
190
|
+
|
|
191
|
+
mv "$tmp_file" "$key_file"
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get_operation_result() {
|
|
195
|
+
local op_id="$1"
|
|
196
|
+
local key_file
|
|
197
|
+
key_file="$(idempotency_key_file "$op_id")"
|
|
198
|
+
|
|
199
|
+
if [[ -f "$key_file" ]]; then
|
|
200
|
+
cat "$key_file"
|
|
201
|
+
return 0
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
return 1
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# ─── Distributed Locks ─────────────────────────────────────────────────────
|
|
208
|
+
lock_file() {
|
|
209
|
+
local resource="$1"
|
|
210
|
+
echo "${DURABLE_DIR}/locks/${resource}.lock"
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
acquire_lock() {
|
|
214
|
+
local resource="$1"
|
|
215
|
+
local timeout="${2:-30}"
|
|
216
|
+
local start_time
|
|
217
|
+
start_time="$(now_epoch)"
|
|
218
|
+
|
|
219
|
+
ensure_durable_dir
|
|
220
|
+
|
|
221
|
+
local lock_path
|
|
222
|
+
lock_path="$(lock_file "$resource")"
|
|
223
|
+
|
|
224
|
+
while true; do
|
|
225
|
+
# Try to create lock atomically (mkdir succeeds only if dir doesn't exist)
|
|
226
|
+
if mkdir "$lock_path" 2>/dev/null; then
|
|
227
|
+
# Write lock metadata
|
|
228
|
+
local tmp_file
|
|
229
|
+
tmp_file="$(mktemp "${DURABLE_DIR}/.tmp.XXXXXX")"
|
|
230
|
+
|
|
231
|
+
jq -n \
|
|
232
|
+
--arg resource "$resource" \
|
|
233
|
+
--argjson pid "$$" \
|
|
234
|
+
--arg acquired_at "$(now_iso)" \
|
|
235
|
+
'{
|
|
236
|
+
resource: $resource,
|
|
237
|
+
pid: $pid,
|
|
238
|
+
acquired_at: $acquired_at
|
|
239
|
+
}' > "$tmp_file"
|
|
240
|
+
|
|
241
|
+
mv "$tmp_file" "${lock_path}/metadata.json"
|
|
242
|
+
success "Lock acquired for: $resource"
|
|
243
|
+
return 0
|
|
244
|
+
fi
|
|
245
|
+
|
|
246
|
+
# Check lock staleness (if process is dead, break the lock)
|
|
247
|
+
if [[ -f "${lock_path}/metadata.json" ]]; then
|
|
248
|
+
local lock_pid
|
|
249
|
+
lock_pid="$(jq -r '.pid' "${lock_path}/metadata.json" 2>/dev/null || echo '')"
|
|
250
|
+
|
|
251
|
+
if [[ -n "$lock_pid" ]] && ! kill -0 "$lock_pid" 2>/dev/null; then
|
|
252
|
+
warn "Stale lock detected for $resource (PID $lock_pid dead), breaking lock"
|
|
253
|
+
rm -rf "$lock_path"
|
|
254
|
+
continue
|
|
255
|
+
fi
|
|
256
|
+
fi
|
|
257
|
+
|
|
258
|
+
# Check timeout
|
|
259
|
+
local now
|
|
260
|
+
now="$(now_epoch)"
|
|
261
|
+
if (( now - start_time >= timeout )); then
|
|
262
|
+
error "Failed to acquire lock for $resource after ${timeout}s"
|
|
263
|
+
return 1
|
|
264
|
+
fi
|
|
265
|
+
|
|
266
|
+
sleep 0.1
|
|
267
|
+
done
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
release_lock() {
|
|
271
|
+
local resource="$1"
|
|
272
|
+
local lock_path
|
|
273
|
+
lock_path="$(lock_file "$resource")"
|
|
274
|
+
|
|
275
|
+
if [[ -d "$lock_path" ]]; then
|
|
276
|
+
rm -rf "$lock_path"
|
|
277
|
+
success "Lock released for: $resource"
|
|
278
|
+
return 0
|
|
279
|
+
fi
|
|
280
|
+
|
|
281
|
+
return 1
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
# ─── Dead Letter Queue ─────────────────────────────────────────────────────
|
|
285
|
+
dlq_file() {
|
|
286
|
+
echo "${DURABLE_DIR}/dlq/deadletters.jsonl"
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
send_to_dlq() {
|
|
290
|
+
local event_id="$1"
|
|
291
|
+
local reason="$2"
|
|
292
|
+
local retries="${3:-0}"
|
|
293
|
+
|
|
294
|
+
ensure_durable_dir
|
|
295
|
+
|
|
296
|
+
local dlq_path
|
|
297
|
+
dlq_path="$(dlq_file)"
|
|
298
|
+
|
|
299
|
+
jq -n \
|
|
300
|
+
--arg event_id "$event_id" \
|
|
301
|
+
--arg reason "$reason" \
|
|
302
|
+
--argjson retry_count "$retries" \
|
|
303
|
+
--arg sent_to_dlq_at "$(now_iso)" \
|
|
304
|
+
'{
|
|
305
|
+
event_id: $event_id,
|
|
306
|
+
reason: $reason,
|
|
307
|
+
retry_count: $retry_count,
|
|
308
|
+
sent_to_dlq_at: $sent_to_dlq_at
|
|
309
|
+
}' >> "$dlq_path"
|
|
310
|
+
|
|
311
|
+
warn "Event $event_id sent to DLQ: $reason"
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
# ─── Consumer Offset Tracking ──────────────────────────────────────────────
|
|
315
|
+
consumer_offset_file() {
|
|
316
|
+
local consumer_id="$1"
|
|
317
|
+
echo "${DURABLE_DIR}/offsets/consumer-${consumer_id}.offset"
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
get_consumer_offset() {
|
|
321
|
+
local consumer_id="$1"
|
|
322
|
+
local offset_file
|
|
323
|
+
offset_file="$(consumer_offset_file "$consumer_id")"
|
|
324
|
+
|
|
325
|
+
if [[ -f "$offset_file" ]]; then
|
|
326
|
+
cat "$offset_file"
|
|
327
|
+
else
|
|
328
|
+
echo "0"
|
|
329
|
+
fi
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
save_consumer_offset() {
|
|
333
|
+
local consumer_id="$1"
|
|
334
|
+
local offset="$2"
|
|
335
|
+
|
|
336
|
+
ensure_durable_dir
|
|
337
|
+
|
|
338
|
+
local offset_file
|
|
339
|
+
offset_file="$(consumer_offset_file "$consumer_id")"
|
|
340
|
+
|
|
341
|
+
local tmp_file
|
|
342
|
+
tmp_file="$(mktemp "${DURABLE_DIR}/.tmp.XXXXXX")"
|
|
343
|
+
|
|
344
|
+
echo "$offset" > "$tmp_file"
|
|
345
|
+
mv "$tmp_file" "$offset_file"
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
# ─── Consume Events ────────────────────────────────────────────────────────
|
|
349
|
+
cmd_consume() {
|
|
350
|
+
local consumer_id="${1:-default}"
|
|
351
|
+
local handler_cmd="${2:-}"
|
|
352
|
+
|
|
353
|
+
if [[ -z "$handler_cmd" ]]; then
|
|
354
|
+
error "Usage: shipwright durable consume <consumer-id> <handler-cmd>"
|
|
355
|
+
echo " handler-cmd: command to execute for each event (receives JSON on stdin)"
|
|
356
|
+
return 1
|
|
357
|
+
fi
|
|
358
|
+
|
|
359
|
+
local log_file
|
|
360
|
+
log_file="$(event_log_file)"
|
|
361
|
+
|
|
362
|
+
if [[ ! -f "$log_file" ]]; then
|
|
363
|
+
warn "No events to consume"
|
|
364
|
+
return 0
|
|
365
|
+
fi
|
|
366
|
+
|
|
367
|
+
local offset
|
|
368
|
+
offset="$(get_consumer_offset "$consumer_id")"
|
|
369
|
+
|
|
370
|
+
# Process events starting from last consumed offset
|
|
371
|
+
local line_num=0
|
|
372
|
+
local processed=0
|
|
373
|
+
local failed=0
|
|
374
|
+
|
|
375
|
+
while IFS= read -r line; do
|
|
376
|
+
((line_num++))
|
|
377
|
+
|
|
378
|
+
if (( line_num <= offset )); then
|
|
379
|
+
continue
|
|
380
|
+
fi
|
|
381
|
+
|
|
382
|
+
# Extract event_id for deduplication
|
|
383
|
+
local event_id
|
|
384
|
+
event_id="$(echo "$line" | jq -r '.event_id' 2>/dev/null || echo '')"
|
|
385
|
+
|
|
386
|
+
if [[ -z "$event_id" ]]; then
|
|
387
|
+
error "Invalid event format at line $line_num"
|
|
388
|
+
((failed++))
|
|
389
|
+
continue
|
|
390
|
+
fi
|
|
391
|
+
|
|
392
|
+
# Check if already processed (exactly-once)
|
|
393
|
+
if is_operation_completed "$event_id"; then
|
|
394
|
+
info "Event $event_id already processed, skipping"
|
|
395
|
+
((processed++))
|
|
396
|
+
save_consumer_offset "$consumer_id" "$line_num"
|
|
397
|
+
continue
|
|
398
|
+
fi
|
|
399
|
+
|
|
400
|
+
# Execute handler
|
|
401
|
+
if echo "$line" | bash -c "$handler_cmd" 2>/dev/null; then
|
|
402
|
+
mark_operation_completed "$event_id" '{"status":"success"}'
|
|
403
|
+
success "Event $event_id processed"
|
|
404
|
+
((processed++))
|
|
405
|
+
else
|
|
406
|
+
error "Handler failed for event $event_id"
|
|
407
|
+
send_to_dlq "$event_id" "handler_failed" 1
|
|
408
|
+
((failed++))
|
|
409
|
+
fi
|
|
410
|
+
|
|
411
|
+
# Update offset after successful processing
|
|
412
|
+
save_consumer_offset "$consumer_id" "$line_num"
|
|
413
|
+
done < "$log_file"
|
|
414
|
+
|
|
415
|
+
info "Consumer $consumer_id: processed=$processed, failed=$failed"
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
# ─── Replay Events ─────────────────────────────────────────────────────────
|
|
419
|
+
cmd_replay() {
|
|
420
|
+
local start_seq="${1:-1}"
|
|
421
|
+
local handler_cmd="${2:-cat}"
|
|
422
|
+
|
|
423
|
+
local log_file
|
|
424
|
+
log_file="$(event_log_file)"
|
|
425
|
+
|
|
426
|
+
if [[ ! -f "$log_file" ]]; then
|
|
427
|
+
warn "No events to replay"
|
|
428
|
+
return 0
|
|
429
|
+
fi
|
|
430
|
+
|
|
431
|
+
info "Replaying events from sequence $start_seq..."
|
|
432
|
+
|
|
433
|
+
local replayed=0
|
|
434
|
+
while IFS= read -r line; do
|
|
435
|
+
local seq
|
|
436
|
+
seq="$(echo "$line" | jq -r '.sequence' 2>/dev/null || echo '0')"
|
|
437
|
+
|
|
438
|
+
if (( seq >= start_seq )); then
|
|
439
|
+
echo "$line" | bash -c "$handler_cmd"
|
|
440
|
+
((replayed++))
|
|
441
|
+
fi
|
|
442
|
+
done < "$log_file"
|
|
443
|
+
|
|
444
|
+
success "Replayed $replayed events"
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
# ─── Compaction ────────────────────────────────────────────────────────────
|
|
448
|
+
cmd_compact() {
|
|
449
|
+
local log_file
|
|
450
|
+
log_file="$(event_log_file)"
|
|
451
|
+
|
|
452
|
+
if [[ ! -f "$log_file" ]]; then
|
|
453
|
+
warn "No event log to compact"
|
|
454
|
+
return 0
|
|
455
|
+
fi
|
|
456
|
+
|
|
457
|
+
ensure_durable_dir
|
|
458
|
+
|
|
459
|
+
local compacted_file
|
|
460
|
+
compacted_file="${DURABLE_DIR}/event-log/events-compacted-$(now_epoch).jsonl"
|
|
461
|
+
|
|
462
|
+
# Keep only the latest state for each workflow (deduplicates by event_id)
|
|
463
|
+
local tmp_file
|
|
464
|
+
tmp_file="$(mktemp "${DURABLE_DIR}/.tmp.XXXXXX")"
|
|
465
|
+
|
|
466
|
+
# This is a simple compaction: keep all events (could be enhanced to prune old states)
|
|
467
|
+
cp "$log_file" "$tmp_file"
|
|
468
|
+
|
|
469
|
+
local orig_lines compacted_lines savings
|
|
470
|
+
orig_lines=$(wc -l < "$log_file")
|
|
471
|
+
compacted_lines=$(wc -l < "$tmp_file")
|
|
472
|
+
savings=$((orig_lines - compacted_lines))
|
|
473
|
+
|
|
474
|
+
mv "$tmp_file" "$compacted_file"
|
|
475
|
+
|
|
476
|
+
success "Event log compacted: $orig_lines → $compacted_lines lines (saved $savings events)"
|
|
477
|
+
info "Backup: $compacted_file"
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
# ─── Status ────────────────────────────────────────────────────────────────
|
|
481
|
+
cmd_status() {
|
|
482
|
+
ensure_durable_dir
|
|
483
|
+
|
|
484
|
+
local log_file dlq_file offsets_dir locks_dir
|
|
485
|
+
log_file="$(event_log_file)"
|
|
486
|
+
dlq_file="$(dlq_file)"
|
|
487
|
+
offsets_dir="${DURABLE_DIR}/offsets"
|
|
488
|
+
locks_dir="${DURABLE_DIR}/locks"
|
|
489
|
+
|
|
490
|
+
local log_events log_size
|
|
491
|
+
log_events=$(wc -l < "$log_file" 2>/dev/null || echo "0")
|
|
492
|
+
log_size=$(du -h "$log_file" 2>/dev/null | awk '{print $1}' || echo "0")
|
|
493
|
+
|
|
494
|
+
local dlq_events
|
|
495
|
+
dlq_events=$(wc -l < "$dlq_file" 2>/dev/null || echo "0")
|
|
496
|
+
|
|
497
|
+
local consumer_count
|
|
498
|
+
consumer_count=$(find "$offsets_dir" -name "consumer-*.offset" 2>/dev/null | wc -l || echo "0")
|
|
499
|
+
|
|
500
|
+
local active_locks
|
|
501
|
+
active_locks=$(find "$locks_dir" -type d -mindepth 1 2>/dev/null | wc -l || echo "0")
|
|
502
|
+
|
|
503
|
+
echo ""
|
|
504
|
+
echo -e "${CYAN}${BOLD} Durable Workflow Status${RESET} ${DIM}v${VERSION}${RESET}"
|
|
505
|
+
echo -e "${DIM} ══════════════════════════════════════════${RESET}"
|
|
506
|
+
echo ""
|
|
507
|
+
echo -e " ${BOLD}Event Log${RESET}"
|
|
508
|
+
echo -e " Events: ${GREEN}$log_events${RESET}"
|
|
509
|
+
echo -e " Size: ${GREEN}$log_size${RESET}"
|
|
510
|
+
echo ""
|
|
511
|
+
echo -e " ${BOLD}Consumers${RESET}"
|
|
512
|
+
echo -e " Count: ${GREEN}$consumer_count${RESET}"
|
|
513
|
+
echo ""
|
|
514
|
+
echo -e " ${BOLD}Dead Letter Queue${RESET}"
|
|
515
|
+
echo -e " Events: ${YELLOW}$dlq_events${RESET}"
|
|
516
|
+
echo ""
|
|
517
|
+
echo -e " ${BOLD}Distributed Locks${RESET}"
|
|
518
|
+
echo -e " Active: ${CYAN}$active_locks${RESET}"
|
|
519
|
+
echo ""
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
# ─── Help ──────────────────────────────────────────────────────────────────
|
|
523
|
+
show_help() {
|
|
524
|
+
echo ""
|
|
525
|
+
echo -e "${CYAN}${BOLD} Shipwright Durable Workflow Engine${RESET} ${DIM}v${VERSION}${RESET}"
|
|
526
|
+
echo -e "${DIM} ════════════════════════════════════════════════════════${RESET}"
|
|
527
|
+
echo ""
|
|
528
|
+
echo -e " ${BOLD}USAGE${RESET}"
|
|
529
|
+
echo -e " shipwright durable <command> [options]"
|
|
530
|
+
echo ""
|
|
531
|
+
echo -e " ${BOLD}COMMANDS${RESET}"
|
|
532
|
+
echo -e " ${CYAN}publish${RESET} <type> <payload> Publish event to WAL"
|
|
533
|
+
echo -e " ${CYAN}consume${RESET} <id> <handler> Process next unconsumed event"
|
|
534
|
+
echo -e " ${CYAN}replay${RESET} [seq] [handler] Replay events from sequence"
|
|
535
|
+
echo -e " ${CYAN}checkpoint${RESET} <cmd> Save/restore workflow checkpoint"
|
|
536
|
+
echo -e " ${CYAN}lock${RESET} <cmd> Acquire/release distributed lock"
|
|
537
|
+
echo -e " ${CYAN}dlq${RESET} <cmd> Inspect/retry dead letter queue"
|
|
538
|
+
echo -e " ${CYAN}compact${RESET} Compact the event log"
|
|
539
|
+
echo -e " ${CYAN}status${RESET} Show event log statistics"
|
|
540
|
+
echo -e " ${CYAN}help${RESET} Show this help message"
|
|
541
|
+
echo ""
|
|
542
|
+
echo -e " ${BOLD}CHECKPOINT SUBCOMMANDS${RESET}"
|
|
543
|
+
echo -e " ${CYAN}save${RESET} <wf-id> <stage> <seq> <state> Save checkpoint"
|
|
544
|
+
echo -e " ${CYAN}restore${RESET} <wf-id> Restore checkpoint"
|
|
545
|
+
echo ""
|
|
546
|
+
echo -e " ${BOLD}LOCK SUBCOMMANDS${RESET}"
|
|
547
|
+
echo -e " ${CYAN}acquire${RESET} <resource> [timeout] Acquire lock (default 30s)"
|
|
548
|
+
echo -e " ${CYAN}release${RESET} <resource> Release lock"
|
|
549
|
+
echo ""
|
|
550
|
+
echo -e " ${BOLD}DLQ SUBCOMMANDS${RESET}"
|
|
551
|
+
echo -e " ${CYAN}list${RESET} List dead letter events"
|
|
552
|
+
echo -e " ${CYAN}inspect${RESET} <event-id> Inspect failed event"
|
|
553
|
+
echo -e " ${CYAN}retry${RESET} <event-id> [max-retries] Retry failed event"
|
|
554
|
+
echo ""
|
|
555
|
+
echo -e " ${BOLD}EXAMPLES${RESET}"
|
|
556
|
+
echo -e " ${DIM}# Publish an event${RESET}"
|
|
557
|
+
echo -e " shipwright durable publish workflow.started '{\"workflow_id\":\"wf-123\"}'${RESET}"
|
|
558
|
+
echo ""
|
|
559
|
+
echo -e " ${DIM}# Save checkpoint at stage boundary${RESET}"
|
|
560
|
+
echo -e " shipwright durable checkpoint save wf-123 build 42 '{\"files\":[\"main.rs\"]}'${RESET}"
|
|
561
|
+
echo ""
|
|
562
|
+
echo -e " ${DIM}# Acquire distributed lock${RESET}"
|
|
563
|
+
echo -e " shipwright durable lock acquire my-resource 60${RESET}"
|
|
564
|
+
echo ""
|
|
565
|
+
echo -e " ${DIM}# Consume events with custom handler${RESET}"
|
|
566
|
+
echo -e " shipwright durable consume my-consumer 'jq .event_type'${RESET}"
|
|
567
|
+
echo ""
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
# ─── Checkpoint Subcommands ────────────────────────────────────────────────
|
|
571
|
+
cmd_checkpoint() {
|
|
572
|
+
local subcmd="${1:-help}"
|
|
573
|
+
|
|
574
|
+
case "$subcmd" in
|
|
575
|
+
save)
|
|
576
|
+
if [[ $# -lt 5 ]]; then
|
|
577
|
+
error "Usage: shipwright durable checkpoint save <wf-id> <stage> <seq> <state>"
|
|
578
|
+
return 1
|
|
579
|
+
fi
|
|
580
|
+
save_checkpoint "$2" "$3" "$4" "$5"
|
|
581
|
+
;;
|
|
582
|
+
restore)
|
|
583
|
+
if [[ $# -lt 2 ]]; then
|
|
584
|
+
error "Usage: shipwright durable checkpoint restore <wf-id>"
|
|
585
|
+
return 1
|
|
586
|
+
fi
|
|
587
|
+
restore_checkpoint "$2"
|
|
588
|
+
;;
|
|
589
|
+
*)
|
|
590
|
+
error "Unknown checkpoint subcommand: $subcmd"
|
|
591
|
+
return 1
|
|
592
|
+
;;
|
|
593
|
+
esac
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
# ─── Lock Subcommands ──────────────────────────────────────────────────────
|
|
597
|
+
cmd_lock() {
|
|
598
|
+
local subcmd="${1:-help}"
|
|
599
|
+
|
|
600
|
+
case "$subcmd" in
|
|
601
|
+
acquire)
|
|
602
|
+
if [[ $# -lt 2 ]]; then
|
|
603
|
+
error "Usage: shipwright durable lock acquire <resource> [timeout]"
|
|
604
|
+
return 1
|
|
605
|
+
fi
|
|
606
|
+
acquire_lock "$2" "${3:-30}"
|
|
607
|
+
;;
|
|
608
|
+
release)
|
|
609
|
+
if [[ $# -lt 2 ]]; then
|
|
610
|
+
error "Usage: shipwright durable lock release <resource>"
|
|
611
|
+
return 1
|
|
612
|
+
fi
|
|
613
|
+
release_lock "$2"
|
|
614
|
+
;;
|
|
615
|
+
*)
|
|
616
|
+
error "Unknown lock subcommand: $subcmd"
|
|
617
|
+
return 1
|
|
618
|
+
;;
|
|
619
|
+
esac
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
# ─── DLQ Subcommands ──────────────────────────────────────────────────────
|
|
623
|
+
cmd_dlq() {
|
|
624
|
+
local subcmd="${1:-help}"
|
|
625
|
+
local dlq_path
|
|
626
|
+
dlq_path="$(dlq_file)"
|
|
627
|
+
|
|
628
|
+
case "$subcmd" in
|
|
629
|
+
list)
|
|
630
|
+
if [[ ! -f "$dlq_path" ]]; then
|
|
631
|
+
info "Dead letter queue is empty"
|
|
632
|
+
return 0
|
|
633
|
+
fi
|
|
634
|
+
cat "$dlq_path" | jq .
|
|
635
|
+
;;
|
|
636
|
+
inspect)
|
|
637
|
+
if [[ $# -lt 2 ]]; then
|
|
638
|
+
error "Usage: shipwright durable dlq inspect <event-id>"
|
|
639
|
+
return 1
|
|
640
|
+
fi
|
|
641
|
+
if [[ ! -f "$dlq_path" ]]; then
|
|
642
|
+
error "Dead letter queue is empty"
|
|
643
|
+
return 1
|
|
644
|
+
fi
|
|
645
|
+
grep "$2" "$dlq_path" | jq .
|
|
646
|
+
;;
|
|
647
|
+
retry)
|
|
648
|
+
if [[ $# -lt 2 ]]; then
|
|
649
|
+
error "Usage: shipwright durable dlq retry <event-id> [max-retries]"
|
|
650
|
+
return 1
|
|
651
|
+
fi
|
|
652
|
+
warn "DLQ retry for $2 (would re-publish event and resume processing)"
|
|
653
|
+
;;
|
|
654
|
+
*)
|
|
655
|
+
error "Unknown dlq subcommand: $subcmd"
|
|
656
|
+
return 1
|
|
657
|
+
;;
|
|
658
|
+
esac
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
# ─── Main Command Router ───────────────────────────────────────────────────
|
|
662
|
+
main() {
|
|
663
|
+
local cmd="${1:-help}"
|
|
664
|
+
shift 2>/dev/null || true
|
|
665
|
+
|
|
666
|
+
case "$cmd" in
|
|
667
|
+
publish)
|
|
668
|
+
if [[ $# -lt 2 ]]; then
|
|
669
|
+
error "Usage: shipwright durable publish <type> <payload>"
|
|
670
|
+
return 1
|
|
671
|
+
fi
|
|
672
|
+
publish_event "$1" "$2"
|
|
673
|
+
;;
|
|
674
|
+
consume)
|
|
675
|
+
cmd_consume "$@"
|
|
676
|
+
;;
|
|
677
|
+
replay)
|
|
678
|
+
cmd_replay "$@"
|
|
679
|
+
;;
|
|
680
|
+
checkpoint)
|
|
681
|
+
cmd_checkpoint "$@"
|
|
682
|
+
;;
|
|
683
|
+
lock)
|
|
684
|
+
cmd_lock "$@"
|
|
685
|
+
;;
|
|
686
|
+
dlq)
|
|
687
|
+
cmd_dlq "$@"
|
|
688
|
+
;;
|
|
689
|
+
compact)
|
|
690
|
+
cmd_compact
|
|
691
|
+
;;
|
|
692
|
+
status)
|
|
693
|
+
cmd_status
|
|
694
|
+
;;
|
|
695
|
+
help|--help|-h)
|
|
696
|
+
show_help
|
|
697
|
+
;;
|
|
698
|
+
*)
|
|
699
|
+
error "Unknown command: $cmd"
|
|
700
|
+
echo ""
|
|
701
|
+
show_help
|
|
702
|
+
return 1
|
|
703
|
+
;;
|
|
704
|
+
esac
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
# ─── Source Guard ─────────────────────────────────────────────────────────
|
|
708
|
+
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
|
709
|
+
main "$@"
|
|
710
|
+
fi
|