@openclaw-cloud/agent-controller 0.2.2 → 0.2.4

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.
@@ -1,8 +1,54 @@
1
1
  import os from 'node:os';
2
2
 
3
- import { getMetrics, startHeartbeat } from '../src/heartbeat';
3
+ import { getMetrics, getLastMessageAt, startHeartbeat } from '../src/heartbeat';
4
4
  import type { AgentApi } from '../src/api';
5
5
 
6
+ jest.mock('node:fs/promises', () => ({
7
+ readdir: jest.fn(),
8
+ stat: jest.fn(),
9
+ }));
10
+
11
+ import fsMock from 'node:fs/promises';
12
+ const fsPromises = fsMock as jest.Mocked<typeof fsMock>;
13
+
14
+ describe('getLastMessageAt', () => {
15
+ const origEnv = process.env.OPENCLAW_STATE_DIR;
16
+
17
+ afterEach(() => {
18
+ process.env.OPENCLAW_STATE_DIR = origEnv;
19
+ jest.clearAllMocks();
20
+ });
21
+
22
+ it('returns null when sessions directory does not exist', async () => {
23
+ fsPromises.readdir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
24
+ const result = await getLastMessageAt();
25
+ expect(result).toBeNull();
26
+ });
27
+
28
+ it('returns null when no .jsonl files are present', async () => {
29
+ fsPromises.readdir.mockResolvedValue(['session.txt', 'other.json'] as unknown as Awaited<ReturnType<typeof fsPromises.readdir>>);
30
+ const result = await getLastMessageAt();
31
+ expect(result).toBeNull();
32
+ });
33
+
34
+ it('returns ISO string of the most recent mtime when .jsonl files are present', async () => {
35
+ process.env.OPENCLAW_STATE_DIR = '/test/state';
36
+ fsPromises.readdir.mockResolvedValue(['a.jsonl', 'b.jsonl'] as unknown as Awaited<ReturnType<typeof fsPromises.readdir>>);
37
+ const t1 = new Date('2024-01-01T10:00:00.000Z').getTime();
38
+ const t2 = new Date('2024-01-02T12:00:00.000Z').getTime();
39
+ fsPromises.stat
40
+ .mockResolvedValueOnce({ mtime: new Date(t1) } as unknown as Awaited<ReturnType<typeof fsPromises.stat>>)
41
+ .mockResolvedValueOnce({ mtime: new Date(t2) } as unknown as Awaited<ReturnType<typeof fsPromises.stat>>);
42
+ const result = await getLastMessageAt();
43
+ expect(result).toBe('2024-01-02T12:00:00.000Z');
44
+ });
45
+
46
+ it('does not throw on any error', async () => {
47
+ fsPromises.readdir.mockRejectedValue(new Error('unexpected'));
48
+ await expect(getLastMessageAt()).resolves.toBeNull();
49
+ });
50
+ });
51
+
6
52
  describe('getMetrics', () => {
7
53
  it('returns cpu, mem, uptime', () => {
8
54
  const metrics = getMetrics();
@@ -37,11 +83,14 @@ describe('startHeartbeat', () => {
37
83
  jest.useRealTimers();
38
84
  });
39
85
 
40
- it('publishes heartbeat immediately and at intervals', () => {
86
+ it('publishes heartbeat immediately and at intervals', async () => {
41
87
  const publishHeartbeat = jest.fn().mockResolvedValue(undefined);
42
88
  const api = makeApi(publishHeartbeat);
43
89
 
44
90
  const timer = startHeartbeat({ api, agentId: 'agent-42' });
91
+ // Flush microtasks so async getLastMessageAt() resolves before api.publishHeartbeat is called
92
+ await Promise.resolve();
93
+ await Promise.resolve();
45
94
 
46
95
  // Immediate call
47
96
  expect(publishHeartbeat).toHaveBeenCalledTimes(1);
@@ -55,6 +104,8 @@ describe('startHeartbeat', () => {
55
104
 
56
105
  // After 30s
57
106
  jest.advanceTimersByTime(30_000);
107
+ await Promise.resolve();
108
+ await Promise.resolve();
58
109
  expect(publishHeartbeat).toHaveBeenCalledTimes(2);
59
110
 
60
111
  clearInterval(timer);
@@ -1,5 +1,6 @@
1
1
  import type { AgentApi } from './api.js';
2
2
  import type { BoardState, HeartbeatPayload } from './types.js';
3
+ export declare function getLastMessageAt(): Promise<string | null>;
3
4
  export interface HeartbeatOptions {
4
5
  api: AgentApi;
5
6
  agentId: string;
package/dist/heartbeat.js CHANGED
@@ -1,6 +1,24 @@
1
1
  import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
2
4
  import { debugLog } from './debug.js';
3
5
  const HEARTBEAT_INTERVAL = 30_000;
6
+ export async function getLastMessageAt() {
7
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim()
8
+ ?? path.join(os.homedir(), '.openclaw');
9
+ const sessionsDir = path.join(stateDir, 'agents', 'main', 'sessions');
10
+ try {
11
+ const files = await fs.readdir(sessionsDir);
12
+ const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
13
+ if (jsonlFiles.length === 0)
14
+ return null;
15
+ const mtimes = await Promise.all(jsonlFiles.map((f) => fs.stat(path.join(sessionsDir, f)).then((s) => s.mtime.getTime())));
16
+ return new Date(Math.max(...mtimes)).toISOString();
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
4
22
  function getMetrics() {
5
23
  const cpus = os.cpus();
6
24
  const cpuUsage = cpus.reduce((acc, cpu) => {
@@ -22,6 +40,7 @@ async function publishHeartbeat(opts) {
22
40
  ts: Date.now(),
23
41
  metrics: getMetrics(),
24
42
  ...(opts.getBoardStatus ? { boardStatus: opts.getBoardStatus() } : {}),
43
+ lastMessageAt: await getLastMessageAt(),
25
44
  };
26
45
  try {
27
46
  debugLog('heartbeat', '→ sending', opts.agentId, JSON.stringify(payload).slice(0, 500));
@@ -1 +1 @@
1
- {"version":3,"file":"heartbeat.js","sourceRoot":"","sources":["../src/heartbeat.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAItC,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAQlC,SAAS,UAAU;IACjB,MAAM,IAAI,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC;IACvB,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QACxC,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QAClE,OAAO,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC;IAC5C,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC;IAEpB,MAAM,QAAQ,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC;IAC/B,MAAM,OAAO,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC;IAE7B,OAAO;QACL,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,QAAQ,GAAG,GAAG,CAAC,GAAG,GAAG;QACrC,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,GAAG,OAAO,CAAC,GAAG,QAAQ,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG;QAC9D,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE;KACpB,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,gBAAgB,CAAC,IAAsB;IACpD,MAAM,OAAO,GAAqB;QAChC,IAAI,EAAE,WAAW;QACjB,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE;QACd,OAAO,EAAE,UAAU,EAAE;QACrB,GAAG,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvE,CAAC;IAEF,IAAI,CAAC;QACH,QAAQ,CAAC,WAAW,EAAE,WAAW,EAAE,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;QACxF,MAAM,IAAI,CAAC,GAAG,CAAC,gBAAgB,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACvD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;IAChC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QACrF,QAAQ,CAAC,WAAW,EAAE,SAAS,EAAE,GAAG,CAAC,CAAC;IACxC,CAAC;AACH,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,IAAsB;IACnD,MAAM,IAAI,GAAG,GAAG,EAAE,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC/D,IAAI,EAAE,CAAC;IACP,OAAO,WAAW,CAAC,IAAI,EAAE,kBAAkB,CAAC,CAAC;AAC/C,CAAC;AAED,OAAO,EAAE,UAAU,EAAE,CAAC"}
1
+ {"version":3,"file":"heartbeat.js","sourceRoot":"","sources":["../src/heartbeat.ts"],"names":[],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -26,6 +26,7 @@ export interface HeartbeatPayload {
26
26
  uptime: number;
27
27
  };
28
28
  boardStatus?: BoardState;
29
+ lastMessageAt?: string | null;
29
30
  }
30
31
  export interface BoardState {
31
32
  state: 'idle' | 'working';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw-cloud/agent-controller",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,9 +1,14 @@
1
1
  import { exec } from 'node:child_process';
2
2
  import fs from 'node:fs/promises';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import type { AgentCommand, AgentResponse } from '../types.js';
5
6
 
6
- const WORKSPACE_DIR = '/home/node/.openclaw/workspace';
7
+ function getWorkspaceDir(): string {
8
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() ?? path.join(os.homedir(), '.openclaw');
9
+ return path.join(stateDir, 'workspace');
10
+ }
11
+
7
12
  const BACKUP_DIR = '/tmp';
8
13
  const TAR_TIMEOUT = 120_000;
9
14
  const UPLOAD_TIMEOUT = 120_000;
@@ -47,9 +52,10 @@ export function handleBackup(command: AgentCommand): Promise<AgentResponse> {
47
52
  }
48
53
 
49
54
  function createArchive(archivePath: string): Promise<void> {
55
+ const workspaceDir = getWorkspaceDir();
50
56
  return new Promise((resolve, reject) => {
51
57
  exec(
52
- `tar -czf ${archivePath} -C ${path.dirname(WORKSPACE_DIR)} ${path.basename(WORKSPACE_DIR)}`,
58
+ `tar -czf ${archivePath} -C ${path.dirname(workspaceDir)} ${path.basename(workspaceDir)}`,
53
59
  { timeout: TAR_TIMEOUT },
54
60
  (error, _stdout, stderr) => {
55
61
  if (error) {
package/src/heartbeat.ts CHANGED
@@ -1,10 +1,31 @@
1
1
  import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
2
4
  import { debugLog } from './debug.js';
3
5
  import type { AgentApi } from './api.js';
4
6
  import type { BoardState, HeartbeatPayload } from './types.js';
5
7
 
6
8
  const HEARTBEAT_INTERVAL = 30_000;
7
9
 
10
+ export async function getLastMessageAt(): Promise<string | null> {
11
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim()
12
+ ?? path.join(os.homedir(), '.openclaw');
13
+ const sessionsDir = path.join(stateDir, 'agents', 'main', 'sessions');
14
+ try {
15
+ const files = await fs.readdir(sessionsDir);
16
+ const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
17
+ if (jsonlFiles.length === 0) return null;
18
+ const mtimes = await Promise.all(
19
+ jsonlFiles.map((f) =>
20
+ fs.stat(path.join(sessionsDir, f)).then((s) => s.mtime.getTime())
21
+ )
22
+ );
23
+ return new Date(Math.max(...mtimes)).toISOString();
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
8
29
  export interface HeartbeatOptions {
9
30
  api: AgentApi;
10
31
  agentId: string;
@@ -35,6 +56,7 @@ async function publishHeartbeat(opts: HeartbeatOptions): Promise<void> {
35
56
  ts: Date.now(),
36
57
  metrics: getMetrics(),
37
58
  ...(opts.getBoardStatus ? { boardStatus: opts.getBoardStatus() } : {}),
59
+ lastMessageAt: await getLastMessageAt(),
38
60
  };
39
61
 
40
62
  try {
package/src/types.ts CHANGED
@@ -33,6 +33,7 @@ export interface HeartbeatPayload {
33
33
  uptime: number;
34
34
  };
35
35
  boardStatus?: BoardState;
36
+ lastMessageAt?: string | null;
36
37
  }
37
38
 
38
39
  export interface BoardState {