Skip to content

Commit 9c3d894

Browse files
chore: apply pr-reviewer fixes for #244 (#247)
Co-authored-by: agent-relay-code[bot] <agent-relay-code[bot]@users.noreply.github.com>
1 parent e9cec99 commit 9c3d894

9 files changed

Lines changed: 156 additions & 7 deletions

File tree

cmd/relayfile-cli/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2753,7 +2753,7 @@ func deadLetterErrorPathFor(localDir, opID string) string {
27532753

27542754
func runWriteback(args []string, stdout io.Writer) error {
27552755
if len(args) == 0 {
2756-
return errors.New("writeback subcommand is required: list, status, or retry")
2756+
return errors.New("writeback subcommand is required: list, status, retry, or sweep-drafts")
27572757
}
27582758
switch args[0] {
27592759
case "list":

cmd/relayfile-cli/writeback_sweep.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,16 @@ func runWritebackSweepDrafts(args []string, stdout io.Writer) error {
6666
return errors.New(writebackSweepUsage)
6767
}
6868

69-
record, err := resolveWorkspaceRecord(firstArg(fs))
69+
creds, err := loadCredentials()
7070
if err != nil {
7171
return err
7272
}
73-
creds, err := loadCredentials()
73+
tokenValue := resolveToken(*tokenOverride, creds)
74+
workspaceID, err := resolveWorkspaceIDWithToken(firstArg(fs), tokenValue)
7475
if err != nil {
7576
return err
7677
}
77-
client, err := newAPIClient(resolveServer(*server, creds), resolveToken(*tokenOverride, creds))
78+
client, err := newAPIClient(resolveServer(*server, creds), tokenValue)
7879
if err != nil {
7980
return err
8081
}
@@ -87,7 +88,7 @@ func runWritebackSweepDrafts(args []string, stdout io.Writer) error {
8788
var result sweepDraftsResult
8889
if err := client.postJSON(
8990
context.Background(),
90-
fmt.Sprintf("/v1/workspaces/%s/writeback/sweep-drafts", url.PathEscape(record.ID)),
91+
fmt.Sprintf("/v1/workspaces/%s/writeback/sweep-drafts", url.PathEscape(workspaceID)),
9192
requestBody,
9293
&result,
9394
); err != nil {

cmd/relayfile-cli/writeback_sweep_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,31 @@ func TestWritebackSweepDraftsApplyAndJSON(t *testing.T) {
128128
}
129129
}
130130

131+
func TestWritebackSweepDraftsUsesTokenWorkspaceWithoutPositionalArg(t *testing.T) {
132+
overrideToken := testJWTWithWorkspace("rw_token")
133+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134+
if r.URL.Path != "/v1/workspaces/rw_token/writeback/sweep-drafts" {
135+
t.Fatalf("unexpected request path: %s", r.URL.Path)
136+
}
137+
if got := r.Header.Get("Authorization"); got != "Bearer "+overrideToken {
138+
t.Fatalf("unexpected Authorization: %q", got)
139+
}
140+
w.Header().Set("Content-Type", "application/json")
141+
_, _ = w.Write([]byte(`{"dryRun": true, "scanned": 0, "removed": [], "skipped": []}`))
142+
}))
143+
defer server.Close()
144+
145+
setupSweepWorkspace(t, server.URL)
146+
var stdout bytes.Buffer
147+
err := run(
148+
[]string{"writeback", "sweep-drafts", "--token", overrideToken},
149+
strings.NewReader(""), &stdout, &stdout,
150+
)
151+
if err != nil {
152+
t.Fatalf("run writeback sweep-drafts with token override failed: %v", err)
153+
}
154+
}
155+
131156
func TestWritebackSweepDraftsRejectsExtraArgs(t *testing.T) {
132157
t.Setenv("HOME", t.TempDir())
133158
clearRelayfileEnv(t)
@@ -137,3 +162,11 @@ func TestWritebackSweepDraftsRejectsExtraArgs(t *testing.T) {
137162
t.Fatalf("expected usage error, got %v", err)
138163
}
139164
}
165+
166+
func TestWritebackMissingSubcommandMentionsSweepDrafts(t *testing.T) {
167+
var stdout bytes.Buffer
168+
err := run([]string{"writeback"}, strings.NewReader(""), &stdout, &stdout)
169+
if err == nil || !strings.Contains(err.Error(), "sweep-drafts") {
170+
t.Fatalf("expected missing subcommand error to mention sweep-drafts, got %v", err)
171+
}
172+
}

internal/httpapi/server.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2208,9 +2208,12 @@ func (s *Server) handleWritebackAck(w http.ResponseWriter, r *http.Request, work
22082208
}
22092209

22102210
ack := relayfile.WritebackAck{}
2211-
if ok, ok2 := payload["success"].(bool); ok2 {
2212-
ack.Success = ok
2211+
success, ok := payload["success"].(bool)
2212+
if !ok {
2213+
writeError(w, http.StatusBadRequest, "bad_request", "success must be a boolean", correlationID)
2214+
return
22132215
}
2216+
ack.Success = success
22142217
if e, ok := payload["error"].(string); ok {
22152218
ack.Error = e
22162219
}

internal/httpapi/server_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5847,6 +5847,54 @@ func TestWritebackAckWithExternalIDReconcilesDraft(t *testing.T) {
58475847
}
58485848
}
58495849

5850+
func TestWritebackAckRejectsMissingSuccess(t *testing.T) {
5851+
store := relayfile.NewStoreWithOptions(relayfile.StoreOptions{ExternalWritebackMode: true})
5852+
defer store.Close()
5853+
server := NewServer(store)
5854+
token := mustTestJWT(t, "dev-secret", "ws_1", "Worker1", []string{"fs:write", "sync:read", "sync:trigger"}, time.Now().Add(time.Hour))
5855+
5856+
writeResp := doRequest(t, server, request{
5857+
method: http.MethodPut,
5858+
path: "/v1/workspaces/ws_1/fs/file?path=" + url.QueryEscape("/slack/channels/C0ALQ06AAUT/messages/messages 0e89a031-65f0-480e-a823-ab1d94b324ea.json"),
5859+
headers: map[string]string{
5860+
"Authorization": "Bearer " + token,
5861+
"X-Correlation-Id": "corr_write_1",
5862+
"If-Match": "0",
5863+
},
5864+
body: map[string]any{
5865+
"contentType": "application/json",
5866+
"content": `{"text":"hi"}`,
5867+
},
5868+
})
5869+
if writeResp.Code != http.StatusAccepted {
5870+
t.Fatalf("expected 202 on draft write, got %d (%s)", writeResp.Code, writeResp.Body.String())
5871+
}
5872+
var writeResult relayfile.WriteResult
5873+
if err := json.NewDecoder(writeResp.Body).Decode(&writeResult); err != nil {
5874+
t.Fatalf("failed to decode write response: %v", err)
5875+
}
5876+
5877+
ackResp := doRequest(t, server, request{
5878+
method: http.MethodPost,
5879+
path: "/v1/workspaces/ws_1/writeback/" + writeResult.OpID + "/ack",
5880+
headers: map[string]string{
5881+
"Authorization": "Bearer " + token,
5882+
"X-Correlation-Id": "corr_ack_1",
5883+
},
5884+
body: map[string]any{"error": "missing success"},
5885+
})
5886+
if ackResp.Code != http.StatusBadRequest {
5887+
t.Fatalf("expected 400 on malformed ack, got %d (%s)", ackResp.Code, ackResp.Body.String())
5888+
}
5889+
ops, err := store.ListOperations("ws_1", "", "", "", "", 10)
5890+
if err != nil {
5891+
t.Fatalf("list operations failed: %v", err)
5892+
}
5893+
if len(ops.Items) != 1 || ops.Items[0].Status != "pending" {
5894+
t.Fatalf("malformed ack must leave operation pending, got %+v", ops.Items)
5895+
}
5896+
}
5897+
58505898
// Issue #242: POST /writeback/sweep-drafts drains accumulated draft residue.
58515899
func TestWritebackSweepDraftsEndpoint(t *testing.T) {
58525900
store := relayfile.NewStoreWithOptions(relayfile.StoreOptions{ExternalWritebackMode: true})

internal/relayfile/draft_reconcile.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ func isDraftFileBasename(basename string) bool {
114114
// enqueueWriteback, so the mutation cannot create operations or writebacks.
115115
func (s *Store) reconcileAckedDraftLocked(workspaceID string, ws *workspaceState, op OperationStatus, ack WritebackAck, correlationID string) DraftDisposition {
116116
none := DraftDisposition{Action: "none"}
117+
if ws.ProviderIndex == nil {
118+
ws.ProviderIndex = map[string]string{}
119+
}
117120
externalID := strings.TrimSpace(ack.ExternalID)
118121
if externalID == "" {
119122
return none
@@ -290,6 +293,9 @@ func (s *Store) SweepWritebackDrafts(workspaceID string, req SweepDraftsRequest)
290293
if _, err := path.Match(pattern, "probe.json"); err != nil {
291294
return SweepDraftsResult{}, ErrInvalidInput
292295
}
296+
if strings.ContainsAny(pattern, `/\`) {
297+
return SweepDraftsResult{}, ErrInvalidInput
298+
}
293299
}
294300

295301
s.mu.Lock()

internal/relayfile/draft_reconcile_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,25 @@ func TestAckWithExternalIDRenamesDraftToCanonicalID(t *testing.T) {
127127
}
128128
}
129129

130+
func TestAckWithExternalIDInitializesLegacyNilProviderIndex(t *testing.T) {
131+
store := newExternalStore(t)
132+
draftPath := "/slack/channels/C0ALQ06AAUT/messages/messages " + draftUUIDA + ".json"
133+
result := writeDraft(t, store, "ws_1", draftPath, `{"text":"hi"}`)
134+
store.mu.Lock()
135+
store.workspaces["ws_1"].ProviderIndex = nil
136+
store.mu.Unlock()
137+
138+
if _, err := store.AcknowledgeWriteback("ws_1", result.OpID, WritebackAck{
139+
Success: true,
140+
ExternalID: "1780018871.351819",
141+
}, "corr_ack_1"); err != nil {
142+
t.Fatalf("ack with nil provider index failed: %v", err)
143+
}
144+
if _, err := store.ReadFile("ws_1", "/slack/channels/C0ALQ06AAUT/messages/1780018871.351819.json"); err != nil {
145+
t.Fatalf("expected canonical-id file after ack: %v", err)
146+
}
147+
}
148+
130149
func TestAckRenameIsClassificationExempt(t *testing.T) {
131150
store := newExternalStore(t)
132151
draftPath := "/slack/channels/C0ALQ06AAUT/messages/messages " + draftUUIDA + ".json"
@@ -620,6 +639,15 @@ func TestSweepRejectsBlankWorkspace(t *testing.T) {
620639
}
621640
}
622641

642+
func TestSweepRejectsPatternsWithPathSeparators(t *testing.T) {
643+
store := newExternalStore(t)
644+
for _, pattern := range []string{"drafts/*.json", `drafts\*.json`} {
645+
if _, err := store.SweepWritebackDrafts("ws_1", SweepDraftsRequest{Patterns: []string{pattern}}); err != ErrInvalidInput {
646+
t.Fatalf("expected ErrInvalidInput for pattern %q, got %v", pattern, err)
647+
}
648+
}
649+
}
650+
623651
func TestDraftBasenameDetection(t *testing.T) {
624652
cases := []struct {
625653
path string

packages/sdk/typescript/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export type {
111111
export type {
112112
AckResponse,
113113
AckWritebackInput,
114+
AckWritebackDraftDisposition,
114115
AckWritebackResponse,
115116
AdminIngressAlert,
116117
AdminIngressAlertProfile,
@@ -199,6 +200,8 @@ export type {
199200
SyncProviderStatusState,
200201
SyncRefreshRequest,
201202
SyncStatusResponse,
203+
SweepWritebackDraftsInput,
204+
SweepWritebackDraftsResponse,
202205
TreeEntry,
203206
TreeResponse,
204207
WritebackActionType,

packages/sdk/typescript/src/types.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import type {
33
DigestHandler,
44
DigestSection,
55
LayoutManifest,
6+
SweepWritebackDraftsInput,
7+
SweepWritebackDraftsResponse,
8+
AckWritebackDraftDisposition,
69
WritebackDeadLetterError,
710
WritebackDeadLetterErrorCode,
811
WritebackItem,
@@ -86,6 +89,30 @@ describe("workspace primitive public types", () => {
8689
expect(item.state).toBe("dead");
8790
});
8891

92+
it("exports writeback ack and sweep public types", () => {
93+
const draft = {
94+
action: "renamed",
95+
from: "/slack/channels/C/messages/messages 0e89a031-65f0-480e-a823-ab1d94b324ea.json",
96+
to: "/slack/channels/C/messages/1780018871.351819.json"
97+
} satisfies AckWritebackDraftDisposition;
98+
const input = {
99+
workspaceId: "ws_acme",
100+
pathPrefix: "/slack/channels/C",
101+
patterns: ["wb-*.json"],
102+
apply: false
103+
} satisfies SweepWritebackDraftsInput;
104+
const response = {
105+
dryRun: true,
106+
scanned: 1,
107+
removed: [{ path: draft.from, reason: "space-uuid-draft" }],
108+
skipped: []
109+
} satisfies SweepWritebackDraftsResponse;
110+
111+
expectTypeOf(input).toMatchTypeOf<SweepWritebackDraftsInput>();
112+
expectTypeOf(response).toMatchTypeOf<SweepWritebackDraftsResponse>();
113+
expect(response.removed[0]?.path).toBe(draft.from);
114+
});
115+
89116
it("accepts digest handlers that return null or a section", async () => {
90117
const noActivity: DigestHandler = async () => null;
91118
const withActivity: DigestHandler = async (ctx) => ({

0 commit comments

Comments
 (0)