Skip to content

Commit 5fe282b

Browse files
authored
Merge pull request #23 from pilot-protocol/feat/site-search-and-submit
Handover prep: cleanup, signed CI/CD, runbook, god-file split, ENV.md, admin lockdown
2 parents 2bd9dd6 + 576ba06 commit 5fe282b

100 files changed

Lines changed: 20161 additions & 13918 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@ jobs:
2020
go-version: '1.25'
2121
cache: true
2222

23+
- name: gofmt (formatting gate)
24+
run: |
25+
unformatted=$(gofmt -l .)
26+
if [ -n "$unformatted" ]; then
27+
echo "These files are not gofmt-clean:"; echo "$unformatted"
28+
echo "Run: gofmt -w ."; exit 1
29+
fi
30+
31+
- name: go vet
32+
run: go vet ./...
33+
34+
- name: Cross-compile for prod target (linux/arm64)
35+
# Production runs on a GH200 (linux/arm64). Pure-Go + CGO_ENABLED=0
36+
# means this is a clean cross-compile on the amd64 runner — no QEMU,
37+
# no self-hosted runner. Catches a compile break on the prod arch
38+
# that the host-arch test build would miss.
39+
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/cosift
40+
2341
- name: Run tests with coverage
2442
# cosift's internal/crawler hits ~25s; allow 5m total headroom.
2543
run: go test -race -coverprofile=coverage.out -covermode=atomic -timeout 5m ./...

