@awiki/cli 0.0.1-beta.2

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.
Files changed (119) hide show
  1. package/.github/workflows/release.yml +44 -0
  2. package/.goreleaser.yml +44 -0
  3. package/AGENTS.md +60 -0
  4. package/CLAUDE.md +192 -0
  5. package/README.md +2 -0
  6. package/docs/architecture/awiki-command-v2.md +955 -0
  7. package/docs/architecture/awiki-skill-architecture.md +475 -0
  8. package/docs/architecture/awiki-v2-architecture.md +1063 -0
  9. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/cli-init.md +1008 -0
  10. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/output-format.md +407 -0
  11. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/overall-init.md +741 -0
  12. package/docs/harness/review-spec.md +474 -0
  13. package/docs/installation.md +372 -0
  14. package/docs/plan/awiki-v2-implementation-plan.md +903 -0
  15. package/docs/plan/phase-0/adr-index.md +56 -0
  16. package/docs/plan/phase-0/audit-findings.md +251 -0
  17. package/docs/plan/phase-0/capability-mapping.md +108 -0
  18. package/docs/plan/phase-0/implementation-constraints.md +363 -0
  19. package/docs/publish.md +169 -0
  20. package/go.mod +29 -0
  21. package/go.sum +73 -0
  22. package/internal/anpsdk/registry.go +63 -0
  23. package/internal/authsdk/session.go +351 -0
  24. package/internal/buildinfo/buildinfo.go +34 -0
  25. package/internal/cli/app.go +136 -0
  26. package/internal/cli/app_test.go +88 -0
  27. package/internal/cli/debug.go +104 -0
  28. package/internal/cli/group.go +263 -0
  29. package/internal/cli/id.go +473 -0
  30. package/internal/cli/init.go +134 -0
  31. package/internal/cli/msg.go +228 -0
  32. package/internal/cli/page.go +267 -0
  33. package/internal/cli/root.go +499 -0
  34. package/internal/cli/runtime.go +232 -0
  35. package/internal/cli/upgrade.go +60 -0
  36. package/internal/cmdmeta/catalog.go +203 -0
  37. package/internal/cmdmeta/catalog_test.go +21 -0
  38. package/internal/config/config.go +399 -0
  39. package/internal/config/config_test.go +104 -0
  40. package/internal/config/write.go +37 -0
  41. package/internal/content/service.go +314 -0
  42. package/internal/content/service_test.go +165 -0
  43. package/internal/content/types.go +44 -0
  44. package/internal/docs/topics.go +110 -0
  45. package/internal/doctor/doctor.go +306 -0
  46. package/internal/identity/client.go +267 -0
  47. package/internal/identity/did.go +85 -0
  48. package/internal/identity/did_test.go +50 -0
  49. package/internal/identity/layout.go +206 -0
  50. package/internal/identity/legacy.go +378 -0
  51. package/internal/identity/public.go +70 -0
  52. package/internal/identity/public_test.go +73 -0
  53. package/internal/identity/readiness.go +74 -0
  54. package/internal/identity/service.go +826 -0
  55. package/internal/identity/store.go +385 -0
  56. package/internal/identity/store_test.go +180 -0
  57. package/internal/identity/types.go +204 -0
  58. package/internal/message/auth.go +167 -0
  59. package/internal/message/group_service.go +838 -0
  60. package/internal/message/group_wire.go +350 -0
  61. package/internal/message/group_wire_test.go +67 -0
  62. package/internal/message/helpers.go +61 -0
  63. package/internal/message/http_client.go +334 -0
  64. package/internal/message/proof.go +156 -0
  65. package/internal/message/proof_test.go +61 -0
  66. package/internal/message/service.go +696 -0
  67. package/internal/message/service_test.go +97 -0
  68. package/internal/message/types.go +155 -0
  69. package/internal/message/wire.go +100 -0
  70. package/internal/message/wire_test.go +49 -0
  71. package/internal/message/ws_proxy_client.go +151 -0
  72. package/internal/output/output.go +350 -0
  73. package/internal/output/output_test.go +48 -0
  74. package/internal/runtime/config.go +117 -0
  75. package/internal/runtime/config_test.go +46 -0
  76. package/internal/runtime/listener/files.go +65 -0
  77. package/internal/runtime/listener/manager.go +142 -0
  78. package/internal/runtime/listener/server.go +983 -0
  79. package/internal/runtime/listener/server_test.go +319 -0
  80. package/internal/runtime/listener/sysproc_unix.go +17 -0
  81. package/internal/runtime/listener/sysproc_windows.go +13 -0
  82. package/internal/runtime/listener/types.go +21 -0
  83. package/internal/runtime/listener/wsclient.go +299 -0
  84. package/internal/runtime/listener/wsclient_test.go +41 -0
  85. package/internal/store/dao.go +632 -0
  86. package/internal/store/dao_test.go +87 -0
  87. package/internal/store/helpers.go +197 -0
  88. package/internal/store/import.go +499 -0
  89. package/internal/store/import_test.go +103 -0
  90. package/internal/store/open.go +71 -0
  91. package/internal/store/query.go +151 -0
  92. package/internal/store/schema.go +277 -0
  93. package/internal/store/schema_test.go +56 -0
  94. package/internal/store/types.go +177 -0
  95. package/internal/update/update.go +368 -0
  96. package/package.json +17 -0
  97. package/scripts/install.js +171 -0
  98. package/scripts/release/release-prerelease.sh +86 -0
  99. package/scripts/release/tag-release.sh +66 -0
  100. package/scripts/release/withdraw-release.sh +78 -0
  101. package/scripts/run.js +69 -0
  102. package/skills/README.md +32 -0
  103. package/skills/awiki-bundle/SKILL.md +76 -0
  104. package/skills/awiki-debug/SKILL.md +80 -0
  105. package/skills/awiki-group/SKILL.md +111 -0
  106. package/skills/awiki-id/SKILL.md +123 -0
  107. package/skills/awiki-msg/SKILL.md +131 -0
  108. package/skills/awiki-page/SKILL.md +93 -0
  109. package/skills/awiki-people/SKILL.md +66 -0
  110. package/skills/awiki-runtime/SKILL.md +137 -0
  111. package/skills/awiki-shared/SKILL.md +124 -0
  112. package/skills/awiki-workflow-discovery/SKILL.md +93 -0
  113. package/skills/awiki-workflow-onboarding/SKILL.md +119 -0
  114. package/skills/manifests/skills.yaml +260 -0
  115. package/skills/templates/bundle-skill-template.md +42 -0
  116. package/skills/templates/debug-skill-template.md +44 -0
  117. package/skills/templates/domain-skill-template.md +56 -0
  118. package/skills/templates/shared-skill-template.md +46 -0
  119. package/skills/templates/workflow-skill-template.md +46 -0
