@vellumai/assistant 0.11.10-dev.202609091913.5b4889c → 0.11.10-dev.202609092016.c7aa45c
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/AGENTS.md +1 -1
- package/node_modules/@vellumai/gateway-client/src/ipc-client.ts +5 -3
- package/openapi.yaml +216 -0
- package/package.json +1 -1
- package/src/__tests__/config-schema.test.ts +26 -0
- package/src/__tests__/db-readiness-http-gate.test.ts +43 -0
- package/src/__tests__/error-handler-friendly-messages.test.ts +15 -0
- package/src/__tests__/identity-routes.test.ts +6 -1
- package/src/__tests__/workspace-git-service.test.ts +170 -1
- package/src/__tests__/workspace-heartbeat-service.test.ts +41 -0
- package/src/config/bundled-skills/conversation-groups/SKILL.md +1 -0
- package/src/config/bundled-skills/schedule/SKILL.md +10 -0
- package/src/config/schemas/workspace-git.ts +10 -0
- package/src/conversations/message-consolidation.ts +4 -0
- package/src/daemon/__tests__/daemon-readiness.test.ts +32 -0
- package/src/daemon/daemon-readiness.ts +34 -4
- package/src/daemon/lifecycle.ts +12 -5
- package/src/daemon/shutdown-handlers.ts +2 -0
- package/src/ipc/__tests__/db-readiness-ipc-gate.test.ts +17 -2
- package/src/ipc/__tests__/gateway-ipc-errors.test.ts +51 -0
- package/src/ipc/__tests__/gateway-validated-call.test.ts +5 -6
- package/src/ipc/assistant-server.ts +9 -16
- package/src/ipc/gateway-client.test.ts +6 -2
- package/src/ipc/gateway-client.ts +53 -14
- package/src/ipc/gateway-ipc-errors.ts +16 -4
- package/src/ipc/gateway-validated-call.ts +5 -12
- package/src/monitoring/__tests__/workspace-git-heartbeat.test.ts +57 -0
- package/src/monitoring/worker.ts +18 -0
- package/src/monitoring/workspace-git-heartbeat.ts +45 -0
- package/src/notifications/__tests__/notification-utils.test.ts +13 -0
- package/src/notifications/__tests__/schedule-result-producer.test.ts +15 -0
- package/src/notifications/notification-utils.ts +15 -0
- package/src/notifications/schedule-result-producer.ts +2 -1
- package/src/persistence/__tests__/db-init-migrations-ok.test.ts +14 -3
- package/src/persistence/conversation-crud.ts +201 -0
- package/src/persistence/db-init.ts +23 -7
- package/src/persistence/migrations/__tests__/run-migrations.test.ts +83 -8
- package/src/persistence/migrations/run-migrations.ts +101 -6
- package/src/runtime/middleware/error-handler.ts +14 -10
- package/src/runtime/routes/attachment-list-route.test.ts +961 -0
- package/src/runtime/routes/attachment-routes.ts +128 -0
- package/src/runtime/routes/debug-routes.ts +64 -0
- package/src/runtime/routes/gateway-log-routes.ts +1 -8
- package/src/runtime/routes/http-adapter.ts +8 -15
- package/src/runtime/routes/identity-routes.ts +13 -0
- package/src/runtime/routes/trust-rules-routes.ts +1 -8
- package/src/workspace/git-service.ts +217 -57
- package/src/workspace/heartbeat-service.ts +34 -9
package/AGENTS.md
CHANGED
|
@@ -30,7 +30,7 @@ DB migrations run asynchronously during startup: the HTTP server binds (so `/hea
|
|
|
30
30
|
|
|
31
31
|
Existing enforcement, which new code must not bypass:
|
|
32
32
|
|
|
33
|
-
- **HTTP** requests are gated per-route in `runtime/http-server.ts`; **IPC** methods in `ipc/assistant-server.ts`; both derive their exempt set from `DB_MIGRATION_READINESS_EXEMPT_OPERATIONS` in `daemon-readiness.ts` (health/liveness probes
|
|
33
|
+
- **HTTP** requests are gated per-route in `runtime/http-server.ts`; **IPC** methods in `ipc/assistant-server.ts`; both derive their exempt set from `DB_MIGRATION_READINESS_EXEMPT_OPERATIONS` in `daemon-readiness.ts` (health/liveness probes plus in-memory diagnostics such as `debug/database` — anything exempted must never touch the DB).
|
|
34
34
|
- **Message sinks** (`processMessage`, `processMessageInBackground`) guard via `assertDbMigrationsReadyForTurn()`.
|
|
35
35
|
- **Background sweeps** are started by lifecycle only after migrations settle (`startRuntimeHttpServerBackgroundSweeps`).
|
|
36
36
|
- The **migration-repair surface** (`admin/rollback-migrations` plus all `migrations/import*` / preflight transports and their job-status route) is additionally allowed in the terminal `failed` state only — see `DB_MIGRATION_FAILED_STATE_EXEMPT_OPERATIONS`. Never widen this to the `running` state: a rollback or import would race the in-flight migration runner. A successful repair does not clear the failed latch — the daemon must be restarted to re-run migrations and become ready.
|
|
@@ -340,7 +340,8 @@ type PendingRequest = {
|
|
|
340
340
|
export type PersistentIpcClientOptions = {
|
|
341
341
|
/**
|
|
342
342
|
* Additional delays (ms) before each reconnect attempt after a retryable
|
|
343
|
-
* connect failure. Pass `[]` to fail on the first miss (tests
|
|
343
|
+
* connect failure. Pass `[]` to fail on the first miss (tests and callers
|
|
344
|
+
* that already own a retry budget, such as classify_risk).
|
|
344
345
|
*/
|
|
345
346
|
connectRetryBackoffsMs?: readonly number[];
|
|
346
347
|
};
|
|
@@ -350,8 +351,9 @@ export type PersistentIpcClientOptions = {
|
|
|
350
351
|
* reconnection on failure. Multiplexes requests by ID so many concurrent
|
|
351
352
|
* callers can share one socket.
|
|
352
353
|
*
|
|
353
|
-
* Designed for
|
|
354
|
-
*
|
|
354
|
+
* Designed for multiplexed calls over one socket. Control-plane callers
|
|
355
|
+
* typically keep the default connect retries for sibling boot races.
|
|
356
|
+
* Callers that already own a retry budget pass `connectRetryBackoffsMs: []`.
|
|
355
357
|
*/
|
|
356
358
|
export class PersistentIpcClient {
|
|
357
359
|
private socket: Socket | null = null;
|
package/openapi.yaml
CHANGED
|
@@ -1713,6 +1713,99 @@ paths:
|
|
|
1713
1713
|
responses:
|
|
1714
1714
|
"204":
|
|
1715
1715
|
description: Successful response
|
|
1716
|
+
get:
|
|
1717
|
+
operationId: attachments_get
|
|
1718
|
+
summary: List conversation attachments
|
|
1719
|
+
description:
|
|
1720
|
+
Return metadata for every attachment linked to a conversation's messages, newest first, across fork
|
|
1721
|
+
lineage. Camera frames kept by live vision are flagged. Bytes come from GET /attachments/{id}/content.
|
|
1722
|
+
tags:
|
|
1723
|
+
- attachments
|
|
1724
|
+
parameters:
|
|
1725
|
+
- name: conversationId
|
|
1726
|
+
in: query
|
|
1727
|
+
required: true
|
|
1728
|
+
schema:
|
|
1729
|
+
type: string
|
|
1730
|
+
description: Conversation whose attachments to list
|
|
1731
|
+
- name: sightFrames
|
|
1732
|
+
in: query
|
|
1733
|
+
required: false
|
|
1734
|
+
schema:
|
|
1735
|
+
type: string
|
|
1736
|
+
enum:
|
|
1737
|
+
- only
|
|
1738
|
+
- exclude
|
|
1739
|
+
description: Restrict to camera frames, or leave them out
|
|
1740
|
+
- name: limit
|
|
1741
|
+
in: query
|
|
1742
|
+
required: false
|
|
1743
|
+
schema:
|
|
1744
|
+
type: integer
|
|
1745
|
+
description: Page size, default 200, max 1000
|
|
1746
|
+
- name: offset
|
|
1747
|
+
in: query
|
|
1748
|
+
required: false
|
|
1749
|
+
schema:
|
|
1750
|
+
type: integer
|
|
1751
|
+
responses:
|
|
1752
|
+
"200":
|
|
1753
|
+
description: Successful response
|
|
1754
|
+
content:
|
|
1755
|
+
application/json:
|
|
1756
|
+
schema:
|
|
1757
|
+
type: object
|
|
1758
|
+
properties:
|
|
1759
|
+
attachments:
|
|
1760
|
+
type: array
|
|
1761
|
+
items:
|
|
1762
|
+
type: object
|
|
1763
|
+
properties:
|
|
1764
|
+
id:
|
|
1765
|
+
type: string
|
|
1766
|
+
filename:
|
|
1767
|
+
type: string
|
|
1768
|
+
mimeType:
|
|
1769
|
+
type: string
|
|
1770
|
+
sizeBytes:
|
|
1771
|
+
type: number
|
|
1772
|
+
kind:
|
|
1773
|
+
type: string
|
|
1774
|
+
messageId:
|
|
1775
|
+
type: string
|
|
1776
|
+
createdAt:
|
|
1777
|
+
type: number
|
|
1778
|
+
description: "Capture time: the carrying message's created_at"
|
|
1779
|
+
fileBacked:
|
|
1780
|
+
type: boolean
|
|
1781
|
+
thumbnailData:
|
|
1782
|
+
type: string
|
|
1783
|
+
sightFrame:
|
|
1784
|
+
type: boolean
|
|
1785
|
+
ambientKeep:
|
|
1786
|
+
type: boolean
|
|
1787
|
+
required:
|
|
1788
|
+
- id
|
|
1789
|
+
- filename
|
|
1790
|
+
- mimeType
|
|
1791
|
+
- sizeBytes
|
|
1792
|
+
- kind
|
|
1793
|
+
- messageId
|
|
1794
|
+
- createdAt
|
|
1795
|
+
- sightFrame
|
|
1796
|
+
- ambientKeep
|
|
1797
|
+
additionalProperties: false
|
|
1798
|
+
total:
|
|
1799
|
+
type: number
|
|
1800
|
+
hasMore:
|
|
1801
|
+
type: boolean
|
|
1802
|
+
required:
|
|
1803
|
+
- attachments
|
|
1804
|
+
- total
|
|
1805
|
+
- hasMore
|
|
1806
|
+
additionalProperties: false
|
|
1807
|
+
"400":
|
|
1808
|
+
description: Missing or invalid query
|
|
1716
1809
|
post:
|
|
1717
1810
|
operationId: attachments_post
|
|
1718
1811
|
summary: Upload attachment
|
|
@@ -11081,6 +11174,71 @@ paths:
|
|
|
11081
11174
|
- exitCode
|
|
11082
11175
|
- timedOut
|
|
11083
11176
|
additionalProperties: false
|
|
11177
|
+
/v1/debug/database:
|
|
11178
|
+
get:
|
|
11179
|
+
operationId: debug_database_get
|
|
11180
|
+
summary: Database migration diagnostics
|
|
11181
|
+
description:
|
|
11182
|
+
Return the in-memory DB migration latch, including failed and deferred steps. Does not query SQLite, so it
|
|
11183
|
+
stays answerable while migrations are running or have failed.
|
|
11184
|
+
tags:
|
|
11185
|
+
- debug
|
|
11186
|
+
responses:
|
|
11187
|
+
"200":
|
|
11188
|
+
description: Successful response
|
|
11189
|
+
content:
|
|
11190
|
+
application/json:
|
|
11191
|
+
schema:
|
|
11192
|
+
type: object
|
|
11193
|
+
properties:
|
|
11194
|
+
ready:
|
|
11195
|
+
type: boolean
|
|
11196
|
+
state:
|
|
11197
|
+
type: string
|
|
11198
|
+
enum:
|
|
11199
|
+
- not_started
|
|
11200
|
+
- running
|
|
11201
|
+
- failed
|
|
11202
|
+
- ready
|
|
11203
|
+
reason:
|
|
11204
|
+
type: string
|
|
11205
|
+
error:
|
|
11206
|
+
type: string
|
|
11207
|
+
failed:
|
|
11208
|
+
type: array
|
|
11209
|
+
items:
|
|
11210
|
+
type: object
|
|
11211
|
+
properties:
|
|
11212
|
+
name:
|
|
11213
|
+
type: string
|
|
11214
|
+
error:
|
|
11215
|
+
type: string
|
|
11216
|
+
required:
|
|
11217
|
+
- name
|
|
11218
|
+
additionalProperties: false
|
|
11219
|
+
deferred:
|
|
11220
|
+
type: array
|
|
11221
|
+
items:
|
|
11222
|
+
type: object
|
|
11223
|
+
properties:
|
|
11224
|
+
name:
|
|
11225
|
+
type: string
|
|
11226
|
+
missing:
|
|
11227
|
+
type: array
|
|
11228
|
+
items:
|
|
11229
|
+
type: string
|
|
11230
|
+
required:
|
|
11231
|
+
- name
|
|
11232
|
+
- missing
|
|
11233
|
+
additionalProperties: false
|
|
11234
|
+
validationError:
|
|
11235
|
+
type: string
|
|
11236
|
+
required:
|
|
11237
|
+
- ready
|
|
11238
|
+
- state
|
|
11239
|
+
- failed
|
|
11240
|
+
- deferred
|
|
11241
|
+
additionalProperties: false
|
|
11084
11242
|
/v1/defer/cancel:
|
|
11085
11243
|
post:
|
|
11086
11244
|
operationId: defer_cancel_post
|
|
@@ -13442,6 +13600,35 @@ paths:
|
|
|
13442
13600
|
type: string
|
|
13443
13601
|
error:
|
|
13444
13602
|
type: string
|
|
13603
|
+
failedMigrations:
|
|
13604
|
+
type: array
|
|
13605
|
+
items:
|
|
13606
|
+
type: object
|
|
13607
|
+
properties:
|
|
13608
|
+
name:
|
|
13609
|
+
type: string
|
|
13610
|
+
error:
|
|
13611
|
+
type: string
|
|
13612
|
+
required:
|
|
13613
|
+
- name
|
|
13614
|
+
additionalProperties: false
|
|
13615
|
+
deferredMigrations:
|
|
13616
|
+
type: array
|
|
13617
|
+
items:
|
|
13618
|
+
type: object
|
|
13619
|
+
properties:
|
|
13620
|
+
name:
|
|
13621
|
+
type: string
|
|
13622
|
+
missing:
|
|
13623
|
+
type: array
|
|
13624
|
+
items:
|
|
13625
|
+
type: string
|
|
13626
|
+
required:
|
|
13627
|
+
- name
|
|
13628
|
+
- missing
|
|
13629
|
+
additionalProperties: false
|
|
13630
|
+
validationError:
|
|
13631
|
+
type: string
|
|
13445
13632
|
required:
|
|
13446
13633
|
- ready
|
|
13447
13634
|
- state
|
|
@@ -13647,6 +13834,35 @@ paths:
|
|
|
13647
13834
|
type: string
|
|
13648
13835
|
error:
|
|
13649
13836
|
type: string
|
|
13837
|
+
failedMigrations:
|
|
13838
|
+
type: array
|
|
13839
|
+
items:
|
|
13840
|
+
type: object
|
|
13841
|
+
properties:
|
|
13842
|
+
name:
|
|
13843
|
+
type: string
|
|
13844
|
+
error:
|
|
13845
|
+
type: string
|
|
13846
|
+
required:
|
|
13847
|
+
- name
|
|
13848
|
+
additionalProperties: false
|
|
13849
|
+
deferredMigrations:
|
|
13850
|
+
type: array
|
|
13851
|
+
items:
|
|
13852
|
+
type: object
|
|
13853
|
+
properties:
|
|
13854
|
+
name:
|
|
13855
|
+
type: string
|
|
13856
|
+
missing:
|
|
13857
|
+
type: array
|
|
13858
|
+
items:
|
|
13859
|
+
type: string
|
|
13860
|
+
required:
|
|
13861
|
+
- name
|
|
13862
|
+
- missing
|
|
13863
|
+
additionalProperties: false
|
|
13864
|
+
validationError:
|
|
13865
|
+
type: string
|
|
13650
13866
|
required:
|
|
13651
13867
|
- ready
|
|
13652
13868
|
- state
|
package/package.json
CHANGED
|
@@ -757,6 +757,7 @@ describe("AssistantConfigSchema", () => {
|
|
|
757
757
|
maxFileSizeBytes: 256000,
|
|
758
758
|
historyCompaction: { enabled: true },
|
|
759
759
|
interactiveGitTimeoutMs: 10000,
|
|
760
|
+
stageBatchSize: 1000,
|
|
760
761
|
enrichmentQueueSize: 50,
|
|
761
762
|
enrichmentConcurrency: 1,
|
|
762
763
|
enrichmentJobTimeoutMs: 30000,
|
|
@@ -811,6 +812,31 @@ describe("AssistantConfigSchema", () => {
|
|
|
811
812
|
expect(result.success).toBe(false);
|
|
812
813
|
});
|
|
813
814
|
|
|
815
|
+
test("applies workspaceGit.stageBatchSize default", () => {
|
|
816
|
+
expect(AssistantConfigSchema.parse({}).workspaceGit.stageBatchSize).toBe(
|
|
817
|
+
1000,
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("accepts custom workspaceGit.stageBatchSize", () => {
|
|
822
|
+
const result = AssistantConfigSchema.parse({
|
|
823
|
+
workspaceGit: { stageBatchSize: 250 },
|
|
824
|
+
});
|
|
825
|
+
expect(result.workspaceGit.stageBatchSize).toBe(250);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("rejects non-positive workspaceGit.stageBatchSize", () => {
|
|
829
|
+
const zeroResult = AssistantConfigSchema.safeParse({
|
|
830
|
+
workspaceGit: { stageBatchSize: 0 },
|
|
831
|
+
});
|
|
832
|
+
expect(zeroResult.success).toBe(false);
|
|
833
|
+
|
|
834
|
+
const negativeResult = AssistantConfigSchema.safeParse({
|
|
835
|
+
workspaceGit: { stageBatchSize: -1 },
|
|
836
|
+
});
|
|
837
|
+
expect(negativeResult.success).toBe(false);
|
|
838
|
+
});
|
|
839
|
+
|
|
814
840
|
test("applies sight defaults", () => {
|
|
815
841
|
expect(AssistantConfigSchema.parse({}).sight).toEqual({
|
|
816
842
|
keepLatestFrames: 2,
|
|
@@ -123,6 +123,49 @@ describe("DB migration readiness HTTP gate", () => {
|
|
|
123
123
|
expect(body.reason).toBe("db_migrations_failed");
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
test("continues serving debug/database while migrations are running", async () => {
|
|
127
|
+
setDbMigrating();
|
|
128
|
+
await startServer();
|
|
129
|
+
|
|
130
|
+
const response = await fetch(url("/debug/database"));
|
|
131
|
+
expect(response.status).toBe(200);
|
|
132
|
+
|
|
133
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
134
|
+
expect(body.ready).toBe(false);
|
|
135
|
+
expect(body.state).toBe("running");
|
|
136
|
+
expect(body.reason).toBe("db_migrations_running");
|
|
137
|
+
expect(body.failed).toEqual([]);
|
|
138
|
+
expect(body.deferred).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("continues serving debug/database when migrations have failed", async () => {
|
|
142
|
+
setDbMigrationFailed(new Error("boom"), {
|
|
143
|
+
failedMigrations: [{ name: "flakyStep", error: "transient failure" }],
|
|
144
|
+
deferredMigrations: [{ name: "dependentStep", missing: ["flakyStep"] }],
|
|
145
|
+
validationError: "schema mismatch",
|
|
146
|
+
});
|
|
147
|
+
await startServer();
|
|
148
|
+
|
|
149
|
+
const response = await fetch(url("/debug/database"));
|
|
150
|
+
expect(response.status).toBe(200);
|
|
151
|
+
|
|
152
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
153
|
+
expect(body.ready).toBe(false);
|
|
154
|
+
expect(body.state).toBe("failed");
|
|
155
|
+
expect(body.reason).toBe("db_migrations_failed");
|
|
156
|
+
expect(body.error).toBe("boom");
|
|
157
|
+
expect(body.failed).toEqual([
|
|
158
|
+
{ name: "flakyStep", error: "transient failure" },
|
|
159
|
+
]);
|
|
160
|
+
expect(body.deferred).toEqual([
|
|
161
|
+
{ name: "dependentStep", missing: ["flakyStep"] },
|
|
162
|
+
]);
|
|
163
|
+
expect(body.validationError).toBe("schema mismatch");
|
|
164
|
+
|
|
165
|
+
const conversations = await fetch(url("/conversations"));
|
|
166
|
+
expect(conversations.status).toBe(503);
|
|
167
|
+
});
|
|
168
|
+
|
|
126
169
|
test("blocks config schema while migrations are running", async () => {
|
|
127
170
|
setDbMigrating();
|
|
128
171
|
await startServer();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { IpcConnectError } from "@vellumai/gateway-client/ipc-client";
|
|
4
|
+
|
|
3
5
|
import { withErrorHandling } from "../runtime/middleware/error-handler.js";
|
|
4
6
|
import { ConfigError, ProviderNotConfiguredError } from "../util/errors.js";
|
|
5
7
|
|
|
@@ -42,4 +44,17 @@ describe("withErrorHandling – friendly error messages", () => {
|
|
|
42
44
|
};
|
|
43
45
|
expect(body.error.message).toBe("Twilio phone number not configured.");
|
|
44
46
|
});
|
|
47
|
+
|
|
48
|
+
test("IpcConnectError surfaces as 503 SERVICE_UNAVAILABLE", async () => {
|
|
49
|
+
const response = await withErrorHandling("test", async () => {
|
|
50
|
+
throw new IpcConnectError("connect ENOENT", "ENOENT");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(response.status).toBe(503);
|
|
54
|
+
const body = (await response.json()) as {
|
|
55
|
+
error: { code: string; message: string };
|
|
56
|
+
};
|
|
57
|
+
expect(body.error.code).toBe("SERVICE_UNAVAILABLE");
|
|
58
|
+
expect(body.error.message).toContain("Gateway is not reachable over IPC");
|
|
59
|
+
});
|
|
45
60
|
});
|
|
@@ -483,13 +483,18 @@ describe("identity routes — /readyz readiness gate", () => {
|
|
|
483
483
|
});
|
|
484
484
|
|
|
485
485
|
test("returns 503 when DB migrations fail", async () => {
|
|
486
|
-
setDbMigrationFailed(new Error("migration failed")
|
|
486
|
+
setDbMigrationFailed(new Error("migration failed"), {
|
|
487
|
+
failedMigrations: [{ name: "flakyStep", error: "transient failure" }],
|
|
488
|
+
});
|
|
487
489
|
const res = handleReadyz();
|
|
488
490
|
expect(res.status).toBe(503);
|
|
489
491
|
const body = (await res.json()) as Record<string, unknown>;
|
|
490
492
|
expect(body.ready).toBe(false);
|
|
491
493
|
expect(body.reason).toBe("db_migrations_failed");
|
|
492
494
|
expect((body.dbMigrations as Record<string, unknown>).state).toBe("failed");
|
|
495
|
+
expect((body.dbMigrations as Record<string, unknown>).failedMigrations).toEqual(
|
|
496
|
+
[{ name: "flakyStep", error: "transient failure" }],
|
|
497
|
+
);
|
|
493
498
|
});
|
|
494
499
|
|
|
495
500
|
test("returns 200 even if CES is down", async () => {
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
isDeadlineExpired,
|
|
26
26
|
WorkspaceGitService,
|
|
27
27
|
} from "../workspace/git-service.js";
|
|
28
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
28
29
|
|
|
29
30
|
describe("WorkspaceGitService", () => {
|
|
30
31
|
let testDir: string;
|
|
@@ -356,6 +357,174 @@ describe("WorkspaceGitService", () => {
|
|
|
356
357
|
});
|
|
357
358
|
});
|
|
358
359
|
|
|
360
|
+
describe("batched add and commit", () => {
|
|
361
|
+
const withStageBatchSize = async (
|
|
362
|
+
size: number,
|
|
363
|
+
fn: () => Promise<void>,
|
|
364
|
+
) => {
|
|
365
|
+
const configPath = getWorkspaceConfigPath();
|
|
366
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
367
|
+
const previous = existsSync(configPath)
|
|
368
|
+
? readFileSync(configPath, "utf-8")
|
|
369
|
+
: null;
|
|
370
|
+
setConfig("workspaceGit", { stageBatchSize: size });
|
|
371
|
+
try {
|
|
372
|
+
await fn();
|
|
373
|
+
} finally {
|
|
374
|
+
if (previous === null) {
|
|
375
|
+
rmSync(configPath, { force: true });
|
|
376
|
+
} else {
|
|
377
|
+
writeFileSync(configPath, previous);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const commitFileLists = (skipInitial = true): string[][] => {
|
|
383
|
+
const hashes = execFileSync("git", ["log", "--pretty=%H", "--reverse"], {
|
|
384
|
+
cwd: testDir,
|
|
385
|
+
encoding: "utf-8",
|
|
386
|
+
})
|
|
387
|
+
.trim()
|
|
388
|
+
.split("\n")
|
|
389
|
+
.filter(Boolean);
|
|
390
|
+
const selected = skipInitial ? hashes.slice(1) : hashes;
|
|
391
|
+
return selected.map((hash) =>
|
|
392
|
+
execFileSync(
|
|
393
|
+
"git",
|
|
394
|
+
["diff-tree", "--no-commit-id", "--name-only", "-r", hash],
|
|
395
|
+
{ cwd: testDir, encoding: "utf-8" },
|
|
396
|
+
)
|
|
397
|
+
.trim()
|
|
398
|
+
.split("\n")
|
|
399
|
+
.filter(Boolean),
|
|
400
|
+
);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const trackedFiles = () =>
|
|
404
|
+
execFileSync("git", ["ls-files"], { cwd: testDir, encoding: "utf-8" })
|
|
405
|
+
.trim()
|
|
406
|
+
.split("\n")
|
|
407
|
+
.filter(Boolean);
|
|
408
|
+
|
|
409
|
+
test("splits a large dirty set into multiple add+commit batches", async () => {
|
|
410
|
+
const service = new WorkspaceGitService(testDir);
|
|
411
|
+
await service.ensureInitialized();
|
|
412
|
+
|
|
413
|
+
const names = ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"];
|
|
414
|
+
for (const name of names) {
|
|
415
|
+
writeFileSync(join(testDir, name), name);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
await withStageBatchSize(2, async () => {
|
|
419
|
+
await service.commitChanges("Add files in batches");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const batches = commitFileLists();
|
|
423
|
+
expect(batches).toHaveLength(3);
|
|
424
|
+
expect(batches[0]).toHaveLength(2);
|
|
425
|
+
expect(batches[1]).toHaveLength(2);
|
|
426
|
+
expect(batches[2]).toHaveLength(1);
|
|
427
|
+
expect(batches.flat().sort()).toEqual(names);
|
|
428
|
+
for (const name of names) {
|
|
429
|
+
expect(trackedFiles()).toContain(name);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const messages = execFileSync("git", ["log", "--pretty=%B", "--reverse"], {
|
|
433
|
+
cwd: testDir,
|
|
434
|
+
encoding: "utf-8",
|
|
435
|
+
});
|
|
436
|
+
expect(messages).toContain("batch: 1/3");
|
|
437
|
+
expect(messages).toContain("batch: 2/3");
|
|
438
|
+
expect(messages).toContain("batch: 3/3");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("unstage-first keeps an already-staged dump from landing in one commit", async () => {
|
|
442
|
+
const service = new WorkspaceGitService(testDir);
|
|
443
|
+
await service.ensureInitialized();
|
|
444
|
+
|
|
445
|
+
const names = ["w.txt", "x.txt", "y.txt", "z.txt"];
|
|
446
|
+
for (const name of names) {
|
|
447
|
+
writeFileSync(join(testDir, name), name);
|
|
448
|
+
}
|
|
449
|
+
execFileSync("git", ["add", "-A"], { cwd: testDir });
|
|
450
|
+
|
|
451
|
+
await withStageBatchSize(2, async () => {
|
|
452
|
+
await service.commitChanges("Split pre-staged files");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const batches = commitFileLists();
|
|
456
|
+
expect(batches).toHaveLength(2);
|
|
457
|
+
expect(batches[0]).toHaveLength(2);
|
|
458
|
+
expect(batches[1]).toHaveLength(2);
|
|
459
|
+
expect(batches.flat().sort()).toEqual(names);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("commitIfDirty batches the same way as commitChanges", async () => {
|
|
463
|
+
const service = new WorkspaceGitService(testDir);
|
|
464
|
+
await service.ensureInitialized();
|
|
465
|
+
|
|
466
|
+
for (const name of ["p.txt", "q.txt", "r.txt"]) {
|
|
467
|
+
writeFileSync(join(testDir, name), name);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
await withStageBatchSize(2, async () => {
|
|
471
|
+
const result = await service.commitIfDirty(() => ({
|
|
472
|
+
message: "Dirty batches",
|
|
473
|
+
}));
|
|
474
|
+
expect(result.committed).toBe(true);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const batches = commitFileLists();
|
|
478
|
+
expect(batches).toHaveLength(2);
|
|
479
|
+
expect(batches.flat().sort()).toEqual(["p.txt", "q.txt", "r.txt"]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("batched commits still skip oversized files", async () => {
|
|
483
|
+
const service = new WorkspaceGitService(testDir);
|
|
484
|
+
await service.ensureInitialized();
|
|
485
|
+
const big = Buffer.alloc(256001, 120);
|
|
486
|
+
|
|
487
|
+
writeFileSync(join(testDir, "one.txt"), "one");
|
|
488
|
+
writeFileSync(join(testDir, "two.txt"), "two");
|
|
489
|
+
writeFileSync(join(testDir, "three.txt"), "three");
|
|
490
|
+
writeFileSync(join(testDir, "huge.bin"), big);
|
|
491
|
+
|
|
492
|
+
await withStageBatchSize(2, async () => {
|
|
493
|
+
await service.commitChanges("Mixed size batches");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(trackedFiles()).toContain("one.txt");
|
|
497
|
+
expect(trackedFiles()).toContain("two.txt");
|
|
498
|
+
expect(trackedFiles()).toContain("three.txt");
|
|
499
|
+
expect(trackedFiles()).not.toContain("huge.bin");
|
|
500
|
+
expect(existsSync(join(testDir, "huge.bin"))).toBe(true);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("batched commits include deletions", async () => {
|
|
504
|
+
const service = new WorkspaceGitService(testDir);
|
|
505
|
+
await service.ensureInitialized();
|
|
506
|
+
|
|
507
|
+
for (const name of ["keep.txt", "gone-a.txt", "gone-b.txt", "gone-c.txt"]) {
|
|
508
|
+
writeFileSync(join(testDir, name), name);
|
|
509
|
+
}
|
|
510
|
+
await service.commitChanges("Seed files");
|
|
511
|
+
|
|
512
|
+
rmSync(join(testDir, "gone-a.txt"));
|
|
513
|
+
rmSync(join(testDir, "gone-b.txt"));
|
|
514
|
+
rmSync(join(testDir, "gone-c.txt"));
|
|
515
|
+
writeFileSync(join(testDir, "keep.txt"), "updated");
|
|
516
|
+
|
|
517
|
+
await withStageBatchSize(2, async () => {
|
|
518
|
+
await service.commitChanges("Delete in batches");
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(trackedFiles()).toContain("keep.txt");
|
|
522
|
+
expect(trackedFiles()).not.toContain("gone-a.txt");
|
|
523
|
+
expect(trackedFiles()).not.toContain("gone-b.txt");
|
|
524
|
+
expect(trackedFiles()).not.toContain("gone-c.txt");
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
359
528
|
describe("getStatus", () => {
|
|
360
529
|
test("returns clean status for new workspace", async () => {
|
|
361
530
|
const service = new WorkspaceGitService(testDir);
|
|
@@ -2100,7 +2269,7 @@ describe("WorkspaceGitService", () => {
|
|
|
2100
2269
|
await service.ensureInitialized();
|
|
2101
2270
|
|
|
2102
2271
|
// A loose blob with no referencing commit — e.g. an external
|
|
2103
|
-
// `git add` that
|
|
2272
|
+
// `git add` that staging later reset out of the index.
|
|
2104
2273
|
const blobSha = execFileSync("git", ["hash-object", "-w", "--stdin"], {
|
|
2105
2274
|
cwd: testDir,
|
|
2106
2275
|
input: bigContent(),
|
|
@@ -23,10 +23,15 @@ import type {
|
|
|
23
23
|
import {
|
|
24
24
|
_getConsecutiveFailures,
|
|
25
25
|
_resetGitServiceRegistry,
|
|
26
|
+
getWorkspaceGitService,
|
|
26
27
|
WorkspaceGitService,
|
|
27
28
|
} from "../workspace/git-service.js";
|
|
28
29
|
import {
|
|
30
|
+
_resetHeartbeatServiceForTests,
|
|
29
31
|
_resetHeartbeatState,
|
|
32
|
+
commitAllPendingWorkspaceChanges,
|
|
33
|
+
startWorkspaceHeartbeatService,
|
|
34
|
+
stopWorkspaceHeartbeatService,
|
|
30
35
|
WorkspaceHeartbeatService,
|
|
31
36
|
} from "../workspace/heartbeat-service.js";
|
|
32
37
|
|
|
@@ -570,4 +575,40 @@ describe("WorkspaceHeartbeatService", () => {
|
|
|
570
575
|
expect(capturedCtx!.changedFiles).toContain("shutdown-file.txt");
|
|
571
576
|
});
|
|
572
577
|
});
|
|
578
|
+
|
|
579
|
+
describe("process singleton", () => {
|
|
580
|
+
afterEach(async () => {
|
|
581
|
+
await _resetHeartbeatServiceForTests();
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("stopWorkspaceHeartbeatService is a no-op when never started", async () => {
|
|
585
|
+
await _resetHeartbeatServiceForTests();
|
|
586
|
+
await stopWorkspaceHeartbeatService();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("startWorkspaceHeartbeatService is idempotent", async () => {
|
|
590
|
+
await _resetHeartbeatServiceForTests();
|
|
591
|
+
startWorkspaceHeartbeatService();
|
|
592
|
+
startWorkspaceHeartbeatService();
|
|
593
|
+
await stopWorkspaceHeartbeatService();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("commitAllPendingWorkspaceChanges constructs on demand", async () => {
|
|
597
|
+
await _resetHeartbeatServiceForTests();
|
|
598
|
+
_resetGitServiceRegistry();
|
|
599
|
+
const registered = getWorkspaceGitService(testDir);
|
|
600
|
+
await registered.ensureInitialized();
|
|
601
|
+
writeFileSync(join(testDir, "shutdown-on-demand.txt"), "pending");
|
|
602
|
+
|
|
603
|
+
await commitAllPendingWorkspaceChanges();
|
|
604
|
+
|
|
605
|
+
const commitMsg = execFileSync("git", ["log", "-1", "--pretty=%B"], {
|
|
606
|
+
cwd: testDir,
|
|
607
|
+
encoding: "utf-8",
|
|
608
|
+
});
|
|
609
|
+
expect(commitMsg).toContain("auto-commit");
|
|
610
|
+
expect(commitMsg).toContain("shutdown");
|
|
611
|
+
expect(commitMsg).toContain("safety net");
|
|
612
|
+
});
|
|
613
|
+
});
|
|
573
614
|
});
|
|
@@ -21,6 +21,7 @@ Organize the user's conversations into sidebar groups. Groups are the sections s
|
|
|
21
21
|
|
|
22
22
|
## Usage
|
|
23
23
|
|
|
24
|
+
- When mentioning a conversation in Vellum chat, link it as `[Title](/assistant/conversations/<id>)`. Use that path only in Vellum chat. Do not use it in Slack, Telegram, Discord, email, or notifications.
|
|
24
25
|
- `conversation_move_to_group` defaults to the **current** conversation, so "file this chat under Work" needs no conversation id.
|
|
25
26
|
- Groups can be referenced by name (case-insensitive) or id. Prefer names; fall back to ids when names are ambiguous.
|
|
26
27
|
- When the user asks to organize their conversations, check the existing groups first (`conversation_group_list`) and reuse a fitting group rather than creating near-duplicates ("Work" vs "Work stuff").
|