.github/workflows/deploy.yml

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
name: deploy
2+
3+
# Build + sign + publish a release artifact. PULL-BASED deploy:
4+
# this workflow never touches the production box and never holds an SSH key.
5+
# The box runs cosift-self-update.timer, which polls the latest GitHub Release,
6+
# verifies sha256 + minisign signature against a public key baked on the box,
7+
# snapshots, atomically swaps the binary, restarts, and health-gates with
8+
# auto-rollback. See deploy/scripts/README.md.
9+
#
10+
# Triggers:
11+
# - push of a version tag (v*) → cut a release for that tag
12+
# - manual workflow_dispatch → build from a chosen ref (no release
13+
# unless a tag is also present)
14+
15+
on:
16+
push:
17+
tags:
18+
- 'v*'
19+
workflow_dispatch:
20+
21+
permissions:
22+
contents: write # needed to create the GitHub Release + upload assets
23+
24+
jobs:
25+
# (a) verify — gate the release on vet + race tests + a smoke subset.
26+
verify:
27+
runs-on: ubuntu-latest
28+
steps:
29+
- uses: actions/checkout@v6
30+
31+
- uses: actions/setup-go@v6
32+
with:
33+
go-version: '1.25'
34+
cache: true
35+
36+
- name: go vet
37+
run: go vet ./...
38+
39+
- name: Tests (race)
40+
run: go test -race -timeout 5m ./...
41+
42+
- name: Smoke subset (build + serve roundtrip)
43+
# make smoke builds the binary, crawls, and asserts /healthz + /search.
44+
# Guarded with a timeout so a hung crawl can't wedge the release.
45+
run: timeout 300 make smoke
46+
47+
# (b) build — reproducible static arm64 binary, sha256, minisign signature.
48+
build:
49+
needs: verify
50+
runs-on: ubuntu-latest
51+
outputs:
52+
version: ${{ steps.ver.outputs.version }}
53+
steps:
54+
- uses: actions/checkout@v6
55+
with:
56+
fetch-depth: 0 # full history so version stamping is meaningful
57+
58+
- uses: actions/setup-go@v6
59+
with:
60+
go-version: '1.25'
61+
cache: true
62+
63+
- name: Resolve version stamp
64+
id: ver
65+
# Tag name when triggered by a tag push; otherwise the short SHA.
66+
run: |
67+
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
68+
echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
69+
else
70+
echo "version=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
71+
fi
72+
73+
- name: Build (linux/arm64, static, trimmed)
74+
env:
75+
CGO_ENABLED: '0'
76+
GOOS: linux
77+
GOARCH: arm64
78+
VERSION: ${{ steps.ver.outputs.version }}
79+
# Stamp both main.version and internal/server.Version so /healthz and
80+
# /metrics report the shipped version. Matches the Makefile `build`
81+
# target's ldflags exactly (kept in sync deliberately).
82+
run: |
83+
go build -trimpath \
84+
-ldflags "-s -w -X main.version=${VERSION} -X github.com/pilot-protocol/cosift/internal/server.Version=${VERSION}" \
85+
-o cosift-linux-arm64 ./cmd/cosift
86+
87+
- name: sha256
88+
run: sha256sum cosift-linux-arm64 | tee cosift-linux-arm64.sha256
89+
90+
- name: Install minisign
91+
run: sudo apt-get update && sudo apt-get install -y minisign
92+
93+
- name: Sign with minisign
94+
env:
95+
# Repo secret. The matching PUBLIC key is baked on the box at
96+
# /etc/cosift/minisign.pub. Generate with `minisign -G` (see
97+
# deploy/scripts/README.md). NEVER commit the secret key.
98+
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
99+
run: |
100+
if [ -z "${MINISIGN_SECRET_KEY}" ]; then
101+
echo "::error::MINISIGN_SECRET_KEY secret is not set" >&2
102+
exit 1
103+
fi
104+
printf '%s' "${MINISIGN_SECRET_KEY}" > minisign.key
105+
# -W: secret key is unencrypted (no interactive passphrase prompt).
106+
# Trusted comment carries the version so the box can sanity-check it.
107+
minisign -S -W -s minisign.key \
108+
-m cosift-linux-arm64 \
109+
-t "cosift ${{ steps.ver.outputs.version }} linux/arm64"
110+
rm -f minisign.key
111+
# Verification against the embedded public key is done on the box;
112+
# here we just confirm the .minisig was produced.
113+
test -f cosift-linux-arm64.minisig
114+
115+
- uses: actions/upload-artifact@v7
116+
with:
117+
name: cosift-release-artifacts
118+
path: |
119+
cosift-linux-arm64
120+
cosift-linux-arm64.sha256
121+
cosift-linux-arm64.minisig
122+
retention-days: 30
123+
124+
# (c) release — publish the signed binary as a GitHub Release asset.
125+
# Only on a tag push (workflow_dispatch builds the artifact but does not
126+
# cut a release).
127+
release:
128+
needs: build
129+
if: github.ref_type == 'tag'
130+
runs-on: ubuntu-latest
131+
permissions:
132+
contents: write
133+
steps:
134+
- uses: actions/download-artifact@v7
135+
with:
136+
name: cosift-release-artifacts
137+
138+
- name: Create / update GitHub Release
139+
uses: softprops/action-gh-release@v2
140+
with:
141+
tag_name: ${{ github.ref_name }}
142+
name: cosift ${{ github.ref_name }}
143+
generate_release_notes: true
144+
fail_on_unmatched_files: true
145+
files: |
146+
cosift-linux-arm64
147+
cosift-linux-arm64.sha256
148+
cosift-linux-arm64.minisig

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ smoke:
2222
@./scripts/smoke-test.sh
2323

2424
build:
25-
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$(VERSION) -X github.com/calinteodor/cosift/internal/server.Version=$(VERSION)" -o $(BINARY) $(PKG)
25+
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$(VERSION) -X github.com/pilot-protocol/cosift/internal/server.Version=$(VERSION)" -o $(BINARY) $(PKG)
2626

2727
# Light-touch verification: compile + vet + unit tests on packages that
2828
# don't need OPENAI/COHERE keys or live network. Catches latent compile

