+162
-15
@@ -190,18 +190,23 @@ type taskCard struct {
|
||||
}
|
||||
|
||||
type taskDTO struct {
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
ServerMinIntervalSec int `json:"server_min_interval_sec"`
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
DefaultMaxNodes int `json:"default_max_nodes"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
ParentTaskID *string `json:"parent_task_id"`
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
ServerMinIntervalSec int `json:"server_min_interval_sec"`
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
DefaultMaxNodes int `json:"default_max_nodes"`
|
||||
GuessLotteryWindowSec int `json:"guess_lottery_window_sec"`
|
||||
GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"`
|
||||
BeaconHuntEnabled int `json:"beacon_hunt_enabled"`
|
||||
BeaconBonusWeight int `json:"beacon_bonus_weight"`
|
||||
BeaconPaths []string `json:"beacon_paths"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
ParentTaskID *string `json:"parent_task_id"`
|
||||
}
|
||||
|
||||
type point struct {
|
||||
@@ -243,6 +248,15 @@ type publicArtifact struct {
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
}
|
||||
|
||||
type ownedArtifact struct {
|
||||
TaskID string `json:"task_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
DownloadURI string `json:"download_uri"`
|
||||
}
|
||||
|
||||
func (c *apiClient) tasks(ctx context.Context) ([]taskCard, error) {
|
||||
var out []taskCard
|
||||
err := c.do(ctx, http.MethodGet, "/api/tasks", nil, &out)
|
||||
@@ -273,6 +287,18 @@ func (c *apiClient) me(ctx context.Context) (meDTO, error) {
|
||||
return out, err
|
||||
}
|
||||
|
||||
type hostedLinkCode struct {
|
||||
Code string `json:"code"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (c *apiClient) hostedLinkCode(ctx context.Context) (hostedLinkCode, error) {
|
||||
var out hostedLinkCode
|
||||
err := c.do(ctx, http.MethodPost, "/api/me/customer-link", map[string]any{}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) leaderboard(ctx context.Context) ([]leader, error) {
|
||||
var out []leader
|
||||
err := c.do(ctx, http.MethodGet, "/api/leaderboard", nil, &out)
|
||||
@@ -285,14 +311,25 @@ func (c *apiClient) artifacts(ctx context.Context, limit int) ([]publicArtifact,
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64) (bool, error) {
|
||||
func (c *apiClient) ownedArtifacts(ctx context.Context, limit int) ([]ownedArtifact, error) {
|
||||
var out []ownedArtifact
|
||||
err := c.do(ctx, http.MethodGet, "/api/me/artifacts?limit="+strconv.Itoa(limit), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64, beaconPath string) (bool, error) {
|
||||
guess := expectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits)
|
||||
sig, err := signRaw(c.key, fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess))
|
||||
msg := fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess)
|
||||
if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 {
|
||||
beaconPath = strings.ToUpper(strings.TrimSpace(beaconPath))
|
||||
msg += "|" + beaconPath
|
||||
}
|
||||
sig, err := signRaw(c.key, msg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var correct bool
|
||||
err = c.do(ctx, http.MethodPost, "/api/tasks/"+url.PathEscape(t.ID)+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig}, &correct)
|
||||
err = c.do(ctx, http.MethodPost, "/api/tasks/"+url.PathEscape(t.ID)+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig, "beacon_path": beaconPath}, &correct)
|
||||
return correct, err
|
||||
}
|
||||
|
||||
@@ -348,6 +385,38 @@ func (c *apiClient) downloadPreview(ctx context.Context, taskID, dest string) er
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *apiClient) downloadOwnedArtifact(ctx context.Context, taskID, dest string) error {
|
||||
path := "/api/me/artifacts/" + url.PathEscape(taskID) + "/download"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return &apiError{Status: resp.StatusCode, Body: string(b)}
|
||||
}
|
||||
if dir := filepath.Dir(dest); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 64<<20))
|
||||
return err
|
||||
}
|
||||
|
||||
func expectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
|
||||
// Keep this exactly aligned with internal/core.ExpectedGuess without importing
|
||||
// implementation details into the terminal UX layer.
|
||||
@@ -355,3 +424,81 @@ func expectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
|
||||
}
|
||||
|
||||
var errSwitching = errors.New("task switch in progress")
|
||||
|
||||
// registerHostedWorker binds this worker's freshly authenticated cryptographic
|
||||
// identity to its Customer Service worker record. The endpoint is reachable
|
||||
// only on the private Docker network and additionally requires the per-worker
|
||||
// one-time bearer token injected by the Customer Service.
|
||||
func (c *apiClient) registerHostedWorker(ctx context.Context) error {
|
||||
registerURL := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_URL"))
|
||||
workerID := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID"))
|
||||
token := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_TOKEN"))
|
||||
if registerURL == "" && workerID == "" && token == "" {
|
||||
return nil
|
||||
}
|
||||
if registerURL == "" || workerID == "" || token == "" {
|
||||
return errors.New("hosted worker registration requires NEURALHUNT_WORKER_REGISTER_URL, NEURALHUNT_WORKER_ID and NEURALHUNT_WORKER_REGISTER_TOKEN")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"worker_id": workerID, "client_id": c.cid})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, registerURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
hc := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hosted worker register: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("hosted worker register HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostedWorkerLeaseLoop is a fail-closed control-plane lease. Billing remains
|
||||
// authoritative in Customer Service; this loop merely prevents a managed
|
||||
// worker from running indefinitely if the Customer Service/Docker controller
|
||||
// disappears. After three consecutive missed 20-second renewals it cancels the
|
||||
// client context and the managed container exits.
|
||||
func (c *apiClient) hostedWorkerLeaseLoop(ctx context.Context, cancel context.CancelFunc) {
|
||||
leaseURL := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_LEASE_URL"))
|
||||
workerID := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID"))
|
||||
token := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_TOKEN"))
|
||||
if leaseURL == "" || workerID == "" || token == "" {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(20 * time.Second)
|
||||
defer t.Stop()
|
||||
failures := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
body, _ := json.Marshal(map[string]string{"worker_id": workerID})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, leaseURL, bytes.NewReader(body))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, callErr := (&http.Client{Timeout: 8 * time.Second}).Do(req)
|
||||
if callErr == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode/100 == 2 {
|
||||
failures = 0
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
failures++
|
||||
if failures >= 3 {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user