@@ -0,0 +1,983 @@
1
+ package listener
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "database/sql"
7
+ "encoding/json"
8
+ "errors"
9
+ "fmt"
10
+ "net"
11
+ "os"
12
+ "path/filepath"
13
+ "strconv"
14
+ "strings"
15
+ "sync"
16
+ "time"
17
+
18
+ "github.com/agentconnect/awiki-cli/internal/authsdk"
19
+ appconfig "github.com/agentconnect/awiki-cli/internal/config"
20
+ "github.com/agentconnect/awiki-cli/internal/identity"
21
+ "github.com/agentconnect/awiki-cli/internal/message"
22
+ "github.com/agentconnect/awiki-cli/internal/runtime"
23
+ "github.com/agentconnect/awiki-cli/internal/store"
24
+ )
25
+
26
+ type Supervisor struct {
27
+ resolved *appconfig.Resolved
28
+ manager *identity.Manager
29
+ statusMu sync.Mutex
30
+ status Status
31
+
32
+ sessionsMu sync.Mutex
33
+ sessions map[string]*session
34
+ listener net.Listener
35
+ db *sql.DB
36
+ }
37
+
38
+ type session struct {
39
+ identityName string
40
+ record *identity.StoredIdentity
41
+ client *WSClient
42
+ lastError string
43
+ connected bool
44
+ ctx context.Context
45
+ cancelFunc context.CancelFunc
46
+ initResult chan error
47
+ initOnce sync.Once
48
+ mu sync.RWMutex
49
+ }
50
+
51
+ const (
52
+ sessionReconnectBaseDelay = time.Second
53
+ sessionReconnectMaxDelay = 30 * time.Second
54
+ sessionPingInterval = 60 * time.Second
55
+ )
56
+
57
+ func NewSupervisor(resolved *appconfig.Resolved) (*Supervisor, error) {
58
+ db, err := store.Open(resolved.Paths)
59
+ if err != nil {
60
+ return nil, err
61
+ }
62
+ if err := store.EnsureSchema(context.Background(), db); err != nil {
63
+ _ = db.Close()
64
+ return nil, err
65
+ }
66
+ pidFile, logFile, statusFile, socketPath, err := paths(resolved)
67
+ if err != nil {
68
+ _ = db.Close()
69
+ return nil, err
70
+ }
71
+ return &Supervisor{
72
+ resolved: resolved,
73
+ manager: identity.NewManager(resolved.Paths),
74
+ status: Status{
75
+ Mode: runtime.Resolve(resolved).Mode,
76
+ PIDFile: pidFile,
77
+ LogFile: logFile,
78
+ StatusFile: statusFile,
79
+ SocketPath: socketPath,
80
+ Running: true,
81
+ PID: os.Getpid(),
82
+ StartedAt: time.Now().UTC().Format(time.RFC3339),
83
+ },
84
+ sessions: map[string]*session{},
85
+ db: db,
86
+ }, nil
87
+ }
88
+
89
+ func (s *Supervisor) Close() error {
90
+ s.sessionsMu.Lock()
91
+ for _, session := range s.sessions {
92
+ if session.cancelFunc != nil {
93
+ session.cancelFunc()
94
+ }
95
+ session.closeCurrentClient()
96
+ }
97
+ s.sessionsMu.Unlock()
98
+ if s.listener != nil {
99
+ _ = s.listener.Close()
100
+ }
101
+ if s.db != nil {
102
+ return s.db.Close()
103
+ }
104
+ return nil
105
+ }
106
+
107
+ func (s *Supervisor) Run(ctx context.Context) error {
108
+ if runtime.Resolve(s.resolved).Mode != runtime.ModeWebSocket {
109
+ return fmt.Errorf("runtime mode must be websocket before starting the listener")
110
+ }
111
+ if err := writePID(s.status.PIDFile, s.status.PID); err != nil {
112
+ return err
113
+ }
114
+ if err := s.writeStatus(); err != nil {
115
+ return err
116
+ }
117
+ if err := s.startSocket(); err != nil {
118
+ return err
119
+ }
120
+ if err := s.startKnownSessions(ctx); err != nil {
121
+ return err
122
+ }
123
+ go s.watchNewIdentities(ctx)
124
+ <-ctx.Done()
125
+ return nil
126
+ }
127
+
128
+ func (s *Supervisor) startSocket() error {
129
+ if err := os.MkdirAll(filepath.Dir(s.status.SocketPath), 0o700); err != nil {
130
+ return err
131
+ }
132
+ _ = os.Remove(s.status.SocketPath)
133
+ listener, err := net.Listen("unix", s.status.SocketPath)
134
+ if err != nil {
135
+ return err
136
+ }
137
+ s.listener = listener
138
+ go s.acceptLoop()
139
+ return nil
140
+ }
141
+
142
+ func (s *Supervisor) acceptLoop() {
143
+ for {
144
+ conn, err := s.listener.Accept()
145
+ if err != nil {
146
+ return
147
+ }
148
+ go s.handleConn(conn)
149
+ }
150
+ }
151
+
152
+ func (s *Supervisor) handleConn(conn net.Conn) {
153
+ defer conn.Close()
154
+ reader := bufio.NewReader(conn)
155
+ line, err := reader.ReadBytes('\n')
156
+ if err != nil {
157
+ _ = json.NewEncoder(conn).Encode(runtime.BridgeResponse{OK: false, Error: &runtime.BridgeError{Message: err.Error()}})
158
+ return
159
+ }
160
+ var request runtime.BridgeRequest
161
+ if err := json.Unmarshal(line, &request); err != nil {
162
+ _ = json.NewEncoder(conn).Encode(runtime.BridgeResponse{OK: false, Error: &runtime.BridgeError{Message: err.Error()}})
163
+ return
164
+ }
165
+ result, err := s.handleBridgeRequest(request)
166
+ if err != nil {
167
+ _ = json.NewEncoder(conn).Encode(runtime.BridgeResponse{OK: false, Error: &runtime.BridgeError{Message: err.Error()}})
168
+ return
169
+ }
170
+ _ = json.NewEncoder(conn).Encode(runtime.BridgeResponse{OK: true, Result: result})
171
+ }
172
+
173
+ func (s *Supervisor) handleBridgeRequest(request runtime.BridgeRequest) (map[string]any, error) {
174
+ session, err := s.ensureSession(request.IdentityName)
175
+ if err != nil {
176
+ return nil, err
177
+ }
178
+ record := session.currentRecord()
179
+ client := session.currentClient()
180
+ if record == nil || client == nil {
181
+ return nil, fmt.Errorf("websocket session is not connected for identity %s", session.identityName)
182
+ }
183
+ switch request.Method {
184
+ case "direct.send":
185
+ target, _ := request.Params["target"].(string)
186
+ text, _ := request.Params["text"].(string)
187
+ msgType, _ := request.Params["type"].(string)
188
+ params, err := message.BuildDirectSendRPCParams(record, s.manager, target, text, msgType)
189
+ if err != nil {
190
+ return nil, err
191
+ }
192
+ return client.SendRPC(context.Background(), "direct.send", params)
193
+ case "inbox.get":
194
+ params := message.BuildInboxRPCParams(record, message.InboxRequest{
195
+ Limit: intValue(request.Params["limit"]),
196
+ With: stringValue(request.Params["with"]),
197
+ UnreadOnly: boolValue(request.Params["unread"]),
198
+ MarkRead: boolValue(request.Params["mark_read"]),
199
+ })
200
+ return client.SendRPC(context.Background(), "inbox.get", params)
201
+ case "direct.get_history":
202
+ params, err := message.BuildHistoryRPCParams(record, message.HistoryRequest{
203
+ With: stringValue(request.Params["with"]),
204
+ Limit: intValue(request.Params["limit"]),
205
+ Cursor: stringValue(request.Params["cursor"]),
206
+ })
207
+ if err != nil {
208
+ return nil, err
209
+ }
210
+ return client.SendRPC(context.Background(), "direct.get_history", params)
211
+ case "inbox.mark_read":
212
+ rawIDs, _ := request.Params["message_ids"].([]any)
213
+ messageIDs := make([]string, 0, len(rawIDs))
214
+ for _, rawID := range rawIDs {
215
+ if id := stringValue(rawID); id != "" {
216
+ messageIDs = append(messageIDs, id)
217
+ }
218
+ }
219
+ params, err := message.BuildMarkReadRPCParams(record, message.MarkReadRequest{MessageIDs: messageIDs})
220
+ if err != nil {
221
+ return nil, err
222
+ }
223
+ result, err := client.SendRPC(context.Background(), "inbox.mark_read", params)
224
+ if err == nil {
225
+ _, _ = store.MarkMessagesRead(context.Background(), s.db, record.DID, messageIDs)
226
+ }
227
+ return result, err
228
+ case "group.create":
229
+ serviceDID, err := s.fetchMessageServiceDID(session)
230
+ if err != nil {
231
+ return nil, err
232
+ }
233
+ params, err := message.BuildGroupCreateRPCParams(record, s.manager, serviceDID, message.GroupCreateRequest{
234
+ Name: stringValue(request.Params["name"]),
235
+ Description: stringValue(request.Params["description"]),
236
+ Discoverability: stringValue(request.Params["discoverability"]),
237
+ AdmissionMode: stringValue(request.Params["admission_mode"]),
238
+ Slug: stringValue(request.Params["slug"]),
239
+ Goal: stringValue(request.Params["goal"]),
240
+ Rules: stringValue(request.Params["rules"]),
241
+ MessagePrompt: stringValue(request.Params["message_prompt"]),
242
+ DocURL: stringValue(request.Params["doc_url"]),
243
+ AttachmentsAllowed: boolPtrValue(request.Params["attachments_allowed"]),
244
+ MaxMembers: stringValue(request.Params["max_members"]),
245
+ MemberMaxMessages: int64PtrValue(request.Params["member_max_messages"]),
246
+ MemberMaxTotalChars: int64PtrValue(request.Params["member_max_total_chars"]),
247
+ })
248
+ if err != nil {
249
+ return nil, err
250
+ }
251
+ return client.SendRPC(context.Background(), "group.create", params)
252
+ case "group.get_info":
253
+ params, err := message.BuildGroupGetInfoRPCParams(record, message.GroupInfoRequest{
254
+ Group: stringValue(request.Params["group"]),
255
+ IncludePolicy: boolValue(request.Params["include_policy"]),
256
+ IncludeMemberList: boolValue(request.Params["include_member_list"]),
257
+ })
258
+ if err != nil {
259
+ return nil, err
260
+ }
261
+ return client.SendRPC(context.Background(), "group.get_info", params)
262
+ case "group.join":
263
+ params, err := message.BuildGroupJoinRPCParams(record, s.manager, message.GroupJoinRequest{
264
+ Group: stringValue(request.Params["group"]),
265
+ ReasonText: stringValue(request.Params["reason_text"]),
266
+ })
267
+ if err != nil {
268
+ return nil, err
269
+ }
270
+ return client.SendRPC(context.Background(), "group.join", params)
271
+ case "group.add":
272
+ params, err := message.BuildGroupAddRPCParams(record, s.manager, message.GroupMemberRequest{
273
+ Group: stringValue(request.Params["group"]),
274
+ Member: stringValue(request.Params["member"]),
275
+ Role: stringValue(request.Params["role"]),
276
+ ReasonText: stringValue(request.Params["reason_text"]),
277
+ })
278
+ if err != nil {
279
+ return nil, err
280
+ }
281
+ return client.SendRPC(context.Background(), "group.add", params)
282
+ case "group.remove":
283
+ params, err := message.BuildGroupRemoveRPCParams(record, s.manager, message.GroupMemberRequest{
284
+ Group: stringValue(request.Params["group"]),
285
+ Member: stringValue(request.Params["member"]),
286
+ ReasonText: stringValue(request.Params["reason_text"]),
287
+ })
288
+ if err != nil {
289
+ return nil, err
290
+ }
291
+ return client.SendRPC(context.Background(), "group.remove", params)
292
+ case "group.leave":
293
+ params, err := message.BuildGroupLeaveRPCParams(record, s.manager, message.GroupLeaveRequest{Group: stringValue(request.Params["group"])})
294
+ if err != nil {
295
+ return nil, err
296
+ }
297
+ return client.SendRPC(context.Background(), "group.leave", params)
298
+ case "group.update_profile":
299
+ patch, _ := request.Params["patch"].(map[string]any)
300
+ params, err := message.BuildGroupUpdateProfileRPCParams(record, s.manager, stringValue(request.Params["group"]), patch)
301
+ if err != nil {
302
+ return nil, err
303
+ }
304
+ return client.SendRPC(context.Background(), "group.update_profile", params)
305
+ case "group.update_policy":
306
+ patch, _ := request.Params["patch"].(map[string]any)
307
+ params, err := message.BuildGroupUpdatePolicyRPCParams(record, s.manager, stringValue(request.Params["group"]), patch)
308
+ if err != nil {
309
+ return nil, err
310
+ }
311
+ return client.SendRPC(context.Background(), "group.update_policy", params)
312
+ case "group.send":
313
+ params, err := message.BuildGroupSendRPCParams(record, s.manager, stringValue(request.Params["group"]), stringValue(request.Params["text"]), stringValue(request.Params["type"]))
314
+ if err != nil {
315
+ return nil, err
316
+ }
317
+ return client.SendRPC(context.Background(), "group.send", params)
318
+ case "group.get":
319
+ params, err := message.BuildGroupGetRPCParams(record, message.GroupGetRequest{Group: stringValue(request.Params["group"])})
320
+ if err != nil {
321
+ return nil, err
322
+ }
323
+ return client.SendRPC(context.Background(), "group.get", params)
324
+ case "group.list_members":
325
+ params, err := message.BuildGroupMembersRPCParams(record, message.GroupMembersRequest{Group: stringValue(request.Params["group"]), Limit: intValue(request.Params["limit"])})
326
+ if err != nil {
327
+ return nil, err
328
+ }
329
+ return client.SendRPC(context.Background(), "group.list_members", params)
330
+ case "group.list_messages":
331
+ params, err := message.BuildGroupMessagesRPCParams(record, message.GroupMessagesRequest{Group: stringValue(request.Params["group"]), Limit: intValue(request.Params["limit"]), Cursor: stringValue(request.Params["cursor"])})
332
+ if err != nil {
333
+ return nil, err
334
+ }
335
+ return client.SendRPC(context.Background(), "group.list_messages", params)
336
+ default:
337
+ return nil, fmt.Errorf("unsupported websocket bridge method: %s", request.Method)
338
+ }
339
+ }
340
+
341
+ func (s *Supervisor) ensureSession(identityName string) (*session, error) {
342
+ identityName = strings.TrimSpace(identityName)
343
+ if identityName == "" {
344
+ current, err := s.manager.Current()
345
+ if err != nil {
346
+ return nil, err
347
+ }
348
+ identityName = current.IdentityName
349
+ }
350
+ s.sessionsMu.Lock()
351
+ existing := s.sessions[identityName]
352
+ if existing != nil {
353
+ s.sessionsMu.Unlock()
354
+ return existing, nil
355
+ }
356
+ sessionCtx, sessionCancel := context.WithCancel(context.Background())
357
+ newSession := &session{
358
+ identityName: identityName,
359
+ ctx: sessionCtx,
360
+ cancelFunc: sessionCancel,
361
+ initResult: make(chan error, 1),
362
+ }
363
+ s.sessions[identityName] = newSession
364
+ s.sessionsMu.Unlock()
365
+ go s.runSessionLoop(newSession)
366
+ select {
367
+ case err := <-newSession.initResult:
368
+ if err != nil {
369
+ return newSession, err
370
+ }
371
+ return newSession, nil
372
+ case <-time.After(15 * time.Second):
373
+ return newSession, fmt.Errorf("websocket session bootstrap timed out for identity %s", identityName)
374
+ }
375
+ }
376
+
377
+ func (s *Supervisor) startKnownSessions(ctx context.Context) error {
378
+ identities, err := s.manager.List()
379
+ if err != nil {
380
+ return err
381
+ }
382
+ for _, summary := range identities {
383
+ select {
384
+ case <-ctx.Done():
385
+ return ctx.Err()
386
+ default:
387
+ }
388
+ if _, err := s.ensureSession(summary.IdentityName); err != nil {
389
+ s.recordSessionError(summary.IdentityName, summary.DID, err)
390
+ }
391
+ }
392
+ s.refreshStatus()
393
+ return nil
394
+ }
395
+
396
+ func (s *Supervisor) watchNewIdentities(ctx context.Context) {
397
+ ticker := time.NewTicker(3 * time.Second)
398
+ defer ticker.Stop()
399
+ for {
400
+ select {
401
+ case <-ctx.Done():
402
+ return
403
+ case <-ticker.C:
404
+ identities, err := s.manager.List()
405
+ if err != nil {
406
+ continue
407
+ }
408
+ for _, summary := range identities {
409
+ s.sessionsMu.Lock()
410
+ _, ok := s.sessions[summary.IdentityName]
411
+ s.sessionsMu.Unlock()
412
+ if ok {
413
+ continue
414
+ }
415
+ if _, err := s.ensureSession(summary.IdentityName); err != nil {
416
+ s.recordSessionError(summary.IdentityName, summary.DID, err)
417
+ }
418
+ }
419
+ s.refreshStatus()
420
+ }
421
+ }
422
+ }
423
+
424
+ func (s *Supervisor) consumeNotifications(ctx context.Context, session *session, client *WSClient) error {
425
+ pingTicker := time.NewTicker(sessionPingInterval)
426
+ defer pingTicker.Stop()
427
+ for {
428
+ select {
429
+ case <-ctx.Done():
430
+ return ctx.Err()
431
+ case <-pingTicker.C:
432
+ pingCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
433
+ err := client.Ping(pingCtx)
434
+ cancel()
435
+ if err != nil {
436
+ return fmt.Errorf("websocket ping failed: %w", err)
437
+ }
438
+ case notification, ok := <-client.Notifications():
439
+ if !ok {
440
+ if err := client.ReaderError(); err != nil {
441
+ return err
442
+ }
443
+ return fmt.Errorf("websocket notification loop closed")
444
+ }
445
+ s.handleNotification(ctx, session, notification)
446
+ }
447
+ }
448
+ }
449
+
450
+ func (s *Supervisor) handleNotification(ctx context.Context, session *session, notification map[string]any) {
451
+ if record, ok := messageRecordFromDirectIncoming(notification, session.record.IdentityName); ok {
452
+ _ = store.StoreMessage(ctx, s.db, record)
453
+ return
454
+ }
455
+ if record, ok := messageRecordFromGroupIncoming(notification, session.record.IdentityName); ok {
456
+ _ = store.StoreMessage(ctx, s.db, record)
457
+ return
458
+ }
459
+ groupRecord, memberRecord, messageRecord, ok := recordsFromGroupStateChanged(notification, session.record.IdentityName)
460
+ if !ok {
461
+ return
462
+ }
463
+ if groupRecord != nil {
464
+ _ = store.UpsertGroup(ctx, s.db, *groupRecord)
465
+ }
466
+ if memberRecord != nil {
467
+ _ = store.UpsertGroupMember(ctx, s.db, *memberRecord)
468
+ }
469
+ if messageRecord != nil {
470
+ _ = store.StoreMessage(ctx, s.db, *messageRecord)
471
+ }
472
+ }
473
+
474
+ func messageRecordFromDirectIncoming(notification map[string]any, identityName string) (store.MessageRecord, bool) {
475
+ method, _ := notification["method"].(string)
476
+ if method != "direct.incoming" {
477
+ return store.MessageRecord{}, false
478
+ }
479
+ params, ok := notification["params"].(map[string]any)
480
+ if !ok {
481
+ return store.MessageRecord{}, false
482
+ }
483
+ meta, _ := params["meta"].(map[string]any)
484
+ body, _ := params["body"].(map[string]any)
485
+ target, _ := meta["target"].(map[string]any)
486
+ targetDID := stringValue(target["did"])
487
+ senderDID := stringValue(meta["sender_did"])
488
+ if targetDID == "" || senderDID == "" {
489
+ return store.MessageRecord{}, false
490
+ }
491
+ contentType := stringValue(meta["content_type"])
492
+ if contentType == "" {
493
+ contentType = "text/plain"
494
+ }
495
+ sentAt := stringValue(meta["created_at"])
496
+ if sentAt == "" {
497
+ sentAt = time.Now().UTC().Format(time.RFC3339)
498
+ }
499
+ return store.MessageRecord{
500
+ MsgID: stringValue(meta["message_id"]),
501
+ OwnerDID: targetDID,
502
+ ThreadID: store.MakeThreadID(targetDID, senderDID, ""),
503
+ Direction: 0,
504
+ SenderDID: senderDID,
505
+ ReceiverDID: targetDID,
506
+ ContentType: contentType,
507
+ Content: stringValue(body["text"]),
508
+ SentAt: sentAt,
509
+ IsRead: false,
510
+ Metadata: metadataValue(params),
511
+ CredentialName: identityName,
512
+ }, true
513
+ }
514
+
515
+ func messageRecordFromGroupIncoming(notification map[string]any, identityName string) (store.MessageRecord, bool) {
516
+ method, _ := notification["method"].(string)
517
+ if method != "group.incoming" {
518
+ return store.MessageRecord{}, false
519
+ }
520
+ params, ok := notification["params"].(map[string]any)
521
+ if !ok {
522
+ return store.MessageRecord{}, false
523
+ }
524
+ meta, _ := params["meta"].(map[string]any)
525
+ body, _ := params["body"].(map[string]any)
526
+ target, _ := meta["target"].(map[string]any)
527
+ ownerDID := stringValue(target["did"])
528
+ groupDID := stringValue(body["group_did"])
529
+ senderDID := stringValue(meta["sender_did"])
530
+ if ownerDID == "" || groupDID == "" {
531
+ return store.MessageRecord{}, false
532
+ }
533
+ content := stringValue(body["text"])
534
+ if content == "" {
535
+ content = metadataValue(body["payload"])
536
+ }
537
+ contentType := stringValue(meta["content_type"])
538
+ if contentType == "" {
539
+ contentType = "text/plain"
540
+ }
541
+ sentAt := stringValue(body["accepted_at"])
542
+ if sentAt == "" {
543
+ sentAt = stringValue(meta["created_at"])
544
+ }
545
+ serverSeq := int64PtrValue(body["group_event_seq"])
546
+ return store.MessageRecord{
547
+ MsgID: fallbackString(stringValue(meta["message_id"]), fmt.Sprintf("%s:%s", groupDID, stringValue(body["group_event_seq"]))),
548
+ OwnerDID: ownerDID,
549
+ ThreadID: store.MakeThreadID(ownerDID, "", groupDID),
550
+ Direction: boolToDirection(senderDID == ownerDID),
551
+ SenderDID: senderDID,
552
+ GroupID: groupDID,
553
+ GroupDID: groupDID,
554
+ ContentType: contentType,
555
+ Content: content,
556
+ ServerSeq: serverSeq,
557
+ SentAt: sentAt,
558
+ IsRead: senderDID == ownerDID,
559
+ Metadata: metadataValue(params),
560
+ CredentialName: identityName,
561
+ }, true
562
+ }
563
+
564
+ func recordsFromGroupStateChanged(notification map[string]any, identityName string) (*store.GroupRecord, *store.GroupMemberRecord, *store.MessageRecord, bool) {
565
+ method, _ := notification["method"].(string)
566
+ if method != "group.state_changed" {
567
+ return nil, nil, nil, false
568
+ }
569
+ params, ok := notification["params"].(map[string]any)
570
+ if !ok {
571
+ return nil, nil, nil, false
572
+ }
573
+ meta, _ := params["meta"].(map[string]any)
574
+ body, _ := params["body"].(map[string]any)
575
+ target, _ := meta["target"].(map[string]any)
576
+ ownerDID := stringValue(target["did"])
577
+ groupDID := stringValue(body["group_did"])
578
+ if ownerDID == "" || groupDID == "" {
579
+ return nil, nil, nil, false
580
+ }
581
+ groupRecord := &store.GroupRecord{
582
+ OwnerDID: ownerDID,
583
+ GroupID: groupDID,
584
+ GroupDID: groupDID,
585
+ LastSyncedSeq: int64PtrValue(body["group_event_seq"]),
586
+ LastMessageAt: stringValue(body["changed_at"]),
587
+ Metadata: metadataValue(body),
588
+ CredentialName: identityName,
589
+ }
590
+ subjectDID := stringValue(body["subject_did"])
591
+ var memberRecord *store.GroupMemberRecord
592
+ if subjectDID != "" {
593
+ memberRecord = &store.GroupMemberRecord{
594
+ OwnerDID: ownerDID,
595
+ GroupID: groupDID,
596
+ UserID: subjectDID,
597
+ MemberDID: subjectDID,
598
+ Status: membershipStatusFromEvent(body),
599
+ Role: "member",
600
+ JoinedAt: stringValue(body["changed_at"]),
601
+ Metadata: metadataValue(body),
602
+ CredentialName: identityName,
603
+ }
604
+ }
605
+ content := systemEventText(body)
606
+ messageRecord := &store.MessageRecord{
607
+ MsgID: fallbackString(stringValue(body["event_id"]), fmt.Sprintf("%s:%s", groupDID, stringValue(body["group_event_seq"]))),
608
+ OwnerDID: ownerDID,
609
+ ThreadID: store.MakeThreadID(ownerDID, "", groupDID),
610
+ Direction: 0,
611
+ SenderDID: stringValue(body["actor_did"]),
612
+ GroupID: groupDID,
613
+ GroupDID: groupDID,
614
+ ContentType: inferSystemContentType(stringValue(body["subject_method"])),
615
+ Content: content,
616
+ ServerSeq: int64PtrValue(body["group_event_seq"]),
617
+ SentAt: stringValue(body["changed_at"]),
618
+ IsRead: false,
619
+ Metadata: metadataValue(body),
620
+ CredentialName: identityName,
621
+ }
622
+ return groupRecord, memberRecord, messageRecord, true
623
+ }
624
+
625
+ func (s *Supervisor) refreshStatus() {
626
+ s.sessionsMu.Lock()
627
+ sessions := make([]SessionStatus, 0, len(s.sessions))
628
+ for identityName, session := range s.sessions {
629
+ record, connected, lastError := session.snapshot()
630
+ did := ""
631
+ if record != nil {
632
+ did = record.DID
633
+ }
634
+ sessions = append(sessions, SessionStatus{
635
+ IdentityName: identityName,
636
+ DID: did,
637
+ Connected: connected,
638
+ LastError: lastError,
639
+ })
640
+ }
641
+ s.sessionsMu.Unlock()
642
+
643
+ s.statusMu.Lock()
644
+ defer s.statusMu.Unlock()
645
+ s.status.Sessions = sessions
646
+ _ = writeStatus(s.status.StatusFile, s.status)
647
+ }
648
+
649
+ func (s *Supervisor) recordSessionError(identityName string, did string, err error) {
650
+ s.sessionsMu.Lock()
651
+ existingSession := s.sessions[identityName]
652
+ if existingSession == nil {
653
+ existingSession = &session{identityName: identityName, record: &identity.StoredIdentity{IdentityName: identityName, DID: did}}
654
+ s.sessions[identityName] = existingSession
655
+ }
656
+ existingSession.markDisconnected(err)
657
+ s.sessionsMu.Unlock()
658
+ s.refreshStatus()
659
+ }
660
+
661
+ func (s *Supervisor) writeStatus() error {
662
+ s.refreshStatus()
663
+ return nil
664
+ }
665
+
666
+ func stringValue(value any) string {
667
+ text, _ := value.(string)
668
+ return text
669
+ }
670
+
671
+ func metadataValue(value any) string {
672
+ raw, err := json.Marshal(value)
673
+ if err != nil {
674
+ return ""
675
+ }
676
+ return string(raw)
677
+ }
678
+
679
+ func intValue(value any) int {
680
+ switch typed := value.(type) {
681
+ case int:
682
+ return typed
683
+ case int64:
684
+ return int(typed)
685
+ case float64:
686
+ return int(typed)
687
+ default:
688
+ return 0
689
+ }
690
+ }
691
+
692
+ func boolValue(value any) bool {
693
+ switch typed := value.(type) {
694
+ case bool:
695
+ return typed
696
+ case int:
697
+ return typed != 0
698
+ case int64:
699
+ return typed != 0
700
+ case float64:
701
+ return typed != 0
702
+ case string:
703
+ return typed == "1" || strings.EqualFold(typed, "true")
704
+ default:
705
+ return false
706
+ }
707
+ }
708
+
709
+ func fallbackString(value string, fallback string) string {
710
+ if strings.TrimSpace(value) == "" {
711
+ return fallback
712
+ }
713
+ return value
714
+ }
715
+
716
+ func boolPtrValue(value any) *bool {
717
+ switch typed := value.(type) {
718
+ case bool:
719
+ value := typed
720
+ return &value
721
+ default:
722
+ return nil
723
+ }
724
+ }
725
+
726
+ func int64PtrValue(value any) *int64 {
727
+ switch typed := value.(type) {
728
+ case int:
729
+ value := int64(typed)
730
+ return &value
731
+ case int64:
732
+ value := typed
733
+ return &value
734
+ case float64:
735
+ value := int64(typed)
736
+ return &value
737
+ case string:
738
+ if typed == "" {
739
+ return nil
740
+ }
741
+ if parsed, err := strconv.ParseInt(typed, 10, 64); err == nil {
742
+ return &parsed
743
+ }
744
+ return nil
745
+ default:
746
+ return nil
747
+ }
748
+ }
749
+
750
+ func boolToDirection(sentBySelf bool) int {
751
+ if sentBySelf {
752
+ return 1
753
+ }
754
+ return 0
755
+ }
756
+
757
+ func membershipStatusFromEvent(body map[string]any) string {
758
+ if status := stringValue(body["membership_status"]); status != "" {
759
+ return status
760
+ }
761
+ switch stringValue(body["subject_method"]) {
762
+ case "group.add", "group.join":
763
+ return "active"
764
+ case "group.leave":
765
+ return "left"
766
+ case "group.remove":
767
+ return "removed"
768
+ default:
769
+ return "active"
770
+ }
771
+ }
772
+
773
+ func inferSystemContentType(subjectMethod string) string {
774
+ switch subjectMethod {
775
+ case "group.add", "group.join":
776
+ return "group_system_member_joined"
777
+ case "group.leave":
778
+ return "group_system_member_left"
779
+ case "group.remove":
780
+ return "group_system_member_kicked"
781
+ default:
782
+ return "application/json"
783
+ }
784
+ }
785
+
786
+ func systemEventText(body map[string]any) string {
787
+ subjectDID := stringValue(body["subject_did"])
788
+ if subjectDID == "" {
789
+ subjectDID = "A member"
790
+ }
791
+ switch stringValue(body["subject_method"]) {
792
+ case "group.add":
793
+ return fmt.Sprintf("%s was added to the group.", subjectDID)
794
+ case "group.join":
795
+ return fmt.Sprintf("%s joined the group.", subjectDID)
796
+ case "group.leave":
797
+ return fmt.Sprintf("%s left the group.", subjectDID)
798
+ case "group.remove":
799
+ return fmt.Sprintf("%s was removed from the group.", subjectDID)
800
+ case "group.update_profile":
801
+ return "The group profile was updated."
802
+ case "group.update_policy":
803
+ return "The group policy was updated."
804
+ default:
805
+ return "The group state changed."
806
+ }
807
+ }
808
+
809
+ func (s *Supervisor) fetchMessageServiceDID(session *session) (string, error) {
810
+ client := session.currentClient()
811
+ if client == nil {
812
+ return "", fmt.Errorf("websocket session is not connected for identity %s", session.identityName)
813
+ }
814
+ result, err := client.SendRPC(context.Background(), "anp.get_capabilities", map[string]any{})
815
+ if err != nil {
816
+ return "", err
817
+ }
818
+ serviceDID := stringValue(result["service_did"])
819
+ if serviceDID == "" {
820
+ return "", fmt.Errorf("message service capabilities response is missing service_did")
821
+ }
822
+ return serviceDID, nil
823
+ }
824
+
825
+ func (s *Supervisor) runSessionLoop(session *session) {
826
+ delay := sessionReconnectBaseDelay
827
+ for {
828
+ select {
829
+ case <-session.ctx.Done():
830
+ session.closeCurrentClient()
831
+ return
832
+ default:
833
+ }
834
+
835
+ record, client, err := s.connectSession(session.identityName)
836
+ if err != nil {
837
+ session.markDisconnected(err)
838
+ session.signalInitial(err)
839
+ s.refreshStatus()
840
+ if !sleepWithContext(session.ctx, delay) {
841
+ return
842
+ }
843
+ delay = minDuration(delay*2, sessionReconnectMaxDelay)
844
+ continue
845
+ }
846
+
847
+ delay = sessionReconnectBaseDelay
848
+ session.markConnected(record, client)
849
+ session.signalInitial(nil)
850
+ s.refreshStatus()
851
+
852
+ err = s.consumeNotifications(session.ctx, session, client)
853
+ _ = client.Close()
854
+ session.markDisconnected(err)
855
+ s.refreshStatus()
856
+ if session.ctx.Err() != nil {
857
+ return
858
+ }
859
+ if !sleepWithContext(session.ctx, delay) {
860
+ return
861
+ }
862
+ delay = minDuration(delay*2, sessionReconnectMaxDelay)
863
+ }
864
+ }
865
+
866
+ func (s *Supervisor) connectSession(identityName string) (*identity.StoredIdentity, *WSClient, error) {
867
+ record, err := s.manager.Load(identityName)
868
+ if err != nil {
869
+ return nil, nil, err
870
+ }
871
+ userState := identity.EvaluateStoredIdentityUserState(record)
872
+ if !userState.ReadyForMessaging {
873
+ return nil, nil, identity.UserRegistrationError(record.IdentityName, userState)
874
+ }
875
+ paths, pathErr := s.manager.PathsForIdentity(identityName)
876
+ if pathErr != nil {
877
+ return nil, nil, pathErr
878
+ }
879
+ authSession := authsdk.NewSession(
880
+ paths.DIDDocumentPath,
881
+ paths.Key1PrivatePath,
882
+ record.IdentityName,
883
+ record.DID,
884
+ record.JWTToken,
885
+ func(token string) error { return s.manager.UpdateJWT(record.IdentityName, token) },
886
+ )
887
+ if strings.TrimSpace(record.JWTToken) != "" {
888
+ authSession.SetBearer(s.resolved.UserServiceURL, record.JWTToken)
889
+ if strings.TrimSpace(s.resolved.MessageServiceURL) != "" {
890
+ authSession.SetBearer(s.resolved.MessageServiceURL, record.JWTToken)
891
+ }
892
+ }
893
+ client, err := NewWSClient(s.resolved, authSession)
894
+ if err != nil {
895
+ return nil, nil, err
896
+ }
897
+ connectCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
898
+ defer cancel()
899
+ if err := client.Connect(connectCtx); err != nil {
900
+ _ = client.Close()
901
+ return nil, nil, err
902
+ }
903
+ record.JWTToken = authSession.CurrentJWT()
904
+ return record, client, nil
905
+ }
906
+
907
+ func (s *session) currentClient() *WSClient {
908
+ s.mu.RLock()
909
+ defer s.mu.RUnlock()
910
+ return s.client
911
+ }
912
+
913
+ func (s *session) currentRecord() *identity.StoredIdentity {
914
+ s.mu.RLock()
915
+ defer s.mu.RUnlock()
916
+ return s.record
917
+ }
918
+
919
+ func (s *session) snapshot() (*identity.StoredIdentity, bool, string) {
920
+ s.mu.RLock()
921
+ defer s.mu.RUnlock()
922
+ return s.record, s.connected, s.lastError
923
+ }
924
+
925
+ func (s *session) markConnected(record *identity.StoredIdentity, client *WSClient) {
926
+ s.mu.Lock()
927
+ defer s.mu.Unlock()
928
+ if s.client != nil && s.client != client {
929
+ _ = s.client.Close()
930
+ }
931
+ s.record = record
932
+ s.client = client
933
+ s.connected = true
934
+ s.lastError = ""
935
+ }
936
+
937
+ func (s *session) markDisconnected(err error) {
938
+ s.mu.Lock()
939
+ defer s.mu.Unlock()
940
+ if s.client != nil {
941
+ _ = s.client.Close()
942
+ }
943
+ s.client = nil
944
+ s.connected = false
945
+ if err != nil && !errors.Is(err, context.Canceled) {
946
+ s.lastError = err.Error()
947
+ }
948
+ }
949
+
950
+ func (s *session) closeCurrentClient() {
951
+ s.mu.Lock()
952
+ defer s.mu.Unlock()
953
+ if s.client != nil {
954
+ _ = s.client.Close()
955
+ s.client = nil
956
+ }
957
+ s.connected = false
958
+ }
959
+
960
+ func (s *session) signalInitial(err error) {
961
+ s.initOnce.Do(func() {
962
+ s.initResult <- err
963
+ close(s.initResult)
964
+ })
965
+ }
966
+
967
+ func sleepWithContext(ctx context.Context, delay time.Duration) bool {
968
+ timer := time.NewTimer(delay)
969
+ defer timer.Stop()
970
+ select {
971
+ case <-ctx.Done():
972
+ return false
973
+ case <-timer.C:
974
+ return true
975
+ }
976
+ }
977
+
978
+ func minDuration(value time.Duration, max time.Duration) time.Duration {
979
+ if value > max {
980
+ return max
981
+ }
982
+ return value
983
+ }