cmd/cosift/assets/chat.html

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,22 @@
213213
font-family: var(--mono); font-size: 11px;
214214
padding: 6px 8px; cursor: pointer;
215215
}
216+
.site-input-wrap {
217+
display: flex; align-items: center; gap: 4px;
218+
border: 1px solid var(--line); border-radius: 8px;
219+
background: var(--bg-2); padding: 0 8px;
220+
}
221+
.site-input-label {
222+
font-family: var(--mono); font-size: 10px; color: var(--ink-dim);
223+
white-space: nowrap; user-select: none; flex-shrink: 0;
224+
}
225+
.site-input {
226+
border: none; background: transparent; color: var(--ink);
227+
font-family: var(--mono); font-size: 11px;
228+
padding: 5px 0; width: 140px; min-width: 0;
229+
outline: none;
230+
}
231+
.site-input::placeholder { color: var(--ink-dim); opacity: 0.6; }
216232
.send-btn {
217233
padding: 14px 22px;
218234
background: var(--accent); color: var(--accent-ink);
@@ -324,6 +340,10 @@ <h1>Chat with the corpus.</h1>
324340
<div class="composer-row">
325341
<textarea id="input" class="composer-input" rows="1" placeholder="Ask anything…" autofocus></textarea>
326342
<div class="composer-controls">
343+
<div class="site-input-wrap" title="Restrict results to a specific domain, e.g. pilotprotocol.network or docs.example.com/api">
344+
<span class="site-input-label">site:</span>
345+
<input id="site-filter" class="site-input" type="text" placeholder="domain.com" autocomplete="off" spellcheck="false">
346+
</div>
327347
<select id="mode" title="query = LLM-planned hybrid (default) · answer = direct RAG · research = multi-step planner">
328348
<option value="query" selected>query</option>
329349
<option value="answer">answer</option>
@@ -332,7 +352,7 @@ <h1>Chat with the corpus.</h1>
332352
<button id="send" class="send-btn">Send →</button>
333353
</div>
334354
</div>
335-
<div class="composer-hint">Enter to send · Shift+Enter newline · /clear to reset</div>
355+
<div class="composer-hint">Enter to send · Shift+Enter newline · /clear to reset · site:domain.com to scope</div>
336356
</div>
337357

338358
<script>
@@ -349,9 +369,16 @@ <h1>Chat with the corpus.</h1>
349369
var inputEl = document.getElementById('input');
350370
var sendBtn = document.getElementById('send');
351371
var modeEl = document.getElementById('mode');
372+
var siteEl = document.getElementById('site-filter');
352373
var emptyEl = document.getElementById('empty-state');
353374
var clearBtn = document.getElementById('clear-btn');
354375

376+
// Persist site filter across page loads
377+
try { var _s = localStorage.getItem('cosift-site'); if (_s) siteEl.value = _s; } catch(e) {}
378+
siteEl.addEventListener('change', function () {
379+
try { localStorage.setItem('cosift-site', siteEl.value.trim()); } catch(e) {}
380+
});
381+
355382
function esc(s) { return String(s).replace(/[&<>"]/g, function(c){ return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]); }); }
356383

357384
function autoresize() {
@@ -519,7 +546,19 @@ <h1>Chat with the corpus.</h1>
519546
autoresize();
520547
return;
521548
}
522-
appendMsg('user', q);
549+
550+
// Allow inline site:domain.com syntax — strip it from the query and
551+
// populate the site filter input. e.g. "pilot tunnels site:pilotprotocol.network"
552+
var siteMatch = q.match(/(?:^|\s)site:(\S+)/);
553+
var site = siteEl.value.trim();
554+
if (siteMatch) {
555+
q = q.replace(siteMatch[0], ' ').trim();
556+
site = siteMatch[1];
557+
siteEl.value = site;
558+
try { localStorage.setItem('cosift-site', site); } catch(e) {}
559+
}
560+
561+
appendMsg('user', q + (site ? ' [site: ' + site + ']' : ''));
523562
inputEl.value = '';
524563
autoresize();
525564

@@ -540,6 +579,7 @@ <h1>Chat with the corpus.</h1>
540579
// progress (planner sub-queries, per-expansion hit counts, judge
541580
// drops, synth tokens).
542581
var url = endpoint + '?q=' + encodeURIComponent(q) + '&k=5&stream=true';
582+
if (site) url += '&site=' + encodeURIComponent(site);
543583

544584
try {
545585
var resp = await fetch(url, { headers: { 'Accept': 'text/event-stream' } });

cmd/cosift/assets/landing.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,9 @@ <h2>Search the <em>live</em> <span class="accent">corpus.</span></h2>
340340
<button class="btn primary" type="submit">Search <span class="arr"></span></button>
341341
</form>
342342
<div class="tryit-knobs">
343+
<label>site
344+
<input type="text" id="tryit-site" placeholder="domain.com" style="width:140px; font-family:monospace" title="Restrict results to a domain, e.g. pilotprotocol.network" autocomplete="off" spellcheck="false"/>
345+
</label>
343346
<label>retriever
344347
<select id="tryit-retriever">
345348
<option value="bm25" selected>bm25</option>
@@ -561,6 +564,8 @@ <h2>Ask <em>anything.</em></h2>
561564
var p = new URLSearchParams();
562565
p.set('q', q);
563566
p.set('k', document.getElementById('tryit-k').value || '5');
567+
var site = document.getElementById('tryit-site').value.trim();
568+
if (site) p.set('site', site);
564569
var retr = document.getElementById('tryit-retriever').value;
565570
if (retr && retr !== 'bm25') p.set('retriever', retr);
566571
var mmr = document.getElementById('tryit-mmr').value;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"time"
9+
10+
"github.com/pilot-protocol/cosift/internal/store"
11+
)
12+
13+
// runBackfillHostPostings rebuilds the 'P' host-partition family from existing
14+
// 'h' + 'p' data so site= queries can scan a single host's postings
15+
// (O(site_docs)) via SearchInHost instead of the global lists (O(corpus)).
16+
//
17+
// It re-uses the data already on disk — no re-tokenize, no embeddings — so it's
18+
// a one-time key reshuffle, typically ~1-2h on a multi-million-doc corpus. The
19+
// store opens read-write because it writes the new family, but it never touches
20+
// 'p'/'d'/'v', so it is safe to run alongside a live pebble-serve. After it
21+
// completes, set COSIFT_HOST_PARTITION_READ=1 on the server to route site=
22+
// queries to the partition (and COSIFT_HOST_PARTITION=1 to keep it fresh on
23+
// future crawls).
24+
//
25+
// cosift backfill-host-postings -dir /home/ubuntu/cosift-data/pebble
26+
func runBackfillHostPostings(ctx context.Context, args []string) error {
27+
fs := flag.NewFlagSet("backfill-host-postings", flag.ExitOnError)
28+
dir := fs.String("dir", "", "PebbleStore directory (required; same dir as pebble-serve -dir)")
29+
host := fs.String("host", "", "backfill only this host (fast 'g'-index path); empty = full corpus single-pass")
30+
if err := fs.Parse(args); err != nil {
31+
return err
32+
}
33+
if *dir == "" {
34+
return fmt.Errorf("-dir required")
35+
}
36+
37+
ps, err := store.OpenPebble(*dir)
38+
if err != nil {
39+
return fmt.Errorf("open store: %w", err)
40+
}
41+
defer ps.Close()
42+
43+
start := time.Now()
44+
if *host != "" {
45+
fmt.Fprintf(os.Stderr, "backfill-host-postings: targeted backfill for host %q (fast 'g'-index path)…\n", *host)
46+
} else {
47+
fmt.Fprintf(os.Stderr, "backfill-host-postings: full-corpus single-pass 'p'→'P'…\n")
48+
}
49+
last := start
50+
written, err := ps.BackfillHostPostings(ctx, *host, func(n int64) {
51+
now := time.Now()
52+
if now.Sub(last) >= 5*time.Second {
53+
rate := float64(n) / time.Since(start).Seconds()
54+
fmt.Fprintf(os.Stderr, " %d host postings written (%.0f/s, %s elapsed)\n",
55+
n, rate, time.Since(start).Round(time.Second))
56+
last = now
57+
}
58+
})
59+
if err != nil {
60+
return fmt.Errorf("backfill: %w (wrote %d before failing)", err, written)
61+
}
62+
fmt.Fprintf(os.Stderr, "backfill-host-postings: done — %d host postings in %s\n",
63+
written, time.Since(start).Round(time.Second))
64+
fmt.Fprintf(os.Stderr, "Next: set COSIFT_HOST_PARTITION_READ=1 (read) + COSIFT_HOST_PARTITION=1 (keep fresh) and restart pebble-serve.\n")
65+
return nil
66+
}

0 commit comments

Comments
 (0)