@codemem/server 0.30.0-alpha.2 → 0.30.0-alpha.3
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/dist/index.js +340 -81
- package/dist/index.js.map +1 -1
- package/dist/routes/memory.d.ts.map +1 -1
- package/dist/routes/stats.d.ts.map +1 -1
- package/dist/routes/sync.d.ts.map +1 -1
- package/package.json +2 -2
- package/static/app.js +3409 -2579
- package/static/index.html +53 -0
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, LOCAL_SYNC_CAPABILITY, MemoryStore, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, addSyncScopeToBoundary, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, cleanupNonces, coordinatorArchiveGroupAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, negotiateSyncCapability, normalizeSyncCapability, parseStrictInteger, parseSyncScopeRequest, personalScopeGrantStatusForPeer, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, rejectInboundScopeFailures, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, summarizeInboundScopeRejections, syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, verifySignature, writeCodememConfigFile } from "@codemem/core";
|
|
3
|
+
import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, MemoryStore, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, cleanupNonces, coordinatorArchiveGroupAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, createCoordinatorReciprocalApproval, deleteProjectScopeSettingsMapping, ensureDeviceIdentity, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, listObserverProviderOptions, listProjectScopeCandidates, listProjectScopeSettingsMappings, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, negotiateSyncCapability, normalizeSyncCapability, parseStrictInteger, parseSyncScopeRequest, personalScopeGrantStatusForPeer, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, rejectInboundScopeFailures, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, summarizeInboundScopeRejections, syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMapping, verifySignature, writeCodememConfigFile } from "@codemem/core";
|
|
4
4
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { createMiddleware } from "hono/factory";
|
|
7
7
|
import { RateLimiter } from "limiter";
|
|
8
8
|
import { homedir, networkInterfaces } from "node:os";
|
|
9
|
-
import { and, count, desc, eq, inArray,
|
|
9
|
+
import { and, count, desc, eq, inArray, max, ne } from "drizzle-orm";
|
|
10
10
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
11
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
12
12
|
//#region src/middleware.ts
|
|
@@ -641,14 +641,65 @@ function normalizeScope(raw) {
|
|
|
641
641
|
const value = String(raw ?? "").trim().toLowerCase();
|
|
642
642
|
if (value === "mine" || value === "theirs") return value;
|
|
643
643
|
}
|
|
644
|
+
function buildViewerMemoryFilters(store, filters) {
|
|
645
|
+
return buildFilterClausesWithContext(filters, {
|
|
646
|
+
actorId: store.actorId,
|
|
647
|
+
deviceId: store.deviceId,
|
|
648
|
+
enforceScopeVisibility: true
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
function countVisibleMemoryRows(store, filters) {
|
|
652
|
+
const filterResult = buildViewerMemoryFilters(store, filters);
|
|
653
|
+
const clauses = ["memory_items.active = 1", ...filterResult.clauses];
|
|
654
|
+
const from = filterResult.joinSessions ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" : "memory_items";
|
|
655
|
+
const row = store.db.prepare(`SELECT COUNT(*) AS total FROM ${from} WHERE ${clauses.join(" AND ")}`).get(...filterResult.params);
|
|
656
|
+
return Number(row?.total ?? 0);
|
|
657
|
+
}
|
|
658
|
+
function sessionAllowsArtifactAccess(store, sessionId) {
|
|
659
|
+
const visibleCount = countVisibleMemoryRows(store, { session_id: sessionId });
|
|
660
|
+
if (visibleCount === 0) return false;
|
|
661
|
+
const row = store.db.prepare(`SELECT COUNT(*) AS total FROM memory_items
|
|
662
|
+
WHERE session_id = ? AND active = 1`).get(sessionId);
|
|
663
|
+
return visibleCount === Number(row?.total ?? 0);
|
|
664
|
+
}
|
|
665
|
+
function countVisiblePromptRows(store, project) {
|
|
666
|
+
const filterResult = buildViewerMemoryFilters(store, null);
|
|
667
|
+
const clauses = ["user_prompts.session_id IS NOT NULL", `EXISTS (
|
|
668
|
+
SELECT 1 FROM memory_items
|
|
669
|
+
WHERE memory_items.session_id = user_prompts.session_id
|
|
670
|
+
AND memory_items.active = 1
|
|
671
|
+
AND ${filterResult.clauses.join(" AND ")}
|
|
672
|
+
)`];
|
|
673
|
+
const params = [...filterResult.params];
|
|
674
|
+
if (project) {
|
|
675
|
+
clauses.unshift("user_prompts.project = ?");
|
|
676
|
+
params.unshift(project);
|
|
677
|
+
}
|
|
678
|
+
const row = store.db.prepare(`SELECT COUNT(*) AS total FROM user_prompts WHERE ${clauses.join(" AND ")}`).get(...params);
|
|
679
|
+
return Number(row?.total ?? 0);
|
|
680
|
+
}
|
|
681
|
+
function countVisibleArtifactRows(store, project) {
|
|
682
|
+
const filterResult = buildViewerMemoryFilters(store, null);
|
|
683
|
+
const clauses = [`EXISTS (
|
|
684
|
+
SELECT 1 FROM memory_items
|
|
685
|
+
WHERE memory_items.session_id = artifacts.session_id
|
|
686
|
+
AND memory_items.active = 1
|
|
687
|
+
AND ${filterResult.clauses.join(" AND ")}
|
|
688
|
+
)`];
|
|
689
|
+
const params = [...filterResult.params];
|
|
690
|
+
const from = project ? "artifacts JOIN sessions ON sessions.id = artifacts.session_id" : "artifacts";
|
|
691
|
+
if (project) {
|
|
692
|
+
clauses.unshift("sessions.project = ?");
|
|
693
|
+
params.unshift(project);
|
|
694
|
+
}
|
|
695
|
+
const row = store.db.prepare(`SELECT COUNT(*) AS total FROM ${from} WHERE ${clauses.join(" AND ")}`).get(...params);
|
|
696
|
+
return Number(row?.total ?? 0);
|
|
697
|
+
}
|
|
644
698
|
function queryMemoryPage(store, options) {
|
|
645
699
|
const filters = {};
|
|
646
700
|
if (options.project) filters.project = options.project;
|
|
647
701
|
if (options.scope) filters.ownership_scope = options.scope;
|
|
648
|
-
const filterResult =
|
|
649
|
-
actorId: store.actorId,
|
|
650
|
-
deviceId: store.deviceId
|
|
651
|
-
});
|
|
702
|
+
const filterResult = buildViewerMemoryFilters(store, filters);
|
|
652
703
|
const where = ["memory_items.active = 1", ...filterResult.clauses].join(" AND ");
|
|
653
704
|
const from = filterResult.joinSessions ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" : "memory_items";
|
|
654
705
|
const rows = store.db.prepare(`SELECT memory_items.* FROM ${from}
|
|
@@ -688,9 +739,18 @@ function memoryRoutes(getStore) {
|
|
|
688
739
|
const store = getStore();
|
|
689
740
|
{
|
|
690
741
|
const limit = queryInt(c.req.query("limit"), 20);
|
|
691
|
-
const
|
|
742
|
+
const filterResult = buildViewerMemoryFilters(store, null);
|
|
743
|
+
const clauses = [
|
|
744
|
+
"memory_items.session_id = sessions.id",
|
|
745
|
+
"memory_items.active = 1",
|
|
746
|
+
...filterResult.clauses
|
|
747
|
+
];
|
|
748
|
+
const items = store.db.prepare(`SELECT sessions.* FROM sessions
|
|
749
|
+
WHERE EXISTS (SELECT 1 FROM memory_items WHERE ${clauses.join(" AND ")})
|
|
750
|
+
ORDER BY sessions.started_at DESC
|
|
751
|
+
LIMIT ?`).all(...filterResult.params, limit).map((row) => ({
|
|
692
752
|
...row,
|
|
693
|
-
metadata_json: fromJson(row.metadata_json)
|
|
753
|
+
metadata_json: fromJson(row.metadata_json ?? null)
|
|
694
754
|
}));
|
|
695
755
|
return c.json({ items });
|
|
696
756
|
}
|
|
@@ -698,7 +758,16 @@ function memoryRoutes(getStore) {
|
|
|
698
758
|
app.get("/api/projects", (c) => {
|
|
699
759
|
const store = getStore();
|
|
700
760
|
{
|
|
701
|
-
const
|
|
761
|
+
const filterResult = buildViewerMemoryFilters(store, null);
|
|
762
|
+
const clauses = [
|
|
763
|
+
"memory_items.session_id = sessions.id",
|
|
764
|
+
"memory_items.active = 1",
|
|
765
|
+
"sessions.project IS NOT NULL",
|
|
766
|
+
...filterResult.clauses
|
|
767
|
+
];
|
|
768
|
+
const rows = store.db.prepare(`SELECT DISTINCT sessions.project AS project FROM sessions
|
|
769
|
+
JOIN memory_items ON memory_items.session_id = sessions.id
|
|
770
|
+
WHERE ${clauses.join(" AND ")}`).all(...filterResult.params);
|
|
702
771
|
const projects = [...new Set(rows.map((r) => String(r.project ?? "").trim()).filter((p) => p && !p.toLowerCase().startsWith("fatal:")).map((p) => projectBasename(p)).filter(Boolean))].sort();
|
|
703
772
|
return c.json({ projects });
|
|
704
773
|
}
|
|
@@ -765,10 +834,6 @@ function memoryRoutes(getStore) {
|
|
|
765
834
|
const store = getStore();
|
|
766
835
|
{
|
|
767
836
|
const project = c.req.query("project") || null;
|
|
768
|
-
const count = (sql, ...params) => {
|
|
769
|
-
const row = store.db.prepare(sql).get(...params);
|
|
770
|
-
return Number(row?.total ?? 0);
|
|
771
|
-
};
|
|
772
837
|
let prompts;
|
|
773
838
|
let artifacts;
|
|
774
839
|
let memories;
|
|
@@ -790,18 +855,14 @@ function memoryRoutes(getStore) {
|
|
|
790
855
|
return total;
|
|
791
856
|
};
|
|
792
857
|
if (project) {
|
|
793
|
-
prompts =
|
|
794
|
-
artifacts =
|
|
795
|
-
|
|
796
|
-
WHERE sessions.project = ?`, project);
|
|
797
|
-
memories = count(`SELECT COUNT(*) AS total FROM memory_items
|
|
798
|
-
JOIN sessions ON sessions.id = memory_items.session_id
|
|
799
|
-
WHERE sessions.project = ?`, project);
|
|
858
|
+
prompts = countVisiblePromptRows(store, project);
|
|
859
|
+
artifacts = countVisibleArtifactRows(store, project);
|
|
860
|
+
memories = countVisibleMemoryRows(store, { project });
|
|
800
861
|
observations = countObservations(project);
|
|
801
862
|
} else {
|
|
802
|
-
prompts =
|
|
803
|
-
artifacts =
|
|
804
|
-
memories =
|
|
863
|
+
prompts = countVisiblePromptRows(store);
|
|
864
|
+
artifacts = countVisibleArtifactRows(store);
|
|
865
|
+
memories = countVisibleMemoryRows(store);
|
|
805
866
|
observations = countObservations();
|
|
806
867
|
}
|
|
807
868
|
const total = prompts + artifacts + memories;
|
|
@@ -878,6 +939,7 @@ function memoryRoutes(getStore) {
|
|
|
878
939
|
if (!sessionIdStr) return c.json({ error: "session_id required" }, 400);
|
|
879
940
|
const sessionId = parseStrictInteger(sessionIdStr);
|
|
880
941
|
if (sessionId == null) return c.json({ error: "session_id must be int" }, 400);
|
|
942
|
+
if (!sessionAllowsArtifactAccess(store, sessionId)) return c.json({ error: "session not found" }, 404);
|
|
881
943
|
const rows = drizzle(store.db, { schema }).select().from(schema.artifacts).where(eq(schema.artifacts.session_id, sessionId)).all();
|
|
882
944
|
return c.json({ items: rows });
|
|
883
945
|
}
|
|
@@ -892,6 +954,7 @@ function memoryRoutes(getStore) {
|
|
|
892
954
|
}
|
|
893
955
|
const memoryId = parseStrictInteger(typeof body.memory_id === "string" ? body.memory_id : String(body.memory_id ?? ""));
|
|
894
956
|
if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
|
|
957
|
+
if (!store.get(memoryId)) return c.json({ error: "memory not found" }, 404);
|
|
895
958
|
const visibility = String(body.visibility ?? "").trim();
|
|
896
959
|
if (visibility !== "private" && visibility !== "shared") return c.json({ error: "visibility must be private or shared" }, 400);
|
|
897
960
|
try {
|
|
@@ -916,6 +979,7 @@ function memoryRoutes(getStore) {
|
|
|
916
979
|
if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
|
|
917
980
|
const project = String(body.project ?? "").trim();
|
|
918
981
|
if (!project) return c.json({ error: "project must be a non-empty string" }, 400);
|
|
982
|
+
if (!store.get(memoryId)) return c.json({ error: "memory not found" }, 404);
|
|
919
983
|
try {
|
|
920
984
|
const result = store.moveMemoryProject(memoryId, project);
|
|
921
985
|
return c.json(result);
|
|
@@ -936,6 +1000,7 @@ function memoryRoutes(getStore) {
|
|
|
936
1000
|
}
|
|
937
1001
|
const memoryId = parseStrictInteger(typeof body.memory_id === "string" ? body.memory_id : String(body.memory_id ?? ""));
|
|
938
1002
|
if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
|
|
1003
|
+
if (!store.get(memoryId)) return c.json({ error: "memory not found" }, 404);
|
|
939
1004
|
const row = drizzle(store.db, { schema }).select().from(schema.memoryItems).where(eq(schema.memoryItems.id, memoryId)).get();
|
|
940
1005
|
if (!row) return c.json({ error: "memory not found" }, 404);
|
|
941
1006
|
if (!memoryOwnedBySelf(row, buildOwnershipContext(store))) return c.json({ error: "memory not owned by this device" }, 403);
|
|
@@ -1330,6 +1395,82 @@ function sortActiveMaintenanceJobs$1(jobs) {
|
|
|
1330
1395
|
return aKind.localeCompare(bKind);
|
|
1331
1396
|
});
|
|
1332
1397
|
}
|
|
1398
|
+
var MEMORY_ID_METADATA_KEYS = [
|
|
1399
|
+
"pack_item_ids",
|
|
1400
|
+
"added_ids",
|
|
1401
|
+
"removed_ids",
|
|
1402
|
+
"retained_ids"
|
|
1403
|
+
];
|
|
1404
|
+
function toUsageRow(row, metadata) {
|
|
1405
|
+
return {
|
|
1406
|
+
id: Number(row.id ?? 0),
|
|
1407
|
+
session_id: row.session_id == null ? null : Number(row.session_id),
|
|
1408
|
+
event: String(row.event ?? "pack"),
|
|
1409
|
+
tokens_read: Number(row.tokens_read ?? 0),
|
|
1410
|
+
tokens_written: Number(row.tokens_written ?? 0),
|
|
1411
|
+
tokens_saved: Number(row.tokens_saved ?? 0),
|
|
1412
|
+
created_at: String(row.created_at ?? ""),
|
|
1413
|
+
metadata_json: metadata
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
function visibleMemoryIds(store, value) {
|
|
1417
|
+
if (!Array.isArray(value)) return [];
|
|
1418
|
+
return value.map((item) => typeof item === "number" ? item : Number(item)).filter((item) => Number.isInteger(item) && store.get(item) != null);
|
|
1419
|
+
}
|
|
1420
|
+
function sessionHasVisibleMemory(store, sessionId) {
|
|
1421
|
+
const filterResult = buildFilterClausesWithContext({ session_id: sessionId }, {
|
|
1422
|
+
actorId: store.actorId,
|
|
1423
|
+
deviceId: store.deviceId,
|
|
1424
|
+
enforceScopeVisibility: true
|
|
1425
|
+
});
|
|
1426
|
+
const clauses = ["memory_items.active = 1", ...filterResult.clauses];
|
|
1427
|
+
return store.db.prepare(`SELECT 1 AS found FROM memory_items WHERE ${clauses.join(" AND ")} LIMIT 1`).get(...filterResult.params) != null;
|
|
1428
|
+
}
|
|
1429
|
+
function usageRowVisible(store, row) {
|
|
1430
|
+
const packItemIds = row.metadata_json?.pack_item_ids;
|
|
1431
|
+
if (Array.isArray(packItemIds)) {
|
|
1432
|
+
if (packItemIds.length === 0) return row.session_id != null && sessionHasVisibleMemory(store, row.session_id);
|
|
1433
|
+
return visibleMemoryIds(store, packItemIds).length > 0;
|
|
1434
|
+
}
|
|
1435
|
+
return row.session_id != null && sessionHasVisibleMemory(store, row.session_id);
|
|
1436
|
+
}
|
|
1437
|
+
function sanitizePackUsageMetadata(store, metadata) {
|
|
1438
|
+
if (!metadata) return null;
|
|
1439
|
+
const sanitized = { ...metadata };
|
|
1440
|
+
for (const key of MEMORY_ID_METADATA_KEYS) if (Array.isArray(sanitized[key])) sanitized[key] = visibleMemoryIds(store, sanitized[key]);
|
|
1441
|
+
return sanitized;
|
|
1442
|
+
}
|
|
1443
|
+
function summarizeUsageEvents(rows) {
|
|
1444
|
+
const byEvent = /* @__PURE__ */ new Map();
|
|
1445
|
+
for (const row of rows) {
|
|
1446
|
+
const summary = byEvent.get(row.event) ?? {
|
|
1447
|
+
event: row.event,
|
|
1448
|
+
total_tokens_read: 0,
|
|
1449
|
+
total_tokens_written: 0,
|
|
1450
|
+
total_tokens_saved: 0,
|
|
1451
|
+
count: 0
|
|
1452
|
+
};
|
|
1453
|
+
summary.total_tokens_read += row.tokens_read;
|
|
1454
|
+
summary.total_tokens_written += row.tokens_written;
|
|
1455
|
+
summary.total_tokens_saved += row.tokens_saved;
|
|
1456
|
+
summary.count += 1;
|
|
1457
|
+
byEvent.set(row.event, summary);
|
|
1458
|
+
}
|
|
1459
|
+
return [...byEvent.values()].sort((a, b) => a.event.localeCompare(b.event));
|
|
1460
|
+
}
|
|
1461
|
+
function totalUsageEvents(rows) {
|
|
1462
|
+
return rows.reduce((acc, row) => ({
|
|
1463
|
+
tokens_read: Number(acc.tokens_read) + row.tokens_read,
|
|
1464
|
+
tokens_written: Number(acc.tokens_written) + row.tokens_written,
|
|
1465
|
+
tokens_saved: Number(acc.tokens_saved) + row.tokens_saved,
|
|
1466
|
+
count: Number(acc.count) + 1
|
|
1467
|
+
}), {
|
|
1468
|
+
tokens_read: 0,
|
|
1469
|
+
tokens_written: 0,
|
|
1470
|
+
tokens_saved: 0,
|
|
1471
|
+
count: 0
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1333
1474
|
/**
|
|
1334
1475
|
* Create stats routes. The store factory is called per-request to get a
|
|
1335
1476
|
* fresh connection (matching the Python viewer pattern).
|
|
@@ -1378,58 +1519,27 @@ function statsRoutes(getStore) {
|
|
|
1378
1519
|
const store = getStore();
|
|
1379
1520
|
{
|
|
1380
1521
|
const projectFilter = c.req.query("project") || null;
|
|
1381
|
-
const
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
const
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
COUNT(*) AS count
|
|
1403
|
-
FROM usage_events`).get();
|
|
1404
|
-
let eventsFiltered = null;
|
|
1405
|
-
let totalsFiltered = null;
|
|
1406
|
-
if (projectFilter) {
|
|
1407
|
-
eventsFiltered = store.db.prepare(`SELECT event,
|
|
1408
|
-
SUM(tokens_read) AS total_tokens_read,
|
|
1409
|
-
SUM(tokens_written) AS total_tokens_written,
|
|
1410
|
-
SUM(tokens_saved) AS total_tokens_saved,
|
|
1411
|
-
COUNT(*) AS count
|
|
1412
|
-
FROM usage_events
|
|
1413
|
-
JOIN sessions ON sessions.id = usage_events.session_id
|
|
1414
|
-
WHERE sessions.project = ?
|
|
1415
|
-
GROUP BY event ORDER BY event`).all(projectFilter);
|
|
1416
|
-
totalsFiltered = store.db.prepare(`SELECT COALESCE(SUM(tokens_read),0) AS tokens_read,
|
|
1417
|
-
COALESCE(SUM(tokens_written),0) AS tokens_written,
|
|
1418
|
-
COALESCE(SUM(tokens_saved),0) AS tokens_saved,
|
|
1419
|
-
COUNT(*) AS count
|
|
1420
|
-
FROM usage_events
|
|
1421
|
-
JOIN sessions ON sessions.id = usage_events.session_id
|
|
1422
|
-
WHERE sessions.project = ?`).get(projectFilter);
|
|
1423
|
-
}
|
|
1424
|
-
const recentPacks = (projectFilter ? recentPacksQuery.all(projectFilter) : recentPacksQuery.all()).map((row) => ({
|
|
1425
|
-
id: Number(row.id ?? 0),
|
|
1426
|
-
session_id: row.session_id == null ? null : Number(row.session_id),
|
|
1427
|
-
event: String(row.event ?? "pack"),
|
|
1428
|
-
tokens_read: Number(row.tokens_read ?? 0),
|
|
1429
|
-
tokens_written: Number(row.tokens_written ?? 0),
|
|
1430
|
-
tokens_saved: Number(row.tokens_saved ?? 0),
|
|
1431
|
-
created_at: String(row.created_at ?? ""),
|
|
1432
|
-
metadata_json: parseMetadataJson(row.metadata_json)
|
|
1522
|
+
const loadUsageRows = (project) => {
|
|
1523
|
+
return (project ? store.db.prepare(`SELECT usage_events.id, usage_events.session_id, usage_events.event,
|
|
1524
|
+
usage_events.tokens_read, usage_events.tokens_written, usage_events.tokens_saved,
|
|
1525
|
+
usage_events.created_at, usage_events.metadata_json
|
|
1526
|
+
FROM usage_events
|
|
1527
|
+
JOIN sessions ON sessions.id = usage_events.session_id
|
|
1528
|
+
WHERE sessions.project = ?
|
|
1529
|
+
ORDER BY usage_events.created_at DESC`).all(project) : store.db.prepare(`SELECT id, session_id, event, tokens_read, tokens_written, tokens_saved,
|
|
1530
|
+
created_at, metadata_json
|
|
1531
|
+
FROM usage_events
|
|
1532
|
+
ORDER BY created_at DESC`).all()).map((row) => toUsageRow(row, parseMetadataJson(row.metadata_json))).filter((row) => usageRowVisible(store, row));
|
|
1533
|
+
};
|
|
1534
|
+
const globalRows = loadUsageRows();
|
|
1535
|
+
const filteredRows = projectFilter ? loadUsageRows(projectFilter) : null;
|
|
1536
|
+
const eventsGlobal = summarizeUsageEvents(globalRows);
|
|
1537
|
+
const totalsGlobal = totalUsageEvents(globalRows);
|
|
1538
|
+
const eventsFiltered = filteredRows ? summarizeUsageEvents(filteredRows) : null;
|
|
1539
|
+
const totalsFiltered = filteredRows ? totalUsageEvents(filteredRows) : null;
|
|
1540
|
+
const recentPacks = (filteredRows ?? globalRows).filter((row) => row.event === "pack").slice(0, 10).map((row) => ({
|
|
1541
|
+
...row,
|
|
1542
|
+
metadata_json: sanitizePackUsageMetadata(store, row.metadata_json)
|
|
1433
1543
|
}));
|
|
1434
1544
|
return c.json({
|
|
1435
1545
|
project: projectFilter,
|
|
@@ -1450,6 +1560,16 @@ function statsRoutes(getStore) {
|
|
|
1450
1560
|
/**
|
|
1451
1561
|
* Sync routes — status, peers, actors, attempts, pairing, mutations.
|
|
1452
1562
|
*/
|
|
1563
|
+
function safeSkippedSyncDetail(detail) {
|
|
1564
|
+
if (!detail) return null;
|
|
1565
|
+
return {
|
|
1566
|
+
reason: detail.reason,
|
|
1567
|
+
skipped_count: detail.skipped_count,
|
|
1568
|
+
scope_id: detail.scope_id ?? null,
|
|
1569
|
+
project: detail.project ?? null,
|
|
1570
|
+
visibility: detail.visibility ?? null
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1453
1573
|
var SYNC_STALE_AFTER_SECONDS = 600;
|
|
1454
1574
|
var SYNC_PROTOCOL_VERSION = "2";
|
|
1455
1575
|
var LEGACY_SYNC_ACTOR_DISPLAY_NAME = "Legacy synced peer";
|
|
@@ -1559,12 +1679,37 @@ function optionalViewerString(body, key) {
|
|
|
1559
1679
|
if (value == null) return null;
|
|
1560
1680
|
return String(value).trim() || null;
|
|
1561
1681
|
}
|
|
1682
|
+
function optionalViewerStrictString(body, key) {
|
|
1683
|
+
const value = body[key];
|
|
1684
|
+
if (value == null) return null;
|
|
1685
|
+
if (typeof value !== "string") throw new Error(`${key} must be string`);
|
|
1686
|
+
return value.trim() || null;
|
|
1687
|
+
}
|
|
1688
|
+
function optionalViewerStringList(body, key) {
|
|
1689
|
+
const value = body[key];
|
|
1690
|
+
if (value == null) return [];
|
|
1691
|
+
if (!Array.isArray(value)) throw new Error(`${key} must be an array of strings`);
|
|
1692
|
+
return [...new Set(value.map((item) => {
|
|
1693
|
+
if (typeof item !== "string") throw new Error(`${key} must be an array of strings`);
|
|
1694
|
+
return item.trim();
|
|
1695
|
+
}).filter(Boolean))];
|
|
1696
|
+
}
|
|
1562
1697
|
function optionalViewerNumber(body, key) {
|
|
1563
1698
|
const value = body[key];
|
|
1564
1699
|
if (value == null || value === "") return null;
|
|
1565
1700
|
const number = typeof value === "number" ? value : Number(value);
|
|
1566
1701
|
return Number.isFinite(number) ? Math.trunc(number) : NaN;
|
|
1567
1702
|
}
|
|
1703
|
+
function optionalViewerInteger(body, key) {
|
|
1704
|
+
const value = body[key];
|
|
1705
|
+
if (value == null || value === "") return null;
|
|
1706
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
1707
|
+
return Number.isInteger(number) ? number : NaN;
|
|
1708
|
+
}
|
|
1709
|
+
function missingGuardrailConfirmations(warnings, confirmedTokens) {
|
|
1710
|
+
const confirmed = new Set(confirmedTokens);
|
|
1711
|
+
return warnings.filter((warning) => warning.requires_confirmation && (!warning.confirmation_token || !confirmed.has(warning.confirmation_token)));
|
|
1712
|
+
}
|
|
1568
1713
|
function coordinatorAdminMutationStatus(message) {
|
|
1569
1714
|
if (message.includes("scope_not_found") || message.includes("membership_not_found") || message.includes("group_not_found") || message.includes("Group not found") || message.includes("Scope not found") || message.includes("device_not_enrolled_for_scope_group") || message.includes("device must be enrolled")) return 404;
|
|
1570
1715
|
if (message.includes("not active") || message.includes("scope_not_active") || message.includes("group_archived") || message.includes("Group is archived")) return 409;
|
|
@@ -1892,6 +2037,42 @@ function currentProjectScope(row, effective) {
|
|
|
1892
2037
|
inherits_global: row.projects_include_json == null && row.projects_exclude_json == null
|
|
1893
2038
|
};
|
|
1894
2039
|
}
|
|
2040
|
+
function cleanPeerScopeText(value) {
|
|
2041
|
+
return String(value ?? "").trim() || null;
|
|
2042
|
+
}
|
|
2043
|
+
function peerAuthorizedScopes(store, peerDeviceId) {
|
|
2044
|
+
const deviceId = peerDeviceId.trim();
|
|
2045
|
+
if (!deviceId) return [];
|
|
2046
|
+
return store.db.prepare(`SELECT
|
|
2047
|
+
rs.scope_id,
|
|
2048
|
+
rs.label,
|
|
2049
|
+
rs.kind,
|
|
2050
|
+
rs.authority_type,
|
|
2051
|
+
rs.coordinator_id,
|
|
2052
|
+
rs.group_id,
|
|
2053
|
+
sm.role,
|
|
2054
|
+
sm.membership_epoch,
|
|
2055
|
+
sm.updated_at
|
|
2056
|
+
FROM scope_memberships sm
|
|
2057
|
+
JOIN replication_scopes rs ON rs.scope_id = sm.scope_id
|
|
2058
|
+
WHERE sm.device_id = ?
|
|
2059
|
+
AND sm.status = 'active'
|
|
2060
|
+
AND rs.status = 'active'
|
|
2061
|
+
AND sm.membership_epoch >= rs.membership_epoch
|
|
2062
|
+
ORDER BY CASE WHEN rs.authority_type = 'local' THEN 1 ELSE 0 END,
|
|
2063
|
+
rs.label COLLATE NOCASE,
|
|
2064
|
+
rs.scope_id`).all(deviceId).map((row) => ({
|
|
2065
|
+
scope_id: String(row.scope_id ?? ""),
|
|
2066
|
+
label: String(row.label ?? row.scope_id ?? ""),
|
|
2067
|
+
kind: String(row.kind ?? "user"),
|
|
2068
|
+
authority_type: String(row.authority_type ?? "local"),
|
|
2069
|
+
coordinator_id: cleanPeerScopeText(row.coordinator_id),
|
|
2070
|
+
group_id: cleanPeerScopeText(row.group_id),
|
|
2071
|
+
role: String(row.role ?? "member"),
|
|
2072
|
+
membership_epoch: Number(row.membership_epoch ?? 0),
|
|
2073
|
+
updated_at: cleanPeerScopeText(row.updated_at)
|
|
2074
|
+
}));
|
|
2075
|
+
}
|
|
1895
2076
|
function ensureLocalActorRecord(store) {
|
|
1896
2077
|
const d = drizzle(store.db, { schema });
|
|
1897
2078
|
if (d.select({ actor_id: schema.actors.actor_id }).from(schema.actors).where(eq(schema.actors.actor_id, store.actorId)).get()) return;
|
|
@@ -2114,7 +2295,8 @@ function filterOpsForPeer(store, peerDeviceId, localDeviceId, ops, options = {})
|
|
|
2114
2295
|
});
|
|
2115
2296
|
return {
|
|
2116
2297
|
allowed,
|
|
2117
|
-
skipped: skipped?.skipped_count ?? 0
|
|
2298
|
+
skipped: skipped?.skipped_count ?? 0,
|
|
2299
|
+
skippedDetail: skipped ? safeSkippedSyncDetail(skipped) : null
|
|
2118
2300
|
};
|
|
2119
2301
|
}
|
|
2120
2302
|
function claimedLocalActorScopeStatus(store, row) {
|
|
@@ -2155,9 +2337,10 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
|
|
|
2155
2337
|
last_error: showDiag ? row.last_error : null,
|
|
2156
2338
|
has_error: Boolean(row.last_error),
|
|
2157
2339
|
claimed_local_actor: Boolean(row.claimed_local_actor),
|
|
2158
|
-
claimed_local_actor_scope:
|
|
2340
|
+
claimed_local_actor_scope: claimedLocalActorScopeStatus(store, row),
|
|
2159
2341
|
actor_id: row.actor_id ?? null,
|
|
2160
2342
|
actor_display_name: row.actor_display_name ?? null,
|
|
2343
|
+
authorized_scopes: peerAuthorizedScopes(store, peerId),
|
|
2161
2344
|
project_scope: { ...currentProjectScope(row, readPeerProjectFilters(store, String(row.peer_device_id ?? ""))) },
|
|
2162
2345
|
recent_ops: {
|
|
2163
2346
|
in: recentOps.in,
|
|
@@ -2366,7 +2549,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
2366
2549
|
retained_floor_cursor: boundary.retained_floor_cursor,
|
|
2367
2550
|
ops: filtered.allowed,
|
|
2368
2551
|
next_cursor: nextCursor,
|
|
2369
|
-
skipped: filtered.skipped
|
|
2552
|
+
skipped: filtered.skipped,
|
|
2553
|
+
skipped_detail: filtered.skippedDetail
|
|
2370
2554
|
});
|
|
2371
2555
|
} catch {
|
|
2372
2556
|
return c.json({ error: "internal_error" }, 500);
|
|
@@ -2483,6 +2667,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
2483
2667
|
return c.json({
|
|
2484
2668
|
...result,
|
|
2485
2669
|
skipped,
|
|
2670
|
+
skipped_detail: filtered.skippedDetail,
|
|
2486
2671
|
sync_capability: LOCAL_SYNC_CAPABILITY,
|
|
2487
2672
|
scope_id: scopeRequest.scope_id
|
|
2488
2673
|
});
|
|
@@ -2585,6 +2770,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
2585
2770
|
statusPayload.daemon_running = runtimeStatus.phase === "starting" || daemonRunning;
|
|
2586
2771
|
statusPayload.daemon_detail = runtimeStatus.detail ?? daemonDetail;
|
|
2587
2772
|
}
|
|
2773
|
+
const coordinatorSnapshot = await coordinatorStatusSnapshot(store, config);
|
|
2774
|
+
const coordinator = traceSync("coordinator", () => redactCoordinatorStatus(coordinatorSnapshot, showDiag));
|
|
2588
2775
|
const peerRows = traceSync("peerRows", () => store.db.prepare(PEERS_QUERY).all());
|
|
2589
2776
|
const recentOpsByPeer = traceSync("recentPeerOps", () => recentPeerOps(store));
|
|
2590
2777
|
const scopeRejectionsByPeer = traceSync("recentScopeRejectionsByPeer", () => recentScopeRejectionsByPeer(store));
|
|
@@ -2631,8 +2818,6 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
2631
2818
|
};
|
|
2632
2819
|
const legacyDevices = traceSync("legacyDevices", () => store.claimableLegacyDeviceIds());
|
|
2633
2820
|
const sharingReview = traceSync("sharingReview", () => store.sharingReviewSummary(project));
|
|
2634
|
-
const coordinatorSnapshot = await coordinatorStatusSnapshot(store, config);
|
|
2635
|
-
const coordinator = traceSync("coordinator", () => redactCoordinatorStatus(coordinatorSnapshot, showDiag));
|
|
2636
2821
|
let joinRequests = [];
|
|
2637
2822
|
if (includeJoinRequests && showDiag && config.syncCoordinatorAdminSecret) try {
|
|
2638
2823
|
joinRequests = await listCoordinatorJoinRequests(config);
|
|
@@ -2821,7 +3006,9 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
2821
3006
|
const result = await runSyncPass(store.db, peerId, { scanner: store.scanner });
|
|
2822
3007
|
items.push({
|
|
2823
3008
|
peer_device_id: peerId,
|
|
2824
|
-
...result
|
|
3009
|
+
...result,
|
|
3010
|
+
skippedOut: void 0,
|
|
3011
|
+
skipped_out: safeSkippedSyncDetail(result.skippedOut)
|
|
2825
3012
|
});
|
|
2826
3013
|
}
|
|
2827
3014
|
return c.json({ items });
|
|
@@ -2855,6 +3042,78 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
2855
3042
|
project_scope: currentProjectScope(row, readPeerProjectFilters(store, peerDeviceId))
|
|
2856
3043
|
});
|
|
2857
3044
|
});
|
|
3045
|
+
app.get("/api/sync/sharing-domains/settings", (c) => {
|
|
3046
|
+
const store = getStore();
|
|
3047
|
+
const limit = Math.max(1, queryInt(c.req.query("limit"), 250));
|
|
3048
|
+
return c.json({
|
|
3049
|
+
scopes: listSharingDomainSettingsScopes(store.db),
|
|
3050
|
+
mappings: listProjectScopeSettingsMappings(store.db),
|
|
3051
|
+
projects: listProjectScopeCandidates(store.db, { limit }),
|
|
3052
|
+
local_default_scope_id: LOCAL_DEFAULT_SCOPE_ID
|
|
3053
|
+
});
|
|
3054
|
+
});
|
|
3055
|
+
app.put("/api/sync/sharing-domains/project-mappings", async (c) => {
|
|
3056
|
+
const store = getStore();
|
|
3057
|
+
const body = await parseViewerJsonBody(c);
|
|
3058
|
+
if (!body) return c.json({ error: "invalid json" }, 400);
|
|
3059
|
+
try {
|
|
3060
|
+
const id = optionalViewerInteger(body, "id");
|
|
3061
|
+
const priority = optionalViewerInteger(body, "priority");
|
|
3062
|
+
if (Number.isNaN(id)) return c.json({ error: "id must be an integer" }, 400);
|
|
3063
|
+
if (Number.isNaN(priority)) return c.json({ error: "priority must be an integer" }, 400);
|
|
3064
|
+
const workspaceIdentity = optionalViewerStrictString(body, "workspace_identity");
|
|
3065
|
+
const projectPattern = optionalViewerStrictString(body, "project_pattern");
|
|
3066
|
+
const scopeId = optionalViewerStrictString(body, "scope_id") ?? "";
|
|
3067
|
+
const source = optionalViewerStrictString(body, "source") ?? "user";
|
|
3068
|
+
const confirmedGuardrailTokens = optionalViewerStringList(body, "confirmed_guardrail_tokens");
|
|
3069
|
+
const mappingInput = {
|
|
3070
|
+
id,
|
|
3071
|
+
workspace_identity: workspaceIdentity,
|
|
3072
|
+
project_pattern: projectPattern,
|
|
3073
|
+
scope_id: scopeId,
|
|
3074
|
+
priority,
|
|
3075
|
+
source
|
|
3076
|
+
};
|
|
3077
|
+
const analysis = analyzeProjectScopeMappingChangeGuardrails(store.db, mappingInput);
|
|
3078
|
+
if (analysis.requested_workspace_identity?.startsWith("unmapped:") && scopeId !== LOCAL_DEFAULT_SCOPE_ID) return c.json({
|
|
3079
|
+
error: "unmapped_project_local_only",
|
|
3080
|
+
message: "Unmapped projects need a stable path, git remote, or workspace id before assigning a non-local Sharing domain.",
|
|
3081
|
+
guardrail_warnings: analysis.warnings
|
|
3082
|
+
}, 400);
|
|
3083
|
+
const missingConfirmations = missingGuardrailConfirmations(analysis.warnings, confirmedGuardrailTokens);
|
|
3084
|
+
if (missingConfirmations.length > 0) {
|
|
3085
|
+
const missingCodes = [...new Set(missingConfirmations.map((warning) => warning.code))];
|
|
3086
|
+
const missingTokens = [...new Set(missingConfirmations.map((warning) => warning.confirmation_token))].filter((token) => typeof token === "string" && token.length > 0);
|
|
3087
|
+
return c.json({
|
|
3088
|
+
error: "guardrail_confirmation_required",
|
|
3089
|
+
required_guardrails: missingCodes,
|
|
3090
|
+
required_guardrail_tokens: missingTokens,
|
|
3091
|
+
guardrail_warnings: analysis.warnings
|
|
3092
|
+
}, 409);
|
|
3093
|
+
}
|
|
3094
|
+
const mapping = upsertProjectScopeSettingsMapping(store.db, { ...mappingInput });
|
|
3095
|
+
return c.json({
|
|
3096
|
+
ok: true,
|
|
3097
|
+
mapping,
|
|
3098
|
+
guardrail_warnings: analysis.warnings
|
|
3099
|
+
});
|
|
3100
|
+
} catch (error) {
|
|
3101
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
3102
|
+
}
|
|
3103
|
+
});
|
|
3104
|
+
app.delete("/api/sync/sharing-domains/project-mappings/:id", (c) => {
|
|
3105
|
+
const store = getStore();
|
|
3106
|
+
const id = Number(c.req.param("id"));
|
|
3107
|
+
try {
|
|
3108
|
+
const deleted = deleteProjectScopeSettingsMapping(store.db, id);
|
|
3109
|
+
return c.json({
|
|
3110
|
+
ok: true,
|
|
3111
|
+
deleted
|
|
3112
|
+
});
|
|
3113
|
+
} catch (error) {
|
|
3114
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
3115
|
+
}
|
|
3116
|
+
});
|
|
2858
3117
|
app.post("/api/sync/peers/identity", async (c) => {
|
|
2859
3118
|
const store = getStore();
|
|
2860
3119
|
ensureLocalActorRecord(store